{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "textarea",
  "type": "registry:ui",
  "title": "Textarea",
  "description": "Multi-line field with an optional character readout and auto-grow.",
  "categories": [
    "forms"
  ],
  "registryDependencies": [
    "https://whiskeyjack.net/r/merge-refs.json",
    "https://whiskeyjack.net/r/utils.json"
  ],
  "files": [
    {
      "path": "components/ui/textarea.tsx",
      "type": "registry:ui",
      "target": "components/ui/textarea.tsx",
      "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\nimport { useMergedRefs } from '@/lib/merge-refs'\n\n/** Fraction of `limit` at which the counter turns advisory. */\nconst WARN_AT = 0.9\n\nexport type CounterTone = 'muted' | 'warning' | 'error'\n\n/** The tone `limit` characters produce at `length`. Exported so a caller\n * rendering its own count elsewhere (a grid tile, a table row) matches the\n * field rather than inventing a second set of thresholds. */\nexport function counterTone(length: number, limit: number): CounterTone {\n  if (length > limit) return 'error'\n  if (length >= Math.floor(limit * WARN_AT)) return 'warning'\n  return 'muted'\n}\n\n/** Code points, so an emoji counts once instead of twice. Not grapheme\n * clusters: a flag or a skin-toned emoji still counts more than one. */\nexport const countCharacters = (value: string): number => Array.from(value).length\n\nconst TONE_CLASS: Record<CounterTone, string> = {\n  muted: 'text-[var(--color-text-muted-light)] dark:text-[var(--color-text-muted-dark)]',\n  warning: 'text-[var(--color-warning-700)] dark:text-[var(--color-warning-400)]',\n  error: 'text-[var(--color-error-700)] dark:text-[var(--color-error-400)]',\n}\n\nexport interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {\n  /**\n   * Rendered above the field as a real `<label>`, with an `id` generated via\n   * `useId` when none is passed. An explicit `id` or `aria-label` wins.\n   */\n  label?: React.ReactNode\n  /**\n   * Advisory character target. Renders a `84 / 125` readout that shifts muted\n   * → warning → error, and **never blocks typing**: `maxLength` stays unset on\n   * purpose, because a hard stop mid-word produces worse text than an\n   * overlong sentence, and the caller may have a good reason to exceed it.\n   */\n  limit?: number\n  /**\n   * Translated sentence naming the limit, wired to the field through\n   * `aria-describedby` (for example \"Aim for 125 characters or fewer\"). The\n   * visible readout is `aria-hidden`, since announcing a new number on every\n   * keystroke makes a field unusable with a screen reader, so this is the only\n   * way the limit reaches assistive tech. Ignored without `limit`.\n   */\n  limitDescription?: string\n  /**\n   * Grow with the content instead of scrolling, starting at `rows`. Off by\n   * default: it is right for a field that owns its column and wrong inside a\n   * fixed-height row, where a virtualised list needs every row the same\n   * height.\n   */\n  autoGrow?: boolean\n}\n\n/**\n * The multi-line field. `Input`'s shape and label behaviour, plus an optional\n * character readout and auto-grow.\n *\n * The counter's thresholds live here rather than in each app because they are\n * an accessibility decision: the state is carried by the number itself and by\n * `aria-describedby`, so it never rests on colour alone.\n *\n * ```tsx\n * <Textarea\n *   label={t('editor.altText')}\n *   limit={125}\n *   limitDescription={t('editor.limitHint', { limit: 125 })}\n *   autoGrow\n *   value={draft}\n *   onChange={(e) => setDraft(e.target.value)}\n * />\n * ```\n */\nconst Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n  (\n    {\n      className,\n      label,\n      limit,\n      limitDescription,\n      autoGrow = false,\n      id: idProp,\n      rows = 3,\n      onChange,\n      'aria-describedby': describedByProp,\n      ...props\n    },\n    ref,\n  ) => {\n    const autoId = React.useId()\n    const id = idProp ?? (label !== undefined ? autoId : undefined)\n    const describedById = `${autoId}-limit`\n\n    const innerRef = React.useRef<HTMLTextAreaElement>(null)\n    const mergedRef = useMergedRefs(innerRef, ref)\n\n    // Controlled fields read their length from the prop; uncontrolled ones have\n    // to be tracked, since there is nothing else to read between renders.\n    const controlled = props.value !== undefined\n    const [innerValue, setInnerValue] = React.useState(() => String(props.defaultValue ?? ''))\n    const text = controlled ? String(props.value ?? '') : innerValue\n    const length = limit === undefined ? 0 : countCharacters(text)\n    const tone = limit === undefined ? 'muted' : counterTone(length, limit)\n\n    const resize = React.useCallback(() => {\n      const el = innerRef.current\n      if (!el || !autoGrow) return\n      // Collapse first: scrollHeight never reports a height below the current\n      // one, so measuring without this makes the field a ratchet.\n      el.style.height = 'auto'\n      el.style.height = `${el.scrollHeight}px`\n    }, [autoGrow])\n\n    React.useLayoutEffect(() => {\n      resize()\n    }, [resize, text])\n\n    const handleChange = React.useCallback(\n      (event: React.ChangeEvent<HTMLTextAreaElement>) => {\n        if (!controlled) setInnerValue(event.target.value)\n        onChange?.(event)\n      },\n      [controlled, onChange],\n    )\n\n    const field = (\n      <textarea\n        id={id}\n        ref={mergedRef}\n        rows={rows}\n        onChange={handleChange}\n        aria-describedby={\n          [describedByProp, limit !== undefined && limitDescription ? describedById : null]\n            .filter(Boolean)\n            .join(' ') || undefined\n        }\n        className={cn(\n          // The field vocabulary Input and SearchField share: regular radius,\n          // solid border, no fill of its own, deboss recess.\n          'block w-full rounded-lg',\n          'border border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]',\n          'bg-transparent shadow-deboss',\n          'px-3 py-2 text-sm',\n          'text-[var(--color-text-primary-light)] dark:text-[var(--color-text-primary-dark)]',\n          'placeholder:text-[var(--color-text-muted-light)] dark:placeholder:text-[var(--color-text-muted-dark)]',\n          'wj-focus-ring transition-colors',\n          'hover:border-[var(--color-warm-300)] dark:hover:border-[var(--color-neutral-700)]',\n          'disabled:cursor-not-allowed disabled:opacity-50',\n          'disabled:hover:border-[var(--color-border-light)] dark:disabled:hover:border-[var(--color-border-dark)]',\n          autoGrow ? 'resize-none overflow-hidden' : 'resize-y',\n          className,\n        )}\n        {...props}\n      />\n    )\n\n    if (label === undefined && limit === undefined) return field\n\n    return (\n      <div className=\"flex flex-col gap-1\">\n        {label !== undefined && (\n          <label\n            htmlFor={id}\n            className=\"block text-sm font-medium text-[var(--color-text-secondary-light)] dark:text-[var(--color-text-secondary-dark)]\"\n          >\n            {label}\n          </label>\n        )}\n        {field}\n        {limit !== undefined && (\n          <>\n            <span\n              aria-hidden=\"true\"\n              className={cn('self-end text-xs tabular-nums transition-colors', TONE_CLASS[tone])}\n            >\n              {length} / {limit}\n            </span>\n            {limitDescription && (\n              <span id={describedById} className=\"sr-only\">\n                {limitDescription}\n              </span>\n            )}\n          </>\n        )}\n      </div>\n    )\n  },\n)\nTextarea.displayName = 'Textarea'\n\nexport { Textarea }\n"
    }
  ],
  "docs": "Input's shape and label behaviour for text that runs to more than one line. `limit` renders a `84 / 125` readout that shifts muted to warning at 90% and error past 100%, and deliberately leaves maxLength unset: a hard stop mid-word produces worse text than an overlong sentence. The readout is aria-hidden, because announcing a new number on every keystroke makes a field unusable with a screen reader, so pass `limitDescription` to state the target once through aria-describedby. `autoGrow` is off by default -- it is right for a field that owns its column and wrong inside a virtualised row, where every row must stay the same height. `counterTone` and `countCharacters` are exported so a count rendered elsewhere (a grid tile, a table row) matches the field instead of inventing a second set of thresholds.",
  "meta": {
    "group": "forms",
    "related": [
      "input",
      "search-field",
      "select"
    ],
    "exports": [
      "Textarea",
      "counterTone",
      "countCharacters"
    ],
    "siteSlug": "textarea"
  }
}
