{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-textarea","type":"registry:component","title":"Textarea (Motion)","description":"Textarea with motion: it glides taller with each new line and back down when cleared, and shakes when it turns invalid while its error grows in.","dependencies":["motion"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/motion-field.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/field.json","https://dashral.com/r/textarea.json"],"files":[{"path":"registry/dashral/motion/textarea.tsx","type":"registry:file","target":"components/dashral/motion/textarea.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { animate, type AnimationPlaybackControls } from \"motion/react\"\nimport { Field, joinIds } from \"../field\"\nimport { Textarea as KitTextarea, type TextareaProps } from \"../textarea\"\nimport { cn } from \"../lib/utils\"\n\nimport { FieldMessage } from \"../lib/motion-field\"\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { spring } from \"../lib/motion-tokens\"\n\nexport type { TextareaProps } from \"../textarea\"\n\n/** What the error says, for telling a new error from a re-render of the same one; null when there's none. */\nfunction errorKey(error: TextareaProps[\"error\"]) {\n  if (!error) return null\n  return typeof error === \"string\" || typeof error === \"number\" ? String(error) : typeof error === \"boolean\" ? \"\" : \"node\"\n}\n\n/**\n * The kit's Textarea with motion: the same label, description, error and props. It grows with what's typed as the\n * kit's does, but glides to each new line's height instead of jumping, and back down when text is cleared. When it\n * turns invalid, and again at every try to submit its form while it's still invalid, it gives a short shake, and the error message grows in under it and folds away when it's fixed. With\n * reduced motion it resizes at once, nothing shakes, and messages only fade.\n */\nexport function Textarea({ label, description, error, labelAction, withAsterisk, id, required, disabled, className, classNames, ref, onInput, ...props }: TextareaProps) {\n  const reduced = useReducedMotion()\n  const generatedId = React.useId()\n  const fieldId = id ?? generatedId\n  const area = React.useRef<HTMLTextAreaElement | null>(null)\n  const [element, setElement] = React.useState<HTMLTextAreaElement | null>(null)\n  const setRef = React.useCallback(\n    (node: HTMLTextAreaElement | null) => {\n      area.current = node\n      setElement(node)\n      if (typeof ref === \"function\") ref(node)\n      else if (ref) ref.current = node\n    },\n    [ref]\n  )\n\n  // Its height: what its text needs (as the kit's field-sizing has it), reached on a glide.\n  const glide = React.useRef<AnimationPlaybackControls | null>(null)\n  const fit = React.useCallback(\n    (now: boolean) => {\n      const node = area.current\n      if (!node) return\n      const from = node.offsetHeight\n      node.style.height = \"auto\"\n      const to = node.scrollHeight + node.offsetHeight - node.clientHeight\n      glide.current?.stop()\n      if (now || reduced || from === to || !from) {\n        node.style.height = `${to}px`\n        return\n      }\n      node.style.height = `${from}px`\n      glide.current = animate(from, to, { ...spring.glide, onUpdate: (h) => (node.style.height = `${h}px`) })\n    },\n    [reduced]\n  )\n  // Set from outside (a form reset, a message sent), it glides too; the first measure is at once.\n  const measured = React.useRef(false)\n  React.useLayoutEffect(() => {\n    fit(!measured.current)\n    measured.current = true\n  }, [fit, props.value])\n  // A new width rewraps the text: follow it at once.\n  React.useEffect(() => {\n    const node = area.current\n    if (!node) return\n    let width = node.offsetWidth\n    const observer = new ResizeObserver(() => {\n      if (node.offsetWidth === width) return\n      width = node.offsetWidth\n      fit(true)\n    })\n    observer.observe(node)\n    return () => {\n      observer.disconnect()\n      glide.current?.stop()\n    }\n  }, [fit])\n\n  const key = errorKey(error)\n  const shown = React.useRef(key)\n  const shake = React.useCallback(() => {\n    const box = area.current\n    if (reduced || !box) return\n    animate(box, { x: [0, -6, 5, -3, 2, 0] }, { duration: 0.36, ease: \"easeOut\" })\n  }, [reduced])\n  // It shakes when it turns invalid or the error changes…\n  React.useEffect(() => {\n    if (shown.current === key) return\n    shown.current = key\n    if (key !== null) shake()\n  }, [key, shake])\n  // …and again at every try to submit its form while it's still invalid, so a second click on Submit isn't met with\n  // nothing; the browser's own check (required, type=\"email\") finding it invalid shakes it too.\n  React.useEffect(() => {\n    const form = element?.form\n    if (!element) return\n    // Before the form's own handler (capture), so it's the error from the last try that counts; a new one shakes above.\n    const onSubmit = () => {\n      if (shown.current !== null) shake()\n    }\n    form?.addEventListener(\"submit\", onSubmit, true)\n    element.addEventListener(\"invalid\", shake)\n    return () => {\n      form?.removeEventListener(\"submit\", onSubmit, true)\n      element.removeEventListener(\"invalid\", shake)\n    }\n  }, [element, shake])\n\n  const message = error && typeof error !== \"boolean\" ? error : null\n  const descriptionId = description ? `${fieldId}-description` : undefined\n  const errorId = message ? `${fieldId}-error` : undefined\n  const control = (\n    <KitTextarea\n      {...props}\n      ref={setRef}\n      id={fieldId}\n      required={required}\n      disabled={disabled}\n      aria-invalid={props[\"aria-invalid\"] ?? (error ? true : undefined)}\n      aria-describedby={joinIds(props[\"aria-describedby\"], descriptionId, errorId)}\n      onInput={(event) => {\n        fit(false)\n        onInput?.(event)\n      }}\n      // The height is this component's, so the browser's own sizing (and its resize handle) stand aside.\n      className={cn(\"field-sizing-fixed resize-none overflow-hidden\", className)}\n      classNames={classNames}\n    />\n  )\n\n  if (!label && !description && !message) return control\n\n  return (\n    <Field\n      id={fieldId}\n      label={label}\n      labelAction={labelAction}\n      withAsterisk={withAsterisk}\n      required={required}\n      disabled={disabled}\n      error={Boolean(error)}\n      className={classNames?.root}\n      classNames={{ label: classNames?.label }}\n    >\n      {/* No gap in here: each line under the box brings its own space, so a message's space grows in with it. */}\n      <div className=\"grid min-w-0\">\n        {control}\n        {description && (\n          <p id={descriptionId} data-slot=\"field-description\" className={cn(\"pt-2 text-xs text-muted-foreground\", classNames?.description)}>\n            {description}\n          </p>\n        )}\n        <FieldMessage id={errorId} message={message} className={classNames?.error} />\n      </div>\n    </Field>\n  )\n}\n"}]}