{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"input","type":"registry:ui","title":"Input","description":"A text field for forms and search.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-input-format.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/field.json"],"files":[{"path":"registry/dashral/input.tsx","type":"registry:file","target":"components/dashral/input.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Eye, EyeOff } from \"lucide-react\"\n\nimport { countSignificant, inputFormatter, type CardBrand, type InputFormat } from \"./lib/input-format\"\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { FieldWrapper, joinIds, splitFieldProps, useFieldControl, type FieldClassNames, type FieldOwnProps } from \"./field\"\n\ntype InputProps = Omit<React.ComponentProps<\"input\">, \"prefix\"> &\n  FieldOwnProps & {\n    /** Shown inside the field before the text: an icon, or text such as \"https://\" or \"$\". */\n    prefix?: React.ReactNode\n    /** Shown inside the field after the text: an icon, a unit, or a button such as a password toggle. */\n    suffix?: React.ReactNode\n    /** Drops the border, background and focus ring, for an input styled entirely by you. */\n    unstyled?: boolean\n    /** With type=\"password\": a button at the end that shows and hides the password. On by default. */\n    revealable?: boolean\n    /**\n     * With type=\"password\": a meter under the box saying how strong the password is (weak, fair, good, strong), from\n     * how many of the rules it meets. \"rules\" also lists the rules, each ticked as it's met. Neutral until something is\n     * typed.\n     */\n    strength?: boolean | \"rules\"\n    /** With strength: your own rules in place of the built-in ones (8 characters, upper and lower case, a number, a symbol). */\n    passwordRules?: PasswordRule[]\n    /**\n     * Formats as you type: \"card\", \"phone\", \"date\", \"expiry\", \"time\", \"number\" ({ type: \"number\", decimals: 2 } for\n     * decimals) or { pattern: \"##.###.###.#-###.###\" } (# a digit, A a letter). The caret stays where you're typing.\n     * value and onChange carry the text as shown; onValueChange gets the raw value too.\n     */\n    format?: InputFormat\n    /** With format: the raw value (\"4242424242424242\", \"1234.5\"), the text as shown, and a card's brand. */\n    onValueChange?: (raw: string, details: { formatted: string; brand?: CardBrand }) => void\n    /** Classes for each part. className styles the box you see: the input, or the box around it and its affixes. */\n    classNames?: FieldClassNames & { wrapper?: string; input?: string; prefix?: string; suffix?: string }\n  }\n\n/** A rule a password meets or doesn't, with what it's called in the list. */\ntype PasswordRule = { label: string; test: (password: string) => boolean }\n\nconst boxClass = \"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent text-base shadow-xs transition-[color,box-shadow] md:text-sm\"\nconst textClass = \"outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground\"\n// readOnly reads as such: a quiet box and muted text, so nobody tries to type in it (it still focuses, selects and\n// copies). By the attribute, not :read-only, which a file input matches too.\nconst readOnlyClass = \"[&[readonly]]:bg-muted/50 [&[readonly]]:text-muted-foreground\"\n\n/**\n * A text field. On its own it's a plain input; give it a label, description or error and it becomes a whole field,\n * with the ids and aria attributes wired up. prefix and suffix put icons, text or buttons inside the box.\n */\nfunction Input(props: InputProps) {\n  const [field, control] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field}>\n      <InputControl {...control} />\n    </FieldWrapper>\n  )\n}\n\nconst autoCompletes: Partial<Record<string, string>> = { card: \"cc-number\", expiry: \"cc-exp\", phone: \"tel\" }\n\n/** The input's props with formatting wired in: the shown value, the keyboard and autofill hints, and onChange. */\nfunction useFormat(\n  format: InputFormat | undefined,\n  onValueChange: InputProps[\"onValueChange\"],\n  props: React.ComponentProps<\"input\">\n): React.ComponentProps<\"input\"> {\n  const { locale } = useLocale()\n  const key = JSON.stringify(format ?? null)\n  // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by the format's contents, so an inline object is fine\n  const formatter = React.useMemo(() => (format ? inputFormatter(format, locale) : null), [key, locale])\n  // The text before the latest keystroke, to tell a deleted separator from a deleted character.\n  const before = React.useRef(\"\")\n  if (!formatter || !format) return props\n\n  const numeric = format === \"number\" || (typeof format === \"object\" && \"type\" in format)\n  const show = (value: unknown) => (value === undefined || value === null ? undefined : formatter.format(String(value)).text)\n\n  return {\n    ...props,\n    value: show(props.value),\n    defaultValue: show(props.defaultValue),\n    inputMode: props.inputMode ?? (numeric ? (typeof format === \"object\" && \"type\" in format && format.decimals ? \"decimal\" : \"numeric\") : typeof format === \"object\" ? undefined : \"numeric\"),\n    autoComplete: props.autoComplete ?? (typeof format === \"string\" ? autoCompletes[format] : undefined),\n    onKeyDown(event) {\n      before.current = event.currentTarget.value\n      props.onKeyDown?.(event)\n    },\n    onChange(event) {\n      const input = event.currentTarget\n      const typed = input.value\n      const caret = input.selectionStart ?? typed.length\n      let head = typed.slice(0, caret)\n      let text = typed\n      // Backspace on a separator (\"4242 |4242\"): delete the character before it as well, or nothing would change.\n      const { significant } = formatter\n      if (\n        (event.nativeEvent as InputEvent).inputType === \"deleteContentBackward\" &&\n        typed.length < before.current.length &&\n        countSignificant(typed, significant) === countSignificant(before.current, significant)\n      ) {\n        let i = head.length - 1\n        while (i >= 0 && !significant.test(head[i]!)) i--\n        if (i >= 0) {\n          head = head.slice(0, i)\n          text = head + typed.slice(caret)\n        }\n      }\n      const result = formatter.format(text)\n      if (input.value !== result.text) input.value = result.text\n      if (document.activeElement === input) {\n        const at = formatter.caret(head, result.text)\n        input.setSelectionRange(at, at)\n      }\n      before.current = result.text\n      onValueChange?.(result.raw, { formatted: result.text, brand: result.brand })\n      props.onChange?.(event)\n    },\n  }\n}\n\nfunction InputControl({\n  prefix,\n  suffix: suffixProp,\n  unstyled,\n  revealable = true,\n  strength,\n  passwordRules,\n  format,\n  onValueChange,\n  className,\n  classNames,\n  ...rest\n}: Omit<InputProps, keyof FieldOwnProps>) {\n  const { type: typeProp, ...fieldProps } = useFieldControl(rest)\n  const meterId = React.useId()\n  const showMeter = Boolean(strength) && typeProp === \"password\"\n  // The password as it's typed, for the meter: the value when controlled, otherwise followed through onChange.\n  const [typed, setTyped] = React.useState(String(fieldProps.defaultValue ?? \"\"))\n  const password = fieldProps.value !== undefined ? String(fieldProps.value) : typed\n  if (showMeter) {\n    const { onChange } = fieldProps\n    fieldProps.onChange = (event) => {\n      setTyped(event.target.value)\n      onChange?.(event)\n    }\n    fieldProps[\"aria-describedby\"] = joinIds(fieldProps[\"aria-describedby\"], meterId)\n  }\n  const props = useFormat(format, onValueChange, fieldProps)\n  const { t } = useLocale()\n  const [revealed, setRevealed] = React.useState(false)\n  const reveal = typeProp === \"password\" && revealable\n  const type = reveal && revealed ? \"text\" : typeProp\n  // The password's show/hide button, after any suffix of your own. It keeps the caret where it was.\n  const suffix = reveal ? (\n    <>\n      {suffixProp}\n      <button\n        type=\"button\"\n        data-slot=\"input-reveal\"\n        aria-label={revealed ? t(\"hidePassword\") : t(\"showPassword\")}\n        aria-pressed={revealed}\n        disabled={props.disabled}\n        onMouseDown={(event) => event.preventDefault()}\n        onClick={() => setRevealed(!revealed)}\n        className=\"-me-1 grid size-6 place-items-center rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none\"\n      >\n        {revealed ? <EyeOff /> : <Eye />}\n      </button>\n    </>\n  ) : (\n    suffixProp\n  )\n\n  const meter = showMeter ? <PasswordStrength id={meterId} password={password} rules={passwordRules} listRules={strength === \"rules\"} /> : null\n\n  if (!prefix && !suffix) {\n    const box = (\n      <input\n        type={type}\n        data-slot=\"input\"\n        className={cn(\n          !unstyled && [\n            boxClass,\n            textClass,\n            \"px-3 py-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50\",\n            // type=\"file\": the browser's \"Choose file\" is a small, quiet button set in the box with the same 3px on\n            // every side (36px box − 2px border − 6px = the button's 28px), its corners following the box's.\n            type === \"file\" &&\n              \"p-[3px] file:me-3 file:inline-flex file:h-7 file:items-center file:rounded-[calc(var(--radius-md)-3px)] file:border-0 file:bg-muted file:px-3 file:text-sm file:font-medium file:text-foreground file:transition-colors hover:file:bg-accent\",\n            \"focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/15\",\n            \"aria-invalid:border-destructive aria-invalid:ring-destructive/15\",\n            readOnlyClass,\n          ],\n          className,\n          classNames?.input\n        )}\n        {...props}\n      />\n    )\n    return meter ? (\n      <>\n        {box}\n        {meter}\n      </>\n    ) : (\n      box\n    )\n  }\n\n  const affixClass =\n    \"flex h-full shrink-0 items-center gap-1.5 text-muted-foreground transition-colors select-none group-focus-within/input:text-foreground group-has-[[aria-invalid=true]]/input:text-destructive-text [&_svg]:size-4 [&_svg]:shrink-0\"\n\n  const box = (\n    <div\n      data-slot=\"input\"\n      data-disabled={props.disabled || undefined}\n      // A click on an affix (but not on a button in it) focuses the input, as if the box were all input.\n      onMouseDown={(event) => {\n        const target = event.target as HTMLElement\n        if (target.closest(\"input, button, a, [tabindex]\")) return\n        event.preventDefault()\n        event.currentTarget.querySelector(\"input\")?.focus()\n      }}\n      className={cn(\n        \"group/input cursor-text items-center\",\n        !unstyled && [\n          boxClass,\n          \"focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15\",\n          \"has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/15\",\n          \"data-disabled:cursor-not-allowed data-disabled:opacity-50\",\n          \"has-[input[readonly]]:bg-muted/50 has-[input[readonly]]:text-muted-foreground\",\n        ],\n        className,\n        classNames?.wrapper\n      )}\n    >\n      {prefix && (\n        <span data-slot=\"input-prefix\" className={cn(affixClass, \"ps-3\", classNames?.prefix)}>\n          {prefix}\n        </span>\n      )}\n      <input\n        type={type}\n        className={cn(\n          \"h-full w-full min-w-0 flex-1 bg-transparent px-3 py-1 disabled:cursor-not-allowed\",\n          textClass,\n          prefix && \"ps-2\",\n          suffix && \"pe-2\",\n          classNames?.input\n        )}\n        {...props}\n      />\n      {suffix && (\n        <span data-slot=\"input-suffix\" className={cn(affixClass, \"pe-3\", classNames?.suffix)}>\n          {suffix}\n        </span>\n      )}\n    </div>\n  )\n  return meter ? (\n    <>\n      {box}\n      {meter}\n    </>\n  ) : (\n    box\n  )\n}\n\n// The four steps of the meter: how many segments fill, in which color, and what it's called. Every class is written out\n// in full so Tailwind finds it.\nconst levels = [\n  { key: \"passwordWeak\", fill: \"bg-destructive\", text: \"text-destructive-text\" },\n  { key: \"passwordFair\", fill: \"bg-warning\", text: \"text-warning-text\" },\n  { key: \"passwordGood\", fill: \"bg-info\", text: \"text-info-text\" },\n  { key: \"passwordStrong\", fill: \"bg-success\", text: \"text-success-text\" },\n] as const\n\n/**\n * How strong a password is: four segments that fill and change color as it meets more rules, with the level's name,\n * and optionally the rules ticked off. Neutral (grey, no name) while it's empty, so it never scolds before anyone types.\n */\nfunction PasswordStrength({ id, password, rules: rulesProp, listRules }: { id: string; password: string; rules?: PasswordRule[]; listRules: boolean }) {\n  const { t } = useLocale()\n  const rules = rulesProp ?? [\n    { label: t(\"ruleLength\", { count: 8 }), test: (p: string) => p.length >= 8 },\n    { label: t(\"ruleCase\"), test: (p: string) => /\\p{Ll}/u.test(p) && /\\p{Lu}/u.test(p) },\n    { label: t(\"ruleNumber\"), test: (p: string) => /\\p{Nd}/u.test(p) },\n    { label: t(\"ruleSymbol\"), test: (p: string) => /[^\\p{L}\\p{Nd}\\s]/u.test(p) },\n  ]\n  const met = rules.map((rule) => rule.test(password))\n  const passed = met.filter(Boolean).length\n  // Rules met, spread over the four levels (with four rules, one level each); at least \"weak\" once anything is typed.\n  const level = password ? Math.max(0, Math.ceil((passed / rules.length) * levels.length) - 1) : -1\n  const current = level >= 0 ? levels[level]! : null\n\n  return (\n    <div id={id} data-slot=\"password-strength\" className=\"grid gap-1.5\">\n      {/* The meter spans the field's width; the level's name and the rules sit under it, so nothing narrows it. */}\n      <div aria-hidden className=\"grid grid-cols-4 gap-1\">\n        {levels.map((step, i) => (\n          <span\n            key={step.key}\n            className={cn(\n              \"h-1 rounded-full transition-colors duration-(--duration-standard) ease-standard motion-reduce:transition-none\",\n              current && i <= level ? current.fill : \"bg-muted\"\n            )}\n          />\n        ))}\n      </div>\n      <div className=\"flex items-start justify-between gap-3\">\n        {listRules ? (\n          <ul className=\"grid flex-1 gap-1 text-xs text-muted-foreground sm:grid-cols-2\">\n            {rules.map((rule, i) => (\n              <li key={rule.label} className={cn(\"flex items-center gap-1.5 transition-colors duration-(--duration-standard)\", met[i] && \"text-foreground\")}>\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"grid size-3.5 shrink-0 place-items-center rounded-full border transition-[background-color,border-color] duration-(--duration-standard) ease-standard\",\n                    met[i] ? \"border-success bg-success text-success-foreground\" : \"border-input\"\n                  )}\n                >\n                  <Check className={cn(\"size-2.5 transition-[opacity,scale] duration-(--duration-standard) ease-spring\", met[i] ? \"scale-100 opacity-100\" : \"scale-50 opacity-0\")} strokeWidth={3} />\n                </span>\n                {rule.label}\n                <span className=\"sr-only\">{met[i] ? t(\"ruleMet\") : t(\"ruleNotMet\")}</span>\n              </li>\n            ))}\n          </ul>\n        ) : (\n          <span />\n        )}\n        {/* Always there, so the field doesn't grow when the first letter is typed; announced politely as it changes. */}\n        <span aria-live=\"polite\" className={cn(\"min-h-4 shrink-0 text-xs leading-4 font-medium transition-colors duration-(--duration-standard)\", current?.text)}>\n          {current && (\n            <>\n              <span className=\"sr-only\">{t(\"passwordStrength\")}: </span>\n              {t(current.key)}\n            </>\n          )}\n        </span>\n      </div>\n    </div>\n  )\n}\n\nexport { Input, type InputProps, type PasswordRule }\n"}],"cssVars":{"theme":{"color-destructive-foreground":"var(--destructive-foreground)","color-destructive-text":"var(--destructive-text)","color-info":"var(--info)","color-info-foreground":"var(--info-foreground)","color-info-text":"var(--info-text)","color-success":"var(--success)","color-success-foreground":"var(--success-foreground)","color-success-text":"var(--success-text)","color-warning":"var(--warning)","color-warning-foreground":"var(--warning-foreground)","color-warning-text":"var(--warning-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"destructive-foreground":"oklch(0.985 0 0)","destructive-text":"#bd2800","info":"oklch(0.5 0.134 242.749)","info-foreground":"oklch(0.985 0 0)","info-text":"oklch(0.443 0.11 240.79)","success":"oklch(0.508 0.118 165.612)","success-foreground":"oklch(0.985 0 0)","success-text":"oklch(0.432 0.095 166.913)","warning":"#f59200","warning-foreground":"oklch(0.3 0.07 60)","warning-text":"#964e00","duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{"destructive-foreground":"oklch(0.2 0 0)","destructive-text":"#ff8a5c","info":"oklch(0.685 0.169 237.323)","info-foreground":"oklch(0.2 0 0)","info-text":"oklch(0.746 0.16 232.661)","success":"oklch(0.696 0.17 162.48)","success-foreground":"oklch(0.2 0.04 160)","success-text":"oklch(0.765 0.177 163.223)","warning":"#f59200","warning-foreground":"oklch(0.25 0.06 60)","warning-text":"#ffba42"}}}