{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "password-field",
  "type": "registry:ui",
  "title": "Password Field",
  "description": "Reveals with an eye that slashes itself, keeps the caret, warns on Caps Lock.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/password-field.tsx",
      "content": "\"use client\";\nimport { Field } from \"@base-ui/react/field\";\nimport { Input } from \"@base-ui/react/input\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/**\n * Tracks Caps Lock from the events a field already receives. Browsers expose it\n * only on keyboard and mouse events, so the state is unknown until the first\n * key or click, and it is dropped on blur because the key can change elsewhere.\n */\nexport function useCapsLock() {\n  const [on, setOn] = useState(false);\n  const read = useCallback((e: React.KeyboardEvent | React.MouseEvent) => {\n    // Some platforms fire synthetic events without the method.\n    if (typeof e.getModifierState === \"function\") setOn(e.getModifierState(\"CapsLock\"));\n  }, []);\n  const clear = useCallback(() => setOn(false), []);\n  return { capsLock: on, handlers: { onKeyDown: read, onKeyUp: read, onMouseDown: read, onBlur: clear } };\n}\n\ntype Size = \"sm\" | \"md\" | \"lg\";\n\nexport type PasswordFieldProps = Omit<React.ComponentProps<\"input\">, \"type\" | \"size\" | \"children\" | \"defaultValue\" | \"value\"> & {\n  label?: React.ReactNode;\n  /** One line under the field. Replaced in place by the error when there is one. */\n  description?: React.ReactNode;\n  /** Marks the field invalid and shows this message under it. */\n  error?: React.ReactNode;\n  /** `current` for sign-in, `new` for sign-up and change-password. Sets autocomplete so managers fill or generate correctly. */\n  purpose?: \"current\" | \"new\";\n  size?: Size;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Whether the password is shown as plain text. */\n  revealed?: boolean;\n  defaultRevealed?: boolean;\n  onRevealedChange?: (revealed: boolean) => void;\n  /** Show the Caps Lock pill while typing with it on. */\n  capsLockWarning?: boolean;\n};\n\nconst sizes: Record<Size, { box: string; input: string; toggle: string; icon: number }> = {\n  sm: { box: \"h-7 rounded-md\", input: \"pl-2 text-[12.5px]\", toggle: \"size-5 rounded-[5px] mr-1\", icon: 14 },\n  md: { box: \"h-8 rounded-lg\", input: \"pl-2.5\", toggle: \"size-6 rounded-md mr-1\", icon: 16 },\n  lg: { box: \"h-9 rounded-lg\", input: \"pl-3\", toggle: \"size-7 rounded-md mr-1\", icon: 16 },\n};\n\nexport function PasswordField({\n  label,\n  description,\n  error,\n  purpose = \"current\",\n  size = \"md\",\n  value,\n  defaultValue,\n  onValueChange,\n  revealed: revealedProp,\n  defaultRevealed = false,\n  onRevealedChange,\n  capsLockWarning = true,\n  disabled,\n  readOnly,\n  className,\n  id: idProp,\n  name = \"password\",\n  ref,\n  onKeyDown,\n  onKeyUp,\n  onMouseDown,\n  onBlur,\n  onFocus,\n  ...rest\n}: PasswordFieldProps) {\n  const reduce = useReducedMotion();\n  const autoId = useId();\n  const id = idProp ?? `pw-${autoId}`;\n  const [revealed, setRevealed] = useControllableState({ value: revealedProp, defaultValue: defaultRevealed, onChange: onRevealedChange });\n  const { capsLock, handlers } = useCapsLock();\n  const [focused, setFocused] = useState(false);\n  const inputRef = useRef<HTMLInputElement | null>(null);\n  // Selection saved at press time, restored after the type swap, which resets it in some browsers.\n  const pendingSelection = useRef<[number, number, \"forward\" | \"backward\" | \"none\"] | null>(null);\n  const lastRevealed = useRef(revealed);\n\n  const setRefs = useCallback(\n    (node: HTMLInputElement | null) => {\n      inputRef.current = node;\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n\n  useLayoutEffect(() => {\n    if (lastRevealed.current === revealed) return;\n    lastRevealed.current = revealed;\n    const input = inputRef.current;\n    if (!input) return;\n    const sel = pendingSelection.current;\n    pendingSelection.current = null;\n    if (sel && document.activeElement === input) {\n      // Chrome rebuilds the editor, and resets the caret, when styles recalc after a type change.\n      // Reading layout forces that now, so the restore below lands after it, not before.\n      void input.offsetWidth;\n      input.setSelectionRange(sel[0], sel[1], sel[2]);\n    }\n    // The glyphs resolve out of a short blur so the swap reads as one change, not a flash.\n    if (!reduce && typeof input.animate === \"function\") {\n      input.animate([{ filter: \"blur(3px)\", opacity: 0.5 }, { filter: \"blur(0px)\", opacity: 1 }], { duration: 200, easing: \"cubic-bezier(0.16, 1, 0.3, 1)\" });\n    }\n  }, [revealed, reduce]);\n\n  // Mask again when the form submits, so the browser offers to save it as a password\n  // and the plain text is not left on screen behind a loading state.\n  useEffect(() => {\n    const form = inputRef.current?.form;\n    if (!form) return;\n    const onSubmit = () => setRevealed(false);\n    form.addEventListener(\"submit\", onSubmit);\n    return () => form.removeEventListener(\"submit\", onSubmit);\n  }, [setRevealed]);\n\n  const toggle = () => {\n    const input = inputRef.current;\n    if (input && document.activeElement === input && input.selectionStart !== null) {\n      pendingSelection.current = [input.selectionStart, input.selectionEnd ?? input.selectionStart, input.selectionDirection ?? \"none\"];\n    }\n    setRevealed((r) => !r);\n  };\n\n  const s = sizes[size];\n  const invalid = Boolean(error) || rest[\"aria-invalid\"] === true || rest[\"aria-invalid\"] === \"true\";\n  const showCaps = capsLockWarning && capsLock && focused && !revealed;\n\n  return (\n    <Field.Root\n      invalid={invalid || undefined}\n      disabled={disabled}\n      data-size={size}\n      className={cn(\"group/field flex w-full min-w-0 flex-col gap-1.5\", className)}\n    >\n      {label && (\n        <Field.Label className=\"w-fit text-[12.5px] font-medium leading-4 text-fg group-data-[disabled]/field:text-fg-3\">{label}</Field.Label>\n      )}\n      <div\n        data-slot=\"control\"\n        data-state={revealed ? \"revealed\" : \"masked\"}\n        className={cn(\n          \"relative flex items-center border border-line-2 bg-raised\",\n          \"transition-[border-color,box-shadow] duration-150 ease-out\",\n          \"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 focus-within:hover:border-fg-3\",\n          \"group-data-[invalid]/field:border-danger/60 group-data-[invalid]/field:focus-within:border-danger/80 group-data-[invalid]/field:focus-within:ring-danger/15\",\n          \"group-data-[disabled]/field:pointer-events-none group-data-[disabled]/field:opacity-50\",\n          readOnly && \"bg-hover\",\n          s.box,\n        )}\n      >\n        <Input\n          {...rest}\n          ref={setRefs}\n          id={id}\n          name={name}\n          type={revealed ? \"text\" : \"password\"}\n          value={value}\n          defaultValue={defaultValue}\n          onValueChange={onValueChange ? (v) => onValueChange(v) : undefined}\n          disabled={disabled}\n          readOnly={readOnly}\n          autoComplete={purpose === \"new\" ? \"new-password\" : \"current-password\"}\n          autoCapitalize=\"none\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          onKeyDown={(e) => {\n            handlers.onKeyDown(e);\n            onKeyDown?.(e);\n          }}\n          onKeyUp={(e) => {\n            handlers.onKeyUp(e);\n            onKeyUp?.(e);\n          }}\n          onMouseDown={(e) => {\n            handlers.onMouseDown(e);\n            onMouseDown?.(e);\n          }}\n          onFocus={(e) => {\n            setFocused(true);\n            onFocus?.(e);\n          }}\n          onBlur={(e) => {\n            setFocused(false);\n            handlers.onBlur();\n            onBlur?.(e);\n          }}\n          className={cn(\n            \"h-full min-w-0 flex-1 bg-transparent pr-1 text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\",\n            // Masked dots get a little air; revealed text keeps the family's own spacing.\n            \"[&[type=password]:not(:placeholder-shown)]:tracking-[0.12em]\",\n            \"disabled:cursor-not-allowed\",\n            s.input,\n          )}\n        />\n\n        <AnimatePresence initial={false}>\n          {showCaps && (\n            <motion.span\n              key=\"caps\"\n              aria-hidden\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.94, filter: \"blur(1px)\", transition: { duration: 0.12, ease: ease.in } }}\n              transition={reduce ? { duration: 0.15 } : spring.pop}\n              className={cn(\n                \"mr-1 inline-flex shrink-0 origin-right select-none items-center gap-1 rounded-full bg-warning-soft font-medium text-warning\",\n                size === \"sm\" ? \"h-4.5 px-1.5 text-[10.5px]\" : \"h-5 px-2 text-[11px]\",\n              )}\n            >\n              <CapsGlyph />\n              Caps lock\n            </motion.span>\n          )}\n        </AnimatePresence>\n\n        <button\n          type=\"button\"\n          aria-label=\"Show password\"\n          aria-pressed={revealed}\n          aria-controls={id}\n          disabled={disabled}\n          // A mouse press must not steal focus, or the caret and the keyboard go with it.\n          onMouseDown={(e) => {\n            if (document.activeElement === inputRef.current) e.preventDefault();\n          }}\n          onClick={toggle}\n          className={cn(\n            \"relative grid shrink-0 place-items-center text-fg-3 outline-none\",\n            \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-fg/6 hover:text-fg active:scale-[0.92] active:duration-75\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n            \"disabled:pointer-events-none\",\n            // A 44px target on touch without growing the drawn button.\n            \"after:absolute after:-inset-y-2 after:-left-1 after:-right-2 after:content-['']\",\n            s.toggle,\n          )}\n        >\n          <EyeGlyph revealed={revealed} size={s.icon} reduce={!!reduce} />\n        </button>\n      </div>\n\n      <MessageSlot description={description} error={error} />\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {showCaps ? \"Caps lock is on\" : \"\"}\n      </span>\n    </Field.Root>\n  );\n}\n\n/**\n * Description and error share one grid cell: the error replaces the hint in place,\n * so a field with a hint never pushes the form down when it turns invalid.\n */\nfunction MessageSlot({ description, error }: { description?: React.ReactNode; error?: React.ReactNode }) {\n  if (!description && !error) return <Field.Error className={errorClass} />;\n  return (\n    <div className=\"grid text-[12px] leading-4\">\n      {description && (\n        <Field.Description\n          className={cn(\n            \"col-start-1 row-start-1 text-fg-3 transition-opacity duration-150\",\n            \"group-data-[invalid]/field:invisible group-data-[invalid]/field:opacity-0\",\n          )}\n        >\n          {description}\n        </Field.Description>\n      )}\n      <Field.Error match={error ? true : undefined} className={cn(errorClass, \"col-start-1 row-start-1\")}>\n        {error ? (\n          <>\n            <Alert size={14} className=\"mt-px shrink-0\" />\n            <span>{error}</span>\n          </>\n        ) : undefined}\n      </Field.Error>\n    </div>\n  );\n}\n\nconst errorClass =\n  \"flex items-start gap-1.5 text-[12px] leading-4 text-danger transition-[opacity,translate] duration-200 ease-out-expo data-[starting-style]:-translate-y-1 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[ending-style]:duration-100 motion-reduce:translate-y-0\";\n\n// The slash draws across the eye, and a mask cuts the outline beside it so the\n// two lines never touch, the way a well-drawn eye-off icon does.\nfunction EyeGlyph({ revealed, size, reduce }: { revealed: boolean; size: number; reduce: boolean }) {\n  const maskId = useId();\n  const draw = reduce\n    ? { duration: 0 }\n    : revealed\n      ? { duration: 0.24, ease: ease.out }\n      : { duration: 0.16, ease: ease.in };\n  // Opacity rides along because a zero-length path still paints its round cap as a dot.\n  const slash = {\n    initial: false as const,\n    animate: { pathLength: revealed ? 1 : 0, opacity: revealed ? 1 : 0 },\n    transition: { ...draw, opacity: { duration: reduce ? 0 : 0.08, delay: revealed || reduce ? 0 : 0.1 } },\n  };\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <mask id={maskId} maskUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"16\" height=\"16\">\n        <rect width=\"16\" height=\"16\" fill=\"white\" stroke=\"none\" />\n        <motion.path d=\"M2.5 2.5l11 11\" stroke=\"black\" strokeWidth={3.6} {...slash} />\n      </mask>\n      <g mask={`url(#${maskId})`}>\n        <path d=\"M1.75 8S4 3.75 8 3.75 14.25 8 14.25 8 12 12.25 8 12.25 1.75 8 1.75 8z\" />\n        <motion.circle\n          cx=\"8\"\n          cy=\"8\"\n          r=\"2\"\n          initial={false}\n          animate={{ scale: revealed ? 0.85 : 1 }}\n          transition={reduce ? { duration: 0 } : spring.snappy}\n          style={{ transformOrigin: \"8px 8px\" }}\n        />\n      </g>\n      <motion.path d=\"M2.5 2.5l11 11\" {...slash} />\n    </svg>\n  );\n}\n\nfunction CapsGlyph() {\n  return (\n    <svg width={11} height={11} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M8 2.5 2.75 8.25H5.5v3h5v-3h2.75z\" />\n      <path d=\"M5.5 13.75h5\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/password-field"
}
