# TextEditor

**Maturity:** stable

A user-editable control for entering freeform multi-paragraph text — the canonical glass long-form editor.

## Import

```tsx
import { TextEditor } from "@liquidify/react/text-editor"
```

## Props

- `autoComplete`: `string | undefined`. Native `autocomplete` passthrough.
- `className`: `string | undefined`. Optional additional CSS class merged into the glass surface class. The supported per-instance customisation seam: override any `--lq-*` custom property from this class's CSS — the token cascade the engine reads (ADR-0004). No raw inline style is involved, so this stays ADR-0003 clean.
- `defaultValue`: `string | undefined`; default ``""``. Uncontrolled seed for the internal value (ignored once {@link TextSurfaceProps.value} is supplied). Read once, on mount.
- `disabled`: `boolean | undefined`. When `true`, the native `disabled` attribute is set on the inner element: the surface is removed from the tab order and all input is suppressed. `aria-disabled` is NOT set — the native attribute already provides full-suppression semantics.
- `id`: `string | undefined`. Explicit id for the native element. Falls back to a generated stable id so an external `<label htmlFor>` or error message can target it (A16).
- `inputMode`: `TextInputMode | undefined`. Native `inputmode` passthrough — hints the on-screen keyboard variant.
- `invalid`: `boolean | undefined`. Marks the surface invalid — sets `aria-invalid="true"` for AT / validation styling. An explicit `aria-invalid` on the same element always wins (including `false` / `"grammar"` / `"spelling"`); `invalid` is only the shorthand fallback.
- `lineLimit`: `LineLimit | undefined`; default ``{ min: 6, max: 12 }``. Row-range bound. `lineLimit.min` is the region's initial height and drives the native `rows` attribute; `lineLimit.max` caps growth under `sizing="relative"` and is inert under the other two modes. See {@link LineLimit} for the sanitisation policy applied at render time.
- `maxLength`: `number | undefined`. Native `maxlength` passthrough.
- `minLength`: `number | undefined`. Native `minlength` passthrough.
- `name`: `string | undefined`. Native form field name. When set, the element participates in form submission and React Hook Form can register it (A16).
- `onBlur`: `((event: FocusEvent<HTMLTextAreaElement, Element>) => void) | undefined`. Low-level native passthrough; fires on native blur of the element. Never fires while {@link TextSurfaceProps.disabled} is `true`.
- `onChange`: `((next: string) => void) | undefined`. Fires with the next string whenever the content changes — the SwiftUI `Binding<String>` write-back analogue. Called in both controlled and uncontrolled modes; never called while {@link TextSurfaceProps.disabled} or {@link TextSurfaceProps.readOnly}.
- `onFocus`: `((event: FocusEvent<HTMLTextAreaElement, Element>) => void) | undefined`. Low-level native passthrough; fires on native focus of the element. Never fires while {@link TextSurfaceProps.disabled} is `true`.
- `placeholder`: `string | undefined`. Placeholder text shown when the surface is empty — the SwiftUI title-arg analogue (the `prompt` parameter).
- `readOnly`: `boolean | undefined`. When `true`, the surface stays focusable and its value is submitted, but no edit is possible: the native `readOnly` attribute is set and `onChange` is hard-suppressed (never a silent CSS-only block).
- `ref`: `Ref<HTMLTextAreaElement> | undefined`. Ref forwarded to the native element — the surface's primary interactive element.
- `required`: `boolean | undefined`. Marks the surface required — sets the native `required` + `aria-required`.
- `resizable`: `TextEditorResizable | undefined`; default ``"none"``. Whether the reader may drag the region taller. `"vertical"` exposes the native resize handle; the inline axis is never resizable, so the region can never break the enclosing layout's width. Precedence, deliberately documented rather than silently enforced: where the browser supports `field-sizing: content` the UA's auto-sizing wins under `sizing="relative"` / `"content"`, which makes the handle inert or puts it in tension with the `lineLimit.max` cap; and a reader's drag writes an inline height that may exceed that cap. The handle is therefore most meaningful with `sizing="fixed"`. It is not gated on the sizing mode — a prop that silently no-ops under three of four values is worse than a documented precedence rule.
- `sizing`: `TextEditorSizing | undefined`; default ``"fixed"``. Height behaviour of the region (**Q2 SUPERSEDED** — the editor adopts TextField's multiline height vocabulary rather than a bare `rows` count, so the two surfaces cannot disagree on what a row range means). Resolved via `data-sizing` in CSS.
- `tint`: `LiquidTintValue | undefined`. System-accent of the control — drawn from the RESTRICTED {@link LIQUID_COLORS} palette (the thirteen Apple system colours). **Orthogonal to a component's `variant`**: it recolours the accent the surface draws from (focus ring, tinted fill, selection) without changing its variant. Resolved via `data-tint` attribute selectors in CSS (tokens-only, ADR-0003).
- `value`: `string | undefined`. Controlled current text value. Passing this switches the surface to controlled mode: the rendered value always reflects this prop and the consumer owns it via {@link TextSurfaceProps.onChange}.

## SwiftUI mapping

- ``TextEditor(text: Binding<String>)`` → ``value?: string` (controlled) / `defaultValue?: string` (uncontrolled seed) + `onChange?(next: string)`` (direct): String-bearing controlled triad (ADR-0015/0017), wired through the shared `useControllableState` machine exactly as TextField. The `Binding<String>` splits into the controlled/uncontrolled pair; `value !== undefined` selects controlled mode (audit §16). `defaultValue` is read once on mount, default `""`. `onChange` fires the next full string on every edit, in both modes, **never** while `disabled` or `readOnly` (hard-suppressed in the handler, not only via the native attribute — charter §3).
- ``.glassEffect(in: .rect(cornerRadius: 18))`` → `*(internal — fixed rect glass shell)*` (direct): Not a consumer prop. `GlassPrimitive material="regular"` shell at the 18pt-equivalent corner radius token (**[RESOLVED Q3]** — `--lq-radius-18`). `material` is not public (ControlProps, ADR-0023).
- ``.scrollContentBackground(.hidden)`` → `*(internal)*` (direct): The engine shell owns the background; the native `<textarea>` background is transparent so the glass shows through. Not a consumer prop.
- ``.frame(height:)` (demo layout: 160pt / 80pt)` → ``sizing?: TextEditorSizing` + `lineLimit?: LineLimit` (**[Q2 SUPERSEDED]**)` (direct): The demo pins a fixed height per section, and `sizing="fixed"` + `lineLimit.min = 6` (the defaults) reproduce that pinned region exactly — iOS-faithful-first is preserved. What changed is the *vocabulary*: rather than a bare `rows` count, the region speaks the same three-mode height model as TextField's multiline branch (`"fixed"` pins and scrolls, `"relative"` grows `min`→`max` then scrolls, `"content"` grows uncapped), sanitised by the shared `sanitizeLineLimit` against the editor's own `{ min: 6, max: 12 }` default. `lineLimit.min` drives the native `<textarea rows>` attribute. **Not** mapped to `SIZE_SCALE`.
- `— (web-only)` → ``resizable?: TextEditorResizable`` (web-only): No SwiftUI analogue: `TextEditor` has no user-resize affordance, so the axis is off by default (`"none"`) and an undecorated `<TextEditor>` renders exactly what the sim shows. `"vertical"` exposes the native drag handle; the inline axis is never resizable, so the region cannot break the enclosing layout's width. See ## Interaction for the documented precedence against `field-sizing`.
- ``.disabled(true)`` → ``disabled?: boolean`` (direct): Inherited from `ControlProps`; native `disabled` on the `<textarea>` (Shape N, charter §3). Removes from tab order and hard-suppresses `onChange` / `onFocus` / `onBlur`.
- `Accessible name (SwiftUI label)` → ``aria-label` / `aria-labelledby`` (direct): Required; the caller supplies it. Demo `DemoSection` captions are chrome, not real labels.
- `— (native form passthroughs)` → ``readOnly?`, `maxLength?`, `minLength?`, `autoComplete?`, `inputMode?`, `name?`, `id?`, `required?`` (direct): The same native `<textarea>` passthroughs TextField's multiline branch exposes; `readOnly` hard-suppresses `onChange`. `id` falls back to a generated stable id; if the caller supplies one it is used verbatim (audit §16 stable-id rule).
- `— (validation state)` → ``invalid?: boolean` + `aria-invalid?` precedence` (direct): Same contract as TextField: `invalid` is shorthand for `aria-invalid="true"`; an explicit `aria-invalid` (incl. `"grammar"` / `"spelling"` / `false`) always wins, via `ariaInvalid ?? (invalid || undefined)`. `aria-describedby` / `aria-errormessage` forwarded verbatim.
- `— (low-level native)` → ``onFocus?(e)`, `onBlur?(e)`` (direct): `FocusEvent<HTMLTextAreaElement>`; never fire while `disabled` (charter §1 rule 2).
- `— (surface override)` → ``className?`` (direct): The overridable glass-surface class (ADR-0004/0016).
- `— (empty state)` → ``placeholder?: string` (**[RESOLVED Q1]** — adopted)` (direct): SwiftUI `TextEditor` has **no** native placeholder and the demo starts pre-filled; adopted for library consistency with TextField (library-consistency-second, no sim conflict — the demo simply never exercises an empty state). Renders through the native `<textarea>` `placeholder` attribute at the secondary-label token.
- ``.tint(_:)` (**not demoed**)` → ``tint?: LiquidTintValue` (**[RESOLVED Q4]** — adopted)` (direct): TextField exposes tint for caret / selection; the TextEditor demo never exercises it, but TextEditor mirrors TextField's tint surface (extends `TintableControlProps`, default `"blue"`).
