{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"textarea","type":"registry:ui","title":"Textarea","description":"A multi-line text field with an optional live character counter and auto-growing height, built on a native textarea","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["forms"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/textarea/Textarea.tsx","content":"\"use client\";\n\nimport { forwardRef, useRef, useState } from \"react\";\nimport type { ChangeEvent, ComponentPropsWithoutRef, HTMLInputAutoCompleteAttribute } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useField } from \"../../internals/field\";\nimport { mergeIds } from \"../../internals/merge-ids\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport \"./textarea.css\";\n\nexport interface TextareaOwnProps {\n\t/**\n\t * Current value. Controlled when supplied: pair it with `onValueChange`,\n\t * the React counterpart of the Svelte source's `bind:value`.\n\t */\n\tvalue?: string;\n\t/** Initial value when uncontrolled (no `value` prop given). */\n\tdefaultValue?: string;\n\t/** Called with the new value on every input event. */\n\tonValueChange?: (value: string) => void;\n\t/** Shown while the field is empty. */\n\tplaceholder?: string;\n\t/** Blocks focus and typing; excluded from form submission. Overridden by a surrounding FormField. */\n\tdisabled?: boolean;\n\t/** Blocks typing but stays focusable and is still submitted, unlike `disabled`. */\n\treadonly?: boolean;\n\t/** Native `required`. Overridden by a surrounding FormField. */\n\trequired?: boolean;\n\t/** Drives the error border and `aria-invalid`. Overridden by a surrounding FormField. */\n\tinvalid?: boolean;\n\t/** Element id. Overridden by a surrounding FormField's own `controlId`. */\n\tid?: string;\n\t/** Native `name`, read on form submission. */\n\tname?: string;\n\t/** Native `autocomplete` hint — the real token set the DOM accepts, not a bare string. */\n\tautocomplete?: HTMLInputAutoCompleteAttribute;\n\t/** Accessible name — for a control with no visible Label next to it. */\n\tlabel?: string;\n\t/** Visible height in text rows before anything grows it. Also the no-JS fallback height. */\n\trows?: number;\n\t/** Native character ceiling; also the counter's denominator. */\n\tmaxlength?: number;\n\t/** Renders the live \"n / max\" counter under the field. */\n\tshowCount?: boolean;\n\t/** Grows to fit content instead of scrolling; disables manual resize. */\n\tautoResize?: boolean;\n\t/** Additional CSS classes — applied to the `<textarea>` itself, not the wrapper. */\n\tclassName?: string;\n}\n\n/**\n * Own props plus every native `<textarea>` attribute and listener; the rest are\n * spread onto the inner `<textarea>` (see the component README for the rules).\n */\nexport interface TextareaProps\n\textends Omit<ComponentPropsWithoutRef<\"textarea\">, keyof TextareaOwnProps>, TextareaOwnProps {}\n\n/**\n * A styled native `<textarea>` with full FormField integration, an optional\n * character counter and an optional grow-to-fit mode.\n *\n * The element reference arrives through the ref channel (the Svelte source\n * declares `ref = $bindable(null)`), pointing at the native `<textarea>`.\n */\nexport const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(\n\t(\n\t\t{\n\t\t\tvalue: valueProp,\n\t\t\tdefaultValue = \"\",\n\t\t\tonValueChange,\n\t\t\tplaceholder,\n\t\t\tdisabled = false,\n\t\t\treadonly = false,\n\t\t\trequired = false,\n\t\t\tinvalid = false,\n\t\t\tid,\n\t\t\tname,\n\t\t\tautocomplete,\n\t\t\tlabel,\n\t\t\trows = 3,\n\t\t\tmaxlength,\n\t\t\tshowCount = false,\n\t\t\tautoResize = false,\n\t\t\tclassName,\n\t\t\tautoComplete: autoCompleteAttr,\n\t\t\tmaxLength: maxLengthAttr,\n\t\t\tonChange,\n\t\t\t\"aria-describedby\": consumerDescribedBy,\n\t\t\t\"aria-label\": consumerAriaLabel,\n\t\t\t\"aria-invalid\": consumerAriaInvalid,\n\t\t\treadOnly: readOnlyAttr,\n\t\t\t...rest\n\t\t},\n\t\tforwardedRef\n\t) => {\n\t\t// The Svelte side's `value = $bindable(\"\")` becomes the standard\n\t\t// controlled/uncontrolled split: uncontrolled by default, controlled\n\t\t// the moment a `value` prop is passed.\n\t\tconst isControlled = valueProp !== undefined;\n\t\tconst [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);\n\t\tconst value = isControlled ? valueProp : uncontrolledValue;\n\n\t\t// Undefined outside a FormField — every fallback below then uses this\n\t\t// component's own props instead of the context, so the control works\n\t\t// standalone exactly as it does wrapped.\n\t\tconst field = useField();\n\n\t\tconst effectiveId = field?.controlId ?? id;\n\t\tconst effectiveDisabled = field?.disabled ?? disabled;\n\t\tconst effectiveRequired = field?.required ?? required;\n\t\tconst effectiveInvalid = field?.invalid ?? invalid;\n\n\t\tconst reactId = useFancyId();\n\t\tconst countId = `${reactId}-count`;\n\t\tconst count = value.length;\n\t\tconst effectiveMax = maxlength ?? maxLengthAttr;\n\t\tconst atLimit = effectiveMax != null && count >= effectiveMax;\n\n\t\t// The count rides along on aria-describedby instead of an aria-live\n\t\t// region. A screen reader announces a field's description once, when\n\t\t// focus lands on it — wiring the counter into the description reports\n\t\t// the current count on focus without re-announcing it on every keystroke\n\t\t// the way a polite live region would while the user is still typing. The\n\t\t// visible counter span below *is* the description target, so there is no\n\t\t// separate hidden node to keep in sync with it.\n\t\tconst describedBy = mergeIds(\n\t\t\tfield?.describedBy,\n\t\t\tconsumerDescribedBy,\n\t\t\tshowCount ? countId : undefined\n\t\t);\n\n\t\tconst classes = cn(\n\t\t\t\"ft-textarea w-full rounded-[8px] border border-input bg-background px-[12px] py-[10px] text-[13px] leading-[1.5] text-foreground transition-colors\",\n\t\t\t\"placeholder:text-muted-foreground\",\n\t\t\t\"focus-visible:outline-none\",\n\t\t\t\"disabled:cursor-not-allowed disabled:opacity-50\",\n\t\t\tautoResize ? \"resize-none overflow-hidden\" : \"resize-y\",\n\t\t\teffectiveInvalid && \"border-destructive/50\",\n\t\t\tclassName\n\t\t);\n\n\t\tconst innerRef = useRef<HTMLTextAreaElement | null>(null);\n\t\tconst composedRef = useComposedRefs(forwardedRef, innerRef);\n\n\t\t// Measured once from the mounted element: line-height/padding/border\n\t\t// don't change under this component's feet, so re-reading them on every\n\t\t// keystroke would force a layout for an answer already known.\n\t\tconst metricsRef = useRef<{ line: number; extra: number } | null>(null);\n\n\t\tfunction px(input: string): number {\n\t\t\tconst parsed = Number.parseFloat(input);\n\t\t\treturn Number.isFinite(parsed) ? parsed : 0;\n\t\t}\n\n\t\tfunction measure(el: HTMLTextAreaElement): { line: number; extra: number } {\n\t\t\tif (metricsRef.current) return metricsRef.current;\n\t\t\tconst style = getComputedStyle(el);\n\t\t\tconst declared = Number.parseFloat(style.lineHeight);\n\t\t\tconst fontSize = Number.parseFloat(style.fontSize);\n\t\t\t// jsdom (and a `line-height: normal` in real browsers) reports no\n\t\t\t// usable line-height at all — fall back to a 1.5x multiple of the\n\t\t\t// font size, then to a flat pixel guess if even that is unavailable.\n\t\t\tconst line =\n\t\t\t\tNumber.isFinite(declared) && declared > 0\n\t\t\t\t\t? declared\n\t\t\t\t\t: Number.isFinite(fontSize) && fontSize > 0\n\t\t\t\t\t\t? fontSize * 1.5\n\t\t\t\t\t\t: 20;\n\t\t\tconst padding = px(style.paddingTop) + px(style.paddingBottom);\n\t\t\t// scrollHeight covers content and padding but never the border, which\n\t\t\t// a border-box height does include.\n\t\t\tconst border =\n\t\t\t\tstyle.boxSizing === \"border-box\"\n\t\t\t\t\t? px(style.borderTopWidth) + px(style.borderBottomWidth)\n\t\t\t\t\t: 0;\n\t\t\tmetricsRef.current = { line, extra: padding + border };\n\t\t\treturn metricsRef.current;\n\t\t}\n\n\t\t/**\n\t\t * Fits the box to its content, floored at `rows`.\n\t\t *\n\t\t * `height: auto` first, otherwise `scrollHeight` reports the box already\n\t\t * set rather than the text inside it, and the element could only ever\n\t\t * grow. Measures and writes within the same call, and only ever touches\n\t\t * `el.style` — never React state — so this can never re-trigger the\n\t\t * effect that calls it below.\n\t\t */\n\t\tfunction grow() {\n\t\t\tconst el = innerRef.current;\n\t\t\tif (!el) return;\n\t\t\tconst { line, extra } = measure(el);\n\t\t\tel.style.height = \"auto\";\n\t\t\tconst min = rows * line + extra;\n\t\t\tel.style.height = `${Math.max(el.scrollHeight, min)}px`;\n\t\t}\n\n\t\tfunction handleInput(event: ChangeEvent<HTMLTextAreaElement>) {\n\t\t\tif (effectiveDisabled) return;\n\t\t\tconst next = event.currentTarget.value;\n\t\t\tif (!isControlled) setUncontrolledValue(next);\n\t\t\tonValueChange?.(next);\n\t\t\tif (autoResize) grow();\n\t\t\tonChange?.(event);\n\t\t}\n\n\t\t// Growth also has to answer to writes that never pass through\n\t\t// `handleInput` — a controlled value assigned from outside, or a\n\t\t// restored draft. Reads `value`, writes only `el.style.height`, so it\n\t\t// can never wake itself. Layout phase, so the height lands before the\n\t\t// user's first painted frame.\n\t\tuseIsomorphicLayoutEffect(() => {\n\t\t\tif (!autoResize) return;\n\t\t\tgrow();\n\t\t\t// grow() reads `rows` and the rendered `value`; both belong in the deps.\n\t\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t\t}, [autoResize, value, rows]);\n\n\t\treturn (\n\t\t\t<div className=\"ft-textarea-wrapper flex w-full flex-col gap-1.5\">\n\t\t\t\t<textarea\n\t\t\t\t\t{...rest}\n\t\t\t\t\tref={composedRef}\n\t\t\t\t\tid={effectiveId}\n\t\t\t\t\tplaceholder={placeholder}\n\t\t\t\t\tname={name}\n\t\t\t\t\tautoComplete={autocomplete ?? autoCompleteAttr}\n\t\t\t\t\trows={rows}\n\t\t\t\t\tmaxLength={effectiveMax}\n\t\t\t\t\tdisabled={effectiveDisabled}\n\t\t\t\t\treadOnly={readonly || readOnlyAttr}\n\t\t\t\t\trequired={effectiveRequired}\n\t\t\t\t\taria-invalid={effectiveInvalid ? \"true\" : consumerAriaInvalid}\n\t\t\t\t\taria-describedby={describedBy}\n\t\t\t\t\taria-label={label ?? consumerAriaLabel}\n\t\t\t\t\tclassName={classes}\n\t\t\t\t\tvalue={value}\n\t\t\t\t\tonChange={handleInput}\n\t\t\t\t></textarea>\n\t\t\t\t{showCount && (\n\t\t\t\t\t<span\n\t\t\t\t\t\tid={countId}\n\t\t\t\t\t\tclassName=\"ft-textarea-count text-muted-foreground self-end text-[11px]\"\n\t\t\t\t\t\tdata-limit-reached={atLimit ? \"true\" : undefined}\n\t\t\t\t\t>\n\t\t\t\t\t\t{count}\n\t\t\t\t\t\t{effectiveMax != null ? ` / ${effectiveMax}` : \"\"}\n\t\t\t\t\t</span>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t);\n\t}\n);\n\nTextarea.displayName = \"Textarea\";\n","type":"registry:ui","target":"@ui/fancy/components/textarea/Textarea.tsx"},{"path":"react/src/components/textarea/index.ts","content":"\"use client\";\n\nexport { Textarea } from \"./Textarea\";\nexport type { TextareaProps } from \"./Textarea\";\n","type":"registry:ui","target":"@ui/fancy/components/textarea/index.ts"},{"path":"react/src/components/textarea/textarea.css","content":"/*\n * The brand accent has no semantic token, so it is declared locally with a\n * light-dark() fallback — the same shape Toggle, Button and Input use for\n * their own accent ring.\n */\n.ft-textarea:focus-visible {\n\t--ft-textarea-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n\tborder-color: var(--ft-textarea-accent);\n\tbox-shadow: 0 0 0 3px color-mix(in oklab, var(--ft-textarea-accent) 25%, transparent);\n}\n\n/* Reaching the character limit needs a cue that survives without colour:\n   weight carries it here, so a colour-blind or monochrome-display reader\n   still sees the counter's state change, not just its digits. */\n.ft-textarea-count[data-limit-reached=\"true\"] {\n\tfont-weight: 600;\n}\n","type":"registry:ui","target":"@ui/fancy/components/textarea/textarea.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/textarea?framework=react"}}