{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"inline-validation","type":"registry:ui","title":"Inline Validation","description":"Error message that does not shove the form.","dependencies":["motion"],"categories":["input"],"docs":"https://www.interior.dev/docs/inline-validation","files":[{"path":"registry/interior/inline-validation.tsx","type":"registry:ui","target":"components/interior/inline-validation.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nconst LINE = 16;\n\nexport type ValidationStatus = \"idle\" | \"pending\" | \"valid\" | \"invalid\";\n\nexport type Validator = (value: string) => string | null;\n\nexport type UseInlineValidationOptions = {\n  value: string;\n  validate: Validator;\n  debounce?: number;\n};\n\nexport type UseInlineValidationReturn = {\n  status: ValidationStatus;\n  error: string | null;\n  message: string;\n  touched: boolean;\n  commit: () => void;\n  reset: () => void;\n  fieldProps: {\n    onBlur: () => void;\n    \"aria-invalid\": boolean;\n  };\n};\n\ntype Settled = {\n  status: ValidationStatus;\n  error: string | null;\n  message: string;\n};\n\nconst CLEAN: Settled = { status: \"idle\", error: null, message: \"\" };\n\nexport function useInlineValidation({\n  value,\n  validate,\n  debounce = 400,\n}: UseInlineValidationOptions): UseInlineValidationReturn {\n  const [touched, setTouched] = useState(false);\n  const [settled, setSettled] = useState<Settled>(CLEAN);\n\n  const check = useRef(validate);\n  const latest = useRef(value);\n\n  useEffect(() => {\n    check.current = validate;\n    latest.current = value;\n  });\n\n  useEffect(() => {\n    if (!touched) return;\n\n    const next = check.current(value);\n    const resolved: ValidationStatus = value.length > 0 ? \"valid\" : \"idle\";\n\n    if (next === null) {\n      setSettled((prev) =>\n        prev.status === resolved && prev.error === null\n          ? prev\n          : { status: resolved, error: null, message: prev.message },\n      );\n      return;\n    }\n\n    setSettled((prev) =>\n      prev.status === \"invalid\"\n        ? prev\n        : { status: \"pending\", error: null, message: prev.message },\n    );\n\n    const t = setTimeout(() => {\n      setSettled((prev) =>\n        prev.error === next ? prev : { status: \"invalid\", error: next, message: next },\n      );\n    }, debounce);\n\n    return () => clearTimeout(t);\n  }, [value, touched, debounce]);\n\n  const commit = useCallback(() => {\n    setTouched(true);\n    const v = latest.current;\n    const next = check.current(v);\n    setSettled((prev) =>\n      next === null\n        ? { status: v.length > 0 ? \"valid\" : \"idle\", error: null, message: prev.message }\n        : { status: \"invalid\", error: next, message: next },\n    );\n  }, []);\n\n  const reset = useCallback(() => {\n    setTouched(false);\n    setSettled(CLEAN);\n  }, []);\n\n  return {\n    status: settled.status,\n    error: settled.error,\n    message: settled.message,\n    touched,\n    commit,\n    reset,\n    fieldProps: { onBlur: commit, \"aria-invalid\": settled.status === \"invalid\" },\n  };\n}\n\nexport type InlineValidationProps = {\n  label: string;\n  value: string;\n  onChange: (value: string) => void;\n  validate: Validator;\n  hint?: string;\n  id?: string;\n  name?: string;\n  type?: \"text\" | \"email\" | \"password\" | \"tel\" | \"url\" | \"search\";\n  placeholder?: string;\n  autoComplete?: string;\n  inputMode?: React.ComponentProps<\"input\">[\"inputMode\"];\n  debounce?: number;\n  reserveLines?: number;\n  disabled?: boolean;\n  required?: boolean;\n  className?: string;\n};\n\nexport function InlineValidation({\n  label,\n  value,\n  onChange,\n  validate,\n  hint,\n  id,\n  name,\n  type = \"text\",\n  placeholder,\n  autoComplete,\n  inputMode,\n  debounce = 400,\n  reserveLines = 1,\n  disabled = false,\n  required = false,\n  className = \"\",\n}: InlineValidationProps) {\n  const reduced = useReducedMotion();\n  const fade = reduced ? INSTANT : CROSSFADE;\n\n  const auto = useId();\n  const fieldId = id ?? `${auto}-field`;\n  const hintId = `${auto}-hint`;\n  const errorId = `${auto}-error`;\n\n  const { status, error, message, fieldProps } = useInlineValidation({\n    value,\n    validate,\n    debounce,\n  });\n\n  const invalid = status === \"invalid\";\n  const valid = status === \"valid\";\n\n  const described = [hint ? hintId : null, invalid ? errorId : null]\n    .filter(Boolean)\n    .join(\" \");\n\n  const clamp = {\n    display: \"-webkit-box\" as const,\n    WebkitBoxOrient: \"vertical\" as const,\n    WebkitLineClamp: reserveLines,\n    overflow: \"hidden\" as const,\n  };\n\n  return (\n    <div className={`w-full ${className}`}>\n      <label\n        htmlFor={fieldId}\n        className=\"block text-[13px] font-medium text-stone-700 dark:text-stone-200\"\n      >\n        {label}\n      </label>\n\n      <div className=\"relative mt-1.5\">\n        <input\n          id={fieldId}\n          name={name}\n          type={type}\n          value={value}\n          placeholder={placeholder}\n          autoComplete={autoComplete}\n          inputMode={inputMode}\n          disabled={disabled}\n          required={required}\n          aria-required={required || undefined}\n          aria-describedby={described || undefined}\n          onChange={(e) => onChange(e.target.value)}\n          {...fieldProps}\n          className={`h-10 w-full rounded-[10px] border-2 pl-3 pr-9 text-[13px] text-stone-700 outline-none transition-[background-color,border-color,box-shadow] duration-150 placeholder:text-stone-400 focus-visible:outline-none disabled:opacity-50 dark:text-stone-200 dark:placeholder:text-stone-500 ${\n            invalid\n              ? \"border-red-500 bg-white dark:border-red-400 dark:bg-[#1D1D1A]\"\n              : \"border-stone-200 bg-stone-100/70 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] focus:border-[#4568FF] focus:bg-white focus:shadow-none dark:border-white/[0.08] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)] dark:focus:border-[#93B0FF] dark:focus:bg-[#252522]\"\n          }`}\n        />\n\n        <span\n          className=\"pointer-events-none absolute right-3 top-1/2 grid size-3.5 -translate-y-1/2 place-items-center\"\n          aria-hidden\n        >\n          <motion.svg\n            viewBox=\"0 0 12 12\"\n            width=\"14\"\n            height=\"14\"\n            fill=\"none\"\n            className=\"col-start-1 row-start-1 text-stone-500 dark:text-stone-400\"\n            initial={false}\n            animate={{ opacity: valid ? 1 : 0, scale: valid ? 1 : 0.7 }}\n            transition={fade}\n          >\n            <path\n              d=\"M2 6.3 4.7 9 10 3.2\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.6\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </motion.svg>\n          <motion.svg\n            viewBox=\"0 0 12 12\"\n            width=\"14\"\n            height=\"14\"\n            fill=\"none\"\n            className=\"col-start-1 row-start-1 text-red-700 dark:text-red-400\"\n            initial={false}\n            animate={{ opacity: invalid ? 1 : 0, scale: invalid ? 1 : 0.7 }}\n            transition={fade}\n          >\n            <path d=\"M6 2v4.4\" stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" />\n            <rect x=\"5.15\" y=\"8.4\" width=\"1.7\" height=\"1.7\" rx=\"0.5\" fill=\"currentColor\" />\n          </motion.svg>\n        </span>\n      </div>\n\n      <div className=\"relative mt-1.5 grid\" style={{ height: reserveLines * LINE }}>\n        {hint ? (\n          <motion.p\n            aria-hidden\n            style={clamp}\n            className=\"col-start-1 row-start-1 text-[11.5px] leading-[16px] text-stone-500 dark:text-stone-400\"\n            initial={false}\n            animate={{ opacity: invalid ? 0 : 1, y: invalid ? 3 : 0 }}\n            transition={fade}\n          >\n            {hint}\n          </motion.p>\n        ) : null}\n\n        <motion.p\n          aria-hidden\n          style={clamp}\n          className=\"col-start-1 row-start-1 text-[11.5px] leading-[16px] text-red-700 dark:text-red-400\"\n          initial={false}\n          animate={{ opacity: invalid ? 1 : 0, y: invalid ? 0 : -3 }}\n          transition={fade}\n        >\n          {error ?? message}\n        </motion.p>\n\n        {hint ? (\n          <span id={hintId} className=\"sr-only\">\n            {hint}\n          </span>\n        ) : null}\n\n        <span\n          id={errorId}\n          role=\"status\"\n          aria-live=\"polite\"\n          aria-atomic=\"true\"\n          className=\"sr-only\"\n        >\n          {error ?? \"\"}\n        </span>\n      </div>\n    </div>\n  );\n}\n"}]}