{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "textarea",
  "type": "registry:ui",
  "title": "Textarea",
  "description": "Grows with its text between row limits, counts near the limit, sends on ⌘↵.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/textarea.tsx",
      "content": "\"use client\";\nimport { Input as BaseInput } from \"@base-ui/react/input\";\nimport NumberFlow from \"@number-flow/react\";\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type TextareaProps = Omit<React.ComponentProps<\"textarea\">, \"value\" | \"defaultValue\" | \"rows\"> & {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Height when empty, in lines. */\n  minRows?: number;\n  /** Grows with the text up to this many lines, then scrolls. */\n  maxRows?: number;\n  /** A soft character limit: typing past it is allowed but marks the field invalid. Use maxLength for a hard stop. */\n  limit?: number;\n  /** How many characters before the limit the count appears. Defaults to 20% of the limit. */\n  countFrom?: number;\n  /** Called on Cmd+Enter (Ctrl+Enter off Mac) with the current text. */\n  onModEnter?: (value: string) => void;\n  /** Label for the shortcut hint shown while focused, e.g. \"to send\". Set to false to hide the hint. */\n  modEnterHint?: string | false;\n  /** Content for the left of the footer row, after the shortcut hint: a formatting note, an attach button. */\n  footer?: React.ReactNode;\n  /** Content for the right end of the footer row, after the count: usually the send button. */\n  actions?: React.ReactNode;\n  invalid?: boolean;\n  /** Classes for the inner textarea; `className` styles the outer box. */\n  textareaClassName?: string;\n};\n\n// Mac check that is stable through hydration: the server and the first client\n// render both say Mac, then the real answer arrives without a mismatch warning.\nconst subscribe = () => () => {};\nconst isMac = () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);\nexport function useIsMac() {\n  return useSyncExternalStore(subscribe, isMac, () => true);\n}\n\n/**\n * Sizes a textarea to its content between two line counts. It measures a hidden\n * twin instead of collapsing the real field to auto, so the page never jumps\n * and the height can transition. Returns refs for the field and the twin.\n */\nexport function useAutosize(value: string, { minRows = 2, maxRows = 8 } = {}) {\n  const fieldRef = useRef<HTMLTextAreaElement | null>(null);\n  const twinRef = useRef<HTMLTextAreaElement | null>(null);\n  const [overflow, setOverflow] = useState(false);\n\n  const measure = useCallback(() => {\n    const el = fieldRef.current;\n    const m = twinRef.current;\n    if (!el || !m) return;\n    const cs = getComputedStyle(el);\n    const line = parseFloat(cs.lineHeight) || 20;\n    const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);\n    m.value = el.value || \" \";\n    const content = m.scrollHeight - pad;\n    const next = Math.min(Math.max(content, minRows * line), maxRows * line) + pad;\n    el.style.height = `${next}px`;\n    setOverflow(content > maxRows * line + 1);\n  }, [minRows, maxRows]);\n\n  useLayoutEffect(measure, [measure, value]);\n\n  useEffect(() => {\n    const el = fieldRef.current;\n    if (!el) return;\n    // Width changes rewrap the text, so the height has to follow.\n    let w = el.offsetWidth;\n    const ro = new ResizeObserver(() => {\n      if (el.offsetWidth === w) return;\n      w = el.offsetWidth;\n      measure();\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [measure]);\n\n  return { fieldRef, twinRef, overflow };\n}\n\nexport function Textarea({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  minRows = 2,\n  maxRows = 8,\n  limit,\n  countFrom,\n  onModEnter,\n  modEnterHint = \"to send\",\n  footer,\n  actions,\n  invalid,\n  disabled,\n  readOnly,\n  className,\n  textareaClassName,\n  onKeyDown,\n  ref,\n  ...rest\n}: TextareaProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const { fieldRef, twinRef, overflow } = useAutosize(value, { minRows, maxRows });\n  const mac = useIsMac();\n  const [fired, setFired] = useState(false);\n  const firedTimer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(firedTimer.current), []);\n\n  const setRef = useCallback(\n    (node: HTMLTextAreaElement | null) => {\n      fieldRef.current = node;\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    },\n    [ref, fieldRef],\n  );\n\n  const length = value.length;\n  const threshold = limit != null ? (countFrom ?? Math.max(10, Math.round(limit * 0.2))) : 0;\n  const showCount = limit != null && length >= limit - threshold;\n  const over = limit != null && length > limit;\n  const isInvalid = invalid || over;\n  const showHint = !!onModEnter && modEnterHint !== false && !readOnly;\n  const hasFooter = footer != null || actions != null || limit != null || showHint;\n\n  // Announce crossings, not keystrokes: entering the last stretch, reaching the limit, going over.\n  const remaining = limit != null ? limit - length : 0;\n  const announcement =\n    limit == null || !showCount ? \"\" : over ? \"Over the character limit\" : remaining === 0 ? \"Character limit reached\" : remaining <= 10 ? \"10 or fewer characters left\" : `${threshold} or fewer characters left`;\n\n  return (\n    <div\n      data-slot=\"textarea\"\n      data-invalid={isInvalid || undefined}\n      data-disabled={disabled || undefined}\n      data-readonly={readOnly || undefined}\n      className={cn(\n        \"group/textarea relative flex w-full min-w-0 flex-col rounded-lg border border-line-2 bg-raised text-fg shadow-[var(--shadow)]\",\n        \"transition-[border-color,box-shadow,background-color] duration-150 ease-out\",\n        \"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3\",\n        \"data-invalid:border-danger/70 data-invalid:hover:border-danger data-invalid:focus-within:border-danger data-invalid:focus-within:ring-danger/15\",\n        \"has-[textarea[data-invalid]]:border-danger/70 has-[textarea[data-invalid]]:focus-within:border-danger has-[textarea[data-invalid]]:focus-within:ring-danger/15\",\n        \"data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:shadow-none data-disabled:hover:border-line-2\",\n        \"data-readonly:bg-frame data-readonly:shadow-none data-readonly:hover:border-line-2 data-readonly:focus-within:border-line-2\",\n        className,\n      )}\n      // Pressing the footer's empty space puts the caret back in the text.\n      onMouseDown={(e) => {\n        const t = e.target as HTMLElement;\n        if (t.closest(\"textarea, button, a, input, [role=button]\") || disabled) return;\n        e.preventDefault();\n        fieldRef.current?.focus();\n      }}\n    >\n      <div className=\"relative\">\n        {/* Base UI Input rendered as a textarea, so Field.Label, validation and Field.Error work as they do for Input. */}\n        <BaseInput\n          ref={setRef as unknown as React.Ref<HTMLInputElement>}\n          value={value}\n          onValueChange={(next) => setValue(next)}\n          disabled={disabled}\n          readOnly={readOnly}\n          aria-invalid={isInvalid || undefined}\n          data-overflow={overflow || undefined}\n          className={cn(\n            \"block w-full resize-none overflow-hidden bg-transparent px-2.5 py-2 text-base leading-6 outline-none sm:text-[13px] sm:leading-5\",\n            \"placeholder:text-fg-4 disabled:cursor-not-allowed\",\n            // Growth eases so a new line reads as the box making room, not a jolt.\n            \"transition-[height] duration-100 ease-out motion-reduce:transition-none\",\n            \"data-overflow:overflow-y-auto [scrollbar-width:thin]\",\n            hasFooter && \"pb-1\",\n            textareaClassName,\n          )}\n          render={\n            <textarea\n              rows={minRows}\n              {...rest}\n              onKeyDown={(e) => {\n                onKeyDown?.(e);\n                if (e.defaultPrevented || !onModEnter) return;\n                if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {\n                  e.preventDefault();\n                  onModEnter(e.currentTarget.value);\n                  setFired(true);\n                  window.clearTimeout(firedTimer.current);\n                  firedTimer.current = window.setTimeout(() => setFired(false), 160);\n                }\n              }}\n            />\n          }\n        />\n        {/* The twin: same box and type, never seen, used only for measuring. */}\n        <textarea\n          ref={twinRef}\n          aria-hidden\n          tabIndex={-1}\n          readOnly\n          rows={1}\n          className={cn(\n            \"pointer-events-none invisible absolute inset-x-0 top-0 block h-0 w-full resize-none overflow-hidden px-2.5 py-2 text-base leading-6 sm:text-[13px] sm:leading-5\",\n            hasFooter && \"pb-1\",\n            textareaClassName,\n          )}\n        />\n      </div>\n\n      {hasFooter && (\n        <div className=\"flex min-h-8 items-center gap-2 pb-1.5 pl-2.5 pr-1.5\">\n          <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n            {showHint && (\n              <span\n                className={cn(\n                  \"flex items-center gap-1 whitespace-nowrap text-[11px] text-fg-4 opacity-0 transition-opacity duration-150 pointer-coarse:hidden\",\n                  \"group-focus-within/textarea:opacity-100\",\n                )}\n              >\n                <span className=\"flex items-center gap-0.5\">\n                  <Key pressed={fired}>{mac ? \"⌘\" : \"Ctrl\"}</Key>\n                  <Key pressed={fired}>↵</Key>\n                </span>\n                {modEnterHint}\n              </span>\n            )}\n            {footer}\n          </div>\n\n          {limit != null && (\n            <span\n              aria-hidden\n              data-state={showCount ? \"visible\" : \"hidden\"}\n              data-over={over || undefined}\n              className={cn(\n                \"flex items-center px-1 text-[11.5px] tabular text-fg-3 transition-[opacity,translate,color] duration-200 ease-out-expo\",\n                \"data-[state=hidden]:translate-y-1 data-[state=hidden]:opacity-0 data-[state=hidden]:duration-100\",\n                \"data-over:text-danger motion-reduce:translate-y-0\",\n              )}\n            >\n              <NumberFlow value={length} />\n              <span className=\"opacity-60\">/{limit}</span>\n            </span>\n          )}\n\n          {actions}\n        </div>\n      )}\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nfunction Key({ pressed, children }: { pressed: boolean; children: React.ReactNode }) {\n  return (\n    <kbd\n      data-pressed={pressed || undefined}\n      className={cn(\n        \"inline-grid h-[18px] min-w-[18px] place-items-center rounded-[4px] border border-line-2 bg-frame px-1 font-sans text-[10.5px] leading-none text-fg-3\",\n        // The keycap dips when the shortcut fires, so the send is acknowledged where the eye already is.\n        \"transition-[scale,background-color,color] duration-150 ease-out data-pressed:scale-[0.88] data-pressed:bg-hover data-pressed:text-fg data-pressed:duration-75\",\n      )}\n    >\n      {children}\n    </kbd>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/textarea"
}
