{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "password-field",
  "type": "registry:ui",
  "title": "Password field",
  "description": "Capture sensitive text with a visible reveal control.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/password-field/password-field.tsx",
      "content": "\"use client\";\nimport { forwardRef, useEffect, useId, useRef, useState } from \"react\";\nimport type { InputHTMLAttributes } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { cn } from \"../lib/utils\";\nimport { motionTokens } from \"../lib/motion-tokens\";\n/** The toggle's names. Both have English defaults; pass the ones you translate. */\nexport interface PasswordFieldMessages {\n  showPassword: string;\n  hidePassword: string;\n}\nexport const defaultPasswordFieldMessages: PasswordFieldMessages = {\n  showPassword: \"Show password\",\n  hidePassword: \"Hide password\",\n};\nexport interface PasswordFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, \"type\"> {\n  label: string;\n  description?: string;\n  /** Translated or reworded toggle names. */\n  messages?: Partial<PasswordFieldMessages>;\n}\n/** One eye that a slash draws across, cutting the outline beneath it, instead of swapping two icons. */\nfunction EyeMorph({ slashed }: { slashed: boolean }) {\n  const reduced = useReducedMotion();\n  const maskId = `eye-${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const slash = { pathLength: slashed ? 1 : 0, opacity: slashed ? 1 : 0 };\n  const transition = reduced\n    ? { duration: 0 }\n    : {\n        pathLength: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard] },\n        opacity: { duration: motionTokens.duration.instant },\n      };\n  return (\n    <svg\n      width={18}\n      height={18}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.75}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <mask id={maskId} maskUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"24\" height=\"24\">\n        <rect width=\"24\" height=\"24\" fill=\"white\" stroke=\"none\" />\n        <motion.path\n          d=\"M2 2l20 20\"\n          stroke=\"black\"\n          strokeWidth={5}\n          initial={false}\n          animate={slash}\n          transition={transition}\n        />\n      </mask>\n      <g mask={`url(#${maskId})`}>\n        <path d=\"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0\" />\n        <circle cx=\"12\" cy=\"12\" r=\"3\" />\n      </g>\n      <motion.path d=\"M2 2l20 20\" initial={false} animate={slash} transition={transition} />\n    </svg>\n  );\n}\n/** Changed words rise in and unblur while unchanged words hold still. Assistive tech reads the plain copy. */\nfunction MotionText({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const words = text.split(\" \");\n  return (\n    <>\n      <span className=\"sr-only\">{text}</span>\n      {/* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */}\n      <span className=\"relative block\" aria-hidden=\"true\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {words.map((word, index) => (\n            <motion.span\n              key={`${index}:${word}`}\n              className=\"inline-block whitespace-pre\"\n              initial={\n                reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : {\n                      opacity: 0,\n                      y: \"-0.35em\",\n                      filter: `blur(${motionTokens.blur.subtle}px)`,\n                      transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.exit] },\n                    }\n              }\n              transition={\n                reduced\n                  ? { duration: motionTokens.duration.instant }\n                  : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }\n              }\n            >\n              {index < words.length - 1 ? `${word} ` : word}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </>\n  );\n}\n\n/** Helper and error copy: the row opens its height on a spring, then the words settle in. */\nfunction FieldMessage({\n  id,\n  text,\n  className,\n  alert,\n}: {\n  id?: string;\n  text?: string;\n  className?: string;\n  alert?: boolean;\n}) {\n  return (\n    <AnimatePresence initial={false}>\n      {text ? <MessageRow key=\"message\" id={id} text={text} className={className} alert={alert} /> : null}\n    </AnimatePresence>\n  );\n}\n\n/** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */\nfunction MessageRow({\n  id,\n  text,\n  className,\n  alert,\n}: {\n  id?: string;\n  text: string;\n  className?: string;\n  alert?: boolean;\n}) {\n  const reduced = useReducedMotion();\n  const copyRef = useRef<HTMLSpanElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const node = copyRef.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(([entry]) => {\n      if (entry) setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  // The row's space is padding inside the clipped slot, so the height animates from a true zero.\n  return (\n    <motion.span\n      className=\"block overflow-hidden\"\n      initial={{ height: 0, opacity: 0 }}\n      animate={{ height, opacity: 1 }}\n      exit={{\n        height: 0,\n        opacity: 0,\n        transition: reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } },\n      }}\n      transition={\n        reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }\n      }\n    >\n      <motion.span\n        ref={copyRef}\n        id={id}\n        className={className}\n        role={alert ? \"alert\" : undefined}\n        initial={reduced ? false : { y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }}\n        animate={{ y: 0, filter: \"blur(0px)\" }}\n        transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}\n      >\n        <MotionText text={text} />\n      </motion.span>\n    </motion.span>\n  );\n}\n\nexport const PasswordField = forwardRef<HTMLInputElement, PasswordFieldProps>(function PasswordField(\n  { label, description, messages: messagesProp, id, className, ...props },\n  ref,\n) {\n  const messages = { ...defaultPasswordFieldMessages, ...messagesProp };\n  const generated = useId();\n  const controlId = id ?? generated;\n  const [visible, setVisible] = useState(false);\n  const [toggled, setToggled] = useState(false);\n  const hintId = description ? `${controlId}-description` : undefined;\n  // data-reveal only appears after the first toggle, so the value resolves on each change but never on mount.\n  return (\n    <div className=\"grid min-w-0\">\n      <label htmlFor={controlId} className=\"mb-2 text-sm/[inherit] font-medium\">\n        {label}\n      </label>\n      {/* Focus shows as a darker border only; the shell never changes size. The toggle sits with an even inset on top, bottom and right. */}\n      <div className=\"flex min-h-control-md items-center rounded-control border border-line-strong bg-surface pr-[calc((var(--control-height-md)-2px-30px)/2)] pl-3 transition-[border-color] duration-fast ease-standard hover:not-focus-within:border-ink focus-within:border-ink has-[input[aria-invalid=true]]:border-red motion-reduce:transition-none\">\n        {/* Switching between dots and characters resolves in place instead of snapping. Swapping the animation name restarts it. */}\n        <input\n          {...props}\n          ref={ref}\n          id={controlId}\n          type={visible ? \"text\" : \"password\"}\n          data-reveal={toggled ? (visible ? \"shown\" : \"hidden\") : undefined}\n          aria-describedby={[props[\"aria-describedby\"], hintId].filter(Boolean).join(\" \") || undefined}\n          className={cn(\n            \"w-full min-w-0 border-0 bg-transparent text-sm leading-body text-ink outline-0 placeholder:text-ink-3 data-[reveal=hidden]:animate-password-field-resolve-hidden data-[reveal=shown]:animate-password-field-resolve-shown motion-reduce:animate-none\",\n            className,\n          )}\n        />\n        <button\n          type=\"button\"\n          className=\"grid size-[30px] flex-none place-items-center rounded-button border-0 bg-transparent text-ink-3 [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] hover:bg-hover hover:text-ink active:[transform:scale(.96)] active:[transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),transform_100ms_var(--ease-standard)] aria-pressed:text-ink motion-reduce:transition-none motion-reduce:active:[transform:none]\"\n          onClick={() => {\n            setVisible((current) => !current);\n            setToggled(true);\n          }}\n          aria-label={visible ? messages.hidePassword : messages.showPassword}\n          aria-pressed={visible}\n        >\n          <EyeMorph slashed={visible} />\n        </button>\n      </div>\n      <FieldMessage id={hintId} text={description} className=\"block pt-2 text-xs leading-body text-ink-3\" />\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "@components/iris/password-field/password-field.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/password-field",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "PasswordField",
    "tags": [
      "field",
      "security"
    ],
    "docs": "https://irisui.dev/components/password-field"
  }
}
