import { useEffect, useRef } from "react"
import type { Override } from "framer"
const DEFAULT_MESSAGES = {
valueMissing: "Por favor, preencha este campo.",
typeMismatchEmail: "Digite um e-mail válido.",
typeMismatchUrl: "Digite uma URL válida.",
patternMismatch: "O formato não corresponde ao esperado.",
tooShort: "O valor está muito curto.",
tooLong: "O valor está muito longo.",
rangeUnderflow: "O valor é muito baixo.",
rangeOverflow: "O valor é muito alto.",
stepMismatch: "O valor não corresponde ao passo definido.",
badInput: "Valor inválido.",
generic: "Verifique este campo.",
}
function makeTooltip() {
const el = document.createElement("div")
el.className = "framer-custom-tooltip"
Object.assign(el.style, {
position: "fixed",
zIndex: "999999",
maxWidth: "260px",
fontFamily:
"Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
fontSize: "12px",
lineHeight: "1.3",
background: "#0055ff",
color: "#fff",
padding: "8px 10px",
borderRadius: "8px",
boxShadow: "0 6px 24px rgba(0,0,0,0.25)",
pointerEvents: "none",
transform: "translateY(-6px)",
transition: "opacity 120ms ease, transform 120ms ease",
opacity: "0",
})
document.body.appendChild(el)
requestAnimationFrame(() => {
el.style.opacity = "1"
el.style.transform = "translateY(0)"
})
return el
}
function destroyTooltip(el?: HTMLElement | null) {
if (el && el.parentNode) el.parentNode.removeChild(el)
}
function getMessageFor(
input: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
) {
const custom = input.getAttribute("data-error")
if (custom) return custom
const v = input.validity
if (v.valueMissing) return DEFAULT_MESSAGES.valueMissing
if (v.typeMismatch && (input as HTMLInputElement).type === "email")
return DEFAULT_MESSAGES.typeMismatchEmail
if (v.typeMismatch && (input as HTMLInputElement).type === "url")
return DEFAULT_MESSAGES.typeMismatchUrl
if (v.patternMismatch) return DEFAULT_MESSAGES.patternMismatch
if (v.tooShort) return DEFAULT_MESSAGES.tooShort
if (v.tooLong) return DEFAULT_MESSAGES.tooLong
if (v.rangeUnderflow) return DEFAULT_MESSAGES.rangeUnderflow
if (v.rangeOverflow) return DEFAULT_MESSAGES.rangeOverflow
if (v.stepMismatch) return DEFAULT_MESSAGES.stepMismatch
if (v.badInput) return DEFAULT_MESSAGES.badInput
const ph = (input as HTMLInputElement).placeholder
if (ph) return ph
return DEFAULT_MESSAGES.generic
}
function positionTooltipNear(input: Element, tip: HTMLElement) {
const r = input.getBoundingClientRect()
const margin = 8
let top = r.top - tip.offsetHeight - margin
let left = r.left + Math.min(16, Math.max(0, r.width - tip.offsetWidth))
if (top < 8) top = r.bottom + margin
left = Math.max(8, Math.min(left, window.innerWidth - tip.offsetWidth - 8))
tip.style.top = `${Math.round(top)}px`
tip.style.left = `${Math.round(left)}px`
}
export function FormWithCustomTooltips(): Override {
const ref = useRef<HTMLElement | null>(null)
const tipRef = useRef<HTMLElement | null>(null)
const currentFieldRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const formEl = (
ref.current?.tagName === "FORM"
? ref.current
: ref.current?.closest("form")
) as HTMLFormElement | null
if (!formEl) return
formEl.setAttribute("novalidate", "true")
const allFields = formEl.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>("input, textarea, select")
const show = (
field: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
) => {
const msg = getMessageFor(field)
field.setCustomValidity(msg)
destroyTooltip(tipRef.current)
const tip = makeTooltip()
tip.textContent = msg
positionTooltipNear(field, tip)
tipRef.current = tip
currentFieldRef.current = field
}
const hide = () => {
destroyTooltip(tipRef.current)
tipRef.current = null
currentFieldRef.current = null
}
const onInput = (e: Event) => {
const field = e.target as
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
field.setCustomValidity("")
hide()
}
const onInvalid = (e: Event) => {
e.preventDefault()
const field = e.target as
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
show(field)
}
const onSubmit = (e: Event) => {
hide()
const isValid = formEl.checkValidity()
if (!isValid) {
e.preventDefault()
e.stopPropagation()
const firstInvalid = Array.from(allFields).find(
(f) => !f.checkValidity()
)
if (firstInvalid) {
firstInvalid.focus({ preventScroll: false })
show(firstInvalid)
}
return false
}
}
const handleResize = () => {
if (tipRef.current && currentFieldRef.current) {
positionTooltipNear(currentFieldRef.current, tipRef.current)
}
}
const handleScroll = () => {
if (tipRef.current && currentFieldRef.current) {
positionTooltipNear(currentFieldRef.current, tipRef.current)
}
}
allFields.forEach((f) => {
f.addEventListener("invalid", onInvalid)
f.addEventListener("input", onInput)
f.addEventListener("blur", onInput)
})
formEl.addEventListener("submit", onSubmit)
window.addEventListener("resize", handleResize)
window.addEventListener("scroll", handleScroll, true)
return () => {
hide()
allFields.forEach((f) => {
f.removeEventListener("invalid", onInvalid)
f.removeEventListener("input", onInput)
f.removeEventListener("blur", onInput)
})
formEl.removeEventListener("submit", onSubmit)
window.removeEventListener("resize", handleResize)
window.removeEventListener("scroll", handleScroll, true)
}
}, [])
return {
ref: (node: HTMLElement | null) => {
ref.current = node
},
}
}