{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "textarea",
  "title": "Textarea",
  "type": "registry:ui",
  "description": "Multi-line text input. Auto-resize variant, character counter, and the same ring/border treatment as the rest of the form primitives.",
  "categories": [
    "form"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/textarea/textarea.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { Check, CircleAlert, Loader, X } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { icon } from '../../lib/icon'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { labelClasses } from '../label/label'\n\ntype Rule = (value: string) => true | string\ntype AutoSize = boolean | { minRows?: number; maxRows?: number }\ntype ShowCount = boolean | { formatter?: (count: number, maxLength?: number) => string }\n\n// `boolean | object` props: a bare attribute means true, JSON means the object\n// (auto-size='{\"minRows\":2,\"maxRows\":6}'). Functions (showCount.formatter)\n// need the property.\nconst boolOrJson = {\n  fromAttribute(v: string | null) {\n    if (v === null || v === 'false') return undefined\n    if (v === '' || v === 'true') return true\n    try {\n      return JSON.parse(v)\n    } catch {\n      return true\n    }\n  },\n}\nconst boolOrNumber = {\n  fromAttribute(v: string | null) {\n    if (v === null || v === 'false') return undefined\n    const n = Number(v)\n    return v !== '' && !Number.isNaN(n) ? n : true\n  },\n}\nconst stringOrList = {\n  fromAttribute(v: string | null) {\n    return v ?? undefined\n  },\n}\n\n// Literal classes (not `rounded-${x}`) so Tailwind compiles them into the\n// shadow sheet; the resulting class names equal React's template string.\nconst roundedClasses: Record<string, string> = {\n  sm: 'rounded-sm',\n  md: 'rounded-md',\n  lg: 'rounded-lg',\n  xl: 'rounded-xl',\n  pill: 'rounded-pill',\n  circle: 'rounded-circle',\n  full: 'rounded-full',\n}\n\n/**\n * <uip-textarea> — the registry Textarea as a web component (one element:\n * label, control, adornments and messages share one shadow root, so\n * `for` / `aria-describedby` resolve).\n *\n * Props are React's, kebab-cased as attributes (`show-count`, `allow-clear`,\n * `max-length`, `auto-size`, `read-only`, `validate-on`, …). Deviations forced\n * by HTMLElement: React's `prefix` / `suffix` are the `prefix` / `suffix`\n * attributes but the `prefixText` / `suffixText` properties (`prefix` is a\n * read-only Element property); `id` isn't forwarded (it stays the host's).\n * `autoSize` / `showCount` / `counter` / `errorMessages` / `successMessages`\n * take a bare attribute, JSON / a number / a string, or the full value as a\n * property; `rules` and `showCount.formatter` are properties only.\n *\n * Value: `value` is the live value (like a native textarea); `default-value`\n * seeds it and is what a form reset restores.\n *\n * Form-associated: submits `name=value`, reports `required` (valueMissing)\n * and error messages (customError), resets with its form, follows a disabled\n * <fieldset>. A native `<label for>` or `<uip-label for>` pointing at the host\n * names the inner textarea.\n *\n * Events: `input` / `change` (bubbling, composed; read `.value`),\n * `value-change` (detail: { value } — React's onValueChange), `clear`\n * (React's onClear). focus / blur / keydown / keyup are the native composed\n * events. Class overrides (className, inputClassName, labelClassName,\n * hintClassName) go through `::part(base | control | textarea | label | hint)`.\n */\nexport class UipTextarea extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    value: {},\n    defaultValue: { attribute: 'default-value' },\n    label: {},\n    placeholder: {},\n    hint: {},\n    error: {},\n    success: {},\n    disabled: { type: Boolean, reflect: true },\n    readOnly: { type: Boolean, attribute: 'read-only', reflect: true },\n    required: { type: Boolean, reflect: true },\n    autoFocus: { type: Boolean, attribute: 'auto-focus' },\n    name: { reflect: true },\n    variant: { reflect: true },\n    density: { reflect: true },\n    rounded: {},\n    autoSize: { attribute: 'auto-size', converter: boolOrJson },\n    autoGrow: { type: Boolean, attribute: 'auto-grow' },\n    noResize: { type: Boolean, attribute: 'no-resize' },\n    autoResize: { type: Boolean, attribute: 'auto-resize' },\n    rows: { type: Number },\n    rowHeight: { type: Number, attribute: 'row-height' },\n    prefixText: { attribute: 'prefix' },\n    suffixText: { attribute: 'suffix' },\n    counter: { converter: boolOrNumber },\n    showCount: { attribute: 'show-count', converter: boolOrJson },\n    maxLength: { type: Number, attribute: 'max-length' },\n    allowClear: { type: Boolean, attribute: 'allow-clear' },\n    rules: { attribute: false },\n    errorMessages: { attribute: 'error-messages', converter: stringOrList },\n    successMessages: { attribute: 'success-messages', converter: stringOrList },\n    validateOn: { attribute: 'validate-on' },\n    loading: { type: Boolean },\n    persistentHint: { type: Boolean, attribute: 'persistent-hint' },\n    persistentPrefix: { type: Boolean, attribute: 'persistent-prefix' },\n    persistentSuffix: { type: Boolean, attribute: 'persistent-suffix' },\n    bgColor: { attribute: 'bg-color' },\n    spellCheck: { attribute: 'spell-check', converter: { fromAttribute: (v: string | null) => (v === null ? undefined : v !== 'false') } },\n    autoComplete: { attribute: 'auto-complete' },\n    accessibleLabel: { attribute: 'aria-label' },\n    focused: { state: true },\n    internalErrorMessages: { state: true },\n    labelText: { state: true },\n  }\n\n  value?: string\n  defaultValue?: string\n  label?: string\n  placeholder?: string\n  hint?: string\n  error?: string\n  success?: string\n  disabled = false\n  readOnly = false\n  required = false\n  autoFocus = false\n  name?: string\n  variant: 'outlined' | 'filled' | 'solo' | 'underlined' | 'plain' = 'outlined'\n  density: 'compact' | 'comfortable' | 'default' = 'default'\n  rounded: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'pill' | 'circle' | 'full' = 'none'\n  autoSize?: AutoSize\n  autoGrow = false\n  noResize = false\n  autoResize = false\n  rows: number | string = 3\n  rowHeight = 24\n  prefixText?: string\n  suffixText?: string\n  counter?: boolean | number\n  showCount?: ShowCount\n  maxLength?: number\n  allowClear = false\n  rules?: Rule[]\n  errorMessages?: string | string[]\n  successMessages?: string | string[]\n  validateOn?: 'blur' | 'input' | 'submit' | 'lazy' | 'blurlazy' | 'inputlazy'\n  loading = false\n  persistentHint = false\n  persistentPrefix = false\n  persistentSuffix = false\n  bgColor?: string\n  spellCheck?: boolean\n  autoComplete?: string\n  accessibleLabel?: string\n  private focused = false\n  private internalErrorMessages: string[] = []\n  private labelText?: string\n\n  private internals = this.attachInternals()\n  private resetValue = ''\n  private minHeightPx = 0\n  private maxHeightPx = Infinity\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'textarea')\n    if (this.value === undefined) this.value = String(this.defaultValue ?? '')\n    this.resetValue = this.defaultValue !== undefined ? String(this.defaultValue) : this.value\n  }\n\n  private get textarea() {\n    return this.renderRoot?.querySelector<HTMLTextAreaElement>('textarea') ?? null\n  }\n\n  private get anyAutoResize() {\n    return this.autoSize !== undefined || this.autoResize || this.autoGrow\n  }\n\n  private get current() {\n    return String(this.value ?? '')\n  }\n\n  // --- validation / derived state -------------------------------------------\n  private runRules(value: string) {\n    if (!this.rules || this.rules.length === 0) return []\n    const next: string[] = []\n    for (const rule of this.rules) {\n      const result = rule(value)\n      if (result !== true) next.push(result as string)\n    }\n    return next\n  }\n\n  /** Run `rules` against the current value (React's validate). Returns true when valid. */\n  validate() {\n    this.internalErrorMessages = this.runRules(this.current)\n    return this.internalErrorMessages.length === 0\n  }\n\n  private get computedErrorMessages() {\n    if (this.errorMessages) return Array.isArray(this.errorMessages) ? this.errorMessages : [this.errorMessages]\n    if (this.error) return [this.error]\n    return this.internalErrorMessages\n  }\n\n  private get computedSuccessMessages() {\n    if (this.successMessages) return Array.isArray(this.successMessages) ? this.successMessages : [this.successMessages]\n    if (this.success) return [this.success]\n    return []\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('value')) this.internals.setFormValue(this.current)\n    const labels = [...(this.internals.labels ?? [])] as HTMLElement[]\n    this.labelText = labels.map((l) => l.textContent?.trim()).filter(Boolean).join(' ') || undefined\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    const errors = this.computedErrorMessages\n    const anchor = this.textarea ?? undefined\n    if (this.required && !this.current) {\n      this.internals.setValidity({ valueMissing: true }, 'Please fill out this field.', anchor)\n    } else if (errors.length) {\n      this.internals.setValidity({ customError: true }, errors[0], anchor)\n    } else {\n      this.internals.setValidity({})\n    }\n    if (changed.has('value') && this.anyAutoResize) requestAnimationFrame(() => this.autoResizeFn())\n  }\n\n  protected firstUpdated() {\n    requestAnimationFrame(() => {\n      this.measureHeights()\n      if (this.anyAutoResize) this.autoResizeFn()\n    })\n  }\n\n  // --- form callbacks ---------------------------------------------------------\n  formResetCallback() {\n    this.value = this.resetValue\n    this.internalErrorMessages = []\n  }\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  // --- auto size (ported from React) -------------------------------------------\n  private autoResizeFn() {\n    const el = this.textarea\n    if (!el || !this.anyAutoResize) return\n    el.style.height = 'auto'\n    let newHeight = el.scrollHeight\n    if (this.minHeightPx && newHeight < this.minHeightPx) newHeight = this.minHeightPx\n    if (newHeight > this.maxHeightPx) {\n      newHeight = this.maxHeightPx\n      el.style.overflowY = 'auto'\n    } else {\n      el.style.overflowY = 'hidden'\n    }\n    el.style.height = `${newHeight}px`\n  }\n\n  private measureHeights() {\n    const el = this.textarea\n    if (!el || this.autoSize === undefined) return\n    const originalValue = el.value\n    const originalRows = el.rows\n    const originalOverflow = el.style.overflowY\n    el.value = ''\n    el.style.overflowY = 'hidden'\n    const { minRows, maxRows } = typeof this.autoSize === 'object' ? this.autoSize : ({} as { minRows?: number; maxRows?: number })\n    if (minRows) {\n      el.rows = minRows\n      this.minHeightPx = el.scrollHeight\n    } else {\n      this.minHeightPx = 0\n    }\n    if (maxRows) {\n      el.rows = maxRows\n      this.maxHeightPx = el.scrollHeight\n    } else {\n      this.maxHeightPx = Infinity\n    }\n    el.value = originalValue\n    el.rows = originalRows\n    el.style.overflowY = originalOverflow\n    this.autoResizeFn()\n  }\n\n  private get computedRows() {\n    const rowsNum = Number(this.rows) || 3\n    if (this.autoSize !== undefined || this.autoResize) return rowsNum\n    if (!this.autoGrow) return rowsNum\n    const el = isServer ? null : this.textarea\n    if (!el) return rowsNum\n    const newRows = Math.ceil((el.scrollHeight - this.rowHeight) / this.rowHeight) + 1\n    return Math.max(rowsNum, newRows)\n  }\n\n  // --- events -------------------------------------------------------------------\n  private setValue(next: string) {\n    this.value = next\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: next }, bubbles: true, composed: true }))\n  }\n\n  private onInput(e: Event) {\n    const next = (e.target as HTMLTextAreaElement).value\n    this.setValue(next)\n    if (this.anyAutoResize) this.autoResizeFn()\n    if (this.validateOn === 'input' || this.validateOn === 'inputlazy') this.internalErrorMessages = this.runRules(next)\n    // The native `input` event is composed and already reaches the host.\n  }\n\n  private onChange() {\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n  }\n\n  private onClear() {\n    this.setValue('')\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('clear', { bubbles: true, composed: true }))\n    requestAnimationFrame(() => {\n      this.autoResizeFn()\n      this.textarea?.focus()\n    })\n  }\n\n  private onFocus() {\n    this.focused = true\n  }\n\n  private onBlur() {\n    this.focused = false\n    if (this.validateOn === 'blur' || this.validateOn === 'blurlazy') this.validate()\n  }\n\n  // --- render ---------------------------------------------------------------------\n  render() {\n    const errors = this.computedErrorMessages\n    const successes = this.computedSuccessMessages\n    const hasError = errors.length > 0\n    const hasSuccess = successes.length > 0\n    const focused = this.focused\n    const current = this.current\n    const currentLength = current.length\n    const maxLength = this.maxLength\n\n    const computedCounter =\n      typeof this.counter === 'number' ? this.counter : this.counter ? (maxLength ?? 100) : null\n\n    const showCountEnabled = this.showCount !== undefined && this.showCount !== false\n    const formatter = typeof this.showCount === 'object' ? this.showCount.formatter : undefined\n    const countText = formatter\n      ? formatter(currentLength, maxLength)\n      : maxLength !== undefined\n        ? `${currentLength} / ${maxLength}`\n        : `${currentLength}`\n\n    const showClear = this.allowClear && !this.disabled && !this.readOnly && currentLength > 0\n\n    const base = 'w-full transition-colors duration-200'\n    let variantClasses = base\n    switch (this.variant) {\n      case 'outlined':\n        variantClasses = cn(\n          base,\n          'border-2 rounded-lg',\n          focused ? 'border-primary ring-2 ring-primary/20' : 'border-input',\n          hasError && 'border-destructive focus:border-destructive focus:ring-destructive/20',\n        )\n        break\n      case 'filled':\n        variantClasses = cn(\n          base,\n          'border-b-2 bg-muted/50 rounded-t-lg',\n          focused ? 'border-primary bg-muted' : 'border-transparent',\n          hasError && 'border-destructive',\n        )\n        break\n      case 'solo':\n        variantClasses = cn(base, 'rounded-lg shadow-sm', focused ? 'shadow-md' : 'shadow-sm', 'bg-card border border-transparent')\n        break\n      case 'underlined':\n        variantClasses = cn(\n          base,\n          'border-b-2 rounded-none border-x-0 border-t-0 px-0',\n          focused ? 'border-primary' : 'border-muted-foreground/30',\n          hasError && 'border-destructive',\n        )\n        break\n      case 'plain':\n        variantClasses = cn(base, 'border-0 bg-transparent')\n        break\n    }\n\n    const densityClasses =\n      this.density === 'compact'\n        ? 'text-sm min-h-[32px]'\n        : this.density === 'comfortable'\n          ? 'text-base min-h-[40px]'\n          : 'text-base min-h-[48px]'\n\n    const resizeClasses = this.noResize ? 'resize-none' : this.anyAutoResize ? 'resize-none' : 'resize-y'\n    const describedBy = hasError || hasSuccess || this.hint ? 'uip-textarea-description' : nothing\n\n    return html`<div part=\"base\" class=\"relative space-y-2\">\n      ${this.label\n        ? html`<label\n            part=\"label\"\n            for=\"uip-textarea\"\n            data-uipkge=\"\"\n            data-slot=\"label\"\n            class=${cn(\n              labelClasses,\n              'text-foreground text-sm font-medium',\n              focused && 'text-primary',\n              hasError && 'text-destructive',\n            )}\n            >${this.label}${this.required ? html`<span class=\"text-destructive ml-0.5\">*</span>` : nothing}</label\n          >`\n        : nothing}\n\n      <div\n        part=\"control\"\n        class=${cn(\n          'relative flex items-center',\n          variantClasses,\n          densityClasses,\n          this.disabled && 'pointer-events-none opacity-50',\n          this.readOnly && !this.disabled && 'cursor-default',\n          this.rounded !== 'none' && roundedClasses[this.rounded],\n        )}\n        style=${styleMap(this.bgColor ? { backgroundColor: this.bgColor } : {})}\n      >\n        ${this.prefixText\n          ? html`<span\n              class=${cn(\n                'text-muted-foreground pointer-events-none absolute top-3 left-3 text-sm',\n                !this.persistentPrefix && !focused && 'opacity-50',\n              )}\n              >${this.prefixText}</span\n            >`\n          : nothing}\n\n        <textarea\n          part=\"textarea\"\n          id=\"uip-textarea\"\n          .value=${current}\n          placeholder=${this.placeholder ?? nothing}\n          ?disabled=${this.disabled}\n          ?readonly=${this.readOnly}\n          ?required=${this.required}\n          name=${this.name ?? nothing}\n          autocomplete=${this.autoComplete ?? nothing}\n          ?autofocus=${this.autoFocus}\n          spellcheck=${this.spellCheck === undefined ? nothing : String(this.spellCheck)}\n          maxlength=${maxLength ?? nothing}\n          rows=${this.computedRows}\n          aria-label=${!this.label ? (this.accessibleLabel ?? this.labelText ?? nothing) : nothing}\n          aria-describedby=${describedBy}\n          aria-invalid=${hasError ? 'true' : nothing}\n          class=${cn(\n            'w-full flex-1 resize-y bg-transparent outline-none',\n            densityClasses,\n            resizeClasses,\n            this.prefixText ? 'pl-16' : 'pl-3',\n            this.suffixText ? 'pr-16' : showClear ? 'pr-10' : 'pr-3',\n            showCountEnabled && 'pb-6',\n            'py-2',\n          )}\n          @input=${this.onInput}\n          @change=${this.onChange}\n          @focus=${this.onFocus}\n          @blur=${this.onBlur}\n        ></textarea>\n\n        ${this.suffixText\n          ? html`<span\n              class=${cn(\n                'text-muted-foreground pointer-events-none absolute top-3 right-3 text-sm',\n                !this.persistentSuffix && !focused && 'opacity-50',\n              )}\n              >${this.suffixText}</span\n            >`\n          : nothing}\n        ${showClear\n          ? html`<button\n              type=\"button\"\n              tabindex=\"-1\"\n              aria-label=\"Clear\"\n              class=${cn(\n                'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-3 flex items-center justify-center rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none',\n                this.suffixText ? 'right-10' : 'right-3',\n              )}\n              @click=${this.onClear}\n            >\n              ${icon(X, 'x', 'size-4')}\n            </button>`\n          : nothing}\n        ${this.loading\n          ? html`<div class=\"absolute top-3 right-3 flex items-center justify-center\">\n              ${icon(Loader, 'loader', 'text-muted-foreground size-4 animate-spin')}\n            </div>`\n          : nothing}\n        ${hasSuccess && !this.loading\n          ? html`<div class=\"text-success absolute top-3 right-3 flex items-center justify-center\">\n              ${icon(Check, 'check', 'size-4')}\n            </div>`\n          : nothing}\n        ${hasError && !this.loading\n          ? html`<div class=\"text-destructive absolute top-3 right-3 flex items-center justify-center\">\n              ${icon(CircleAlert, 'circle-alert', 'size-4')}\n            </div>`\n          : nothing}\n        ${showCountEnabled\n          ? html`<div\n              class=${cn(\n                'text-muted-foreground pointer-events-none absolute right-3 bottom-1.5 text-xs',\n                maxLength !== undefined && currentLength > maxLength && 'text-destructive',\n              )}\n            >\n              ${countText}\n            </div>`\n          : nothing}\n      </div>\n\n      <div id=\"uip-textarea-description\" class=\"mt-1.5\">\n        ${this.hint && (!hasError || this.persistentHint) && !focused\n          ? html`<p part=\"hint\" class=\"text-muted-foreground text-sm\">${this.hint}</p>`\n          : nothing}\n        ${errors.map(\n          (msg) => html`<p class=\"text-destructive flex items-center gap-1 text-sm\" role=\"alert\">\n            ${icon(CircleAlert, 'circle-alert', 'size-3 shrink-0')}${msg}\n          </p>`,\n        )}\n        ${successes.map(\n          (msg) => html`<p class=\"text-success flex items-center gap-1 text-sm\">\n            ${icon(Check, 'check', 'size-3 shrink-0')}${msg}\n          </p>`,\n        )}\n        ${computedCounter !== null\n          ? html`<div\n              class=${cn('text-muted-foreground mt-1 text-right text-xs', currentLength > computedCounter && 'text-destructive')}\n            >\n              ${currentLength} / ${computedCounter}\n            </div>`\n          : nothing}\n      </div>\n\n      <slot></slot>\n    </div>`\n  }\n}\n\ncustomElements.get('uip-textarea') || customElements.define('uip-textarea', UipTextarea)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-textarea': UipTextarea\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/textarea/textarea.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/label.json",
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
