# TextField

**Maturity:** experimental

A user-editable control for entering freeform text — the canonical glass input field.

## Import

```tsx
import { TextField } from "@liquidify/react/text-field"
```

## 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.
- `clearable`: `boolean | undefined`; default ``false``. When `true`, a trailing clear button renders whenever the resolved value is non-empty and the field is neither `disabled` nor `readOnly`. Pressing it commits `""` through the controlled triad, fires {@link TextFieldChromeProps.onClear}, and returns focus to the field. `Escape` takes the same path.
- `clearLabel`: `string | undefined`; default ``"Clear text"``. Accessible name of the clear button.
- `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.
- `distribution`: `"fill" | "fit" | undefined`; default ``"fill"``. Layout of the field's DOM root — the TextField sibling of SegmentedControl's `distribution` axis (`NSSegmentedControl.Distribution` pedigree, charter §5). `"fill"` fills the container width; `"fit"` hugs its content. Resolved via the `data-distribution` attribute selector in `styles.css`.
- `icon`: `ReactNode`. Optional decorative node rendered before the text — the in-field leading glyph (a magnifier for a search field), following the `Label` / `EmptyState` icon convention. Rendered `aria-hidden`, so it is never the accessible name: still supply `aria-label` / `aria-labelledby`. On the multiline branch it aligns to the first text row rather than the box's vertical centre.
- `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: 2, max: 4 }``. Not on the single-line branch — `lineLimit` is multiline-only. See {@link TextFieldSingleLineProps.sizing}. Row-range bound — maps to SwiftUI's `.lineLimit(min...max)`. See {@link LineLimit} for the sanitisation policy applied at render time.
- `maxLength`: `number | undefined`. Native `maxlength` passthrough.
- `minLength`: `number | undefined`. Native `minlength` passthrough.
- `multiline`: `boolean | undefined`. Discriminant: absent/`false` selects the single-line (`<input>`) branch. Discriminant: `true` selects the multiline (`<textarea>`) branch.
- `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) | ((event: FocusEvent<HTMLInputElement, 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}.
- `onClear`: `(() => void) | undefined`. Fires after the value is cleared via the clear button or `Escape`.
- `onFocus`: `((event: FocusEvent<HTMLTextAreaElement, Element>) => void) | ((event: FocusEvent<HTMLInputElement, Element>) => void) | undefined`. Low-level native passthrough; fires on native focus of the element. Never fires while {@link TextSurfaceProps.disabled} is `true`.
- `onKeyDown`: `((event: KeyboardEvent<HTMLInputElement>) => void) | ((event: KeyboardEvent<HTMLTextAreaElement>) => void) | undefined`. Low-level native passthrough; fires on `keydown` of the `<input>`, **before** the component's own `Enter` / `Escape` handling — calling `event.preventDefault()` here vetoes it. Never fires while {@link TextSurfaceProps.disabled} is `true`. Low-level native passthrough; fires on `keydown` of the `<textarea>`, **before** the component's own `Escape` handling. See {@link TextFieldSingleLineProps.onKeyDown}.
- `onSubmit`: `((value: string) => void) | undefined`. Fires with the current value when `Enter` is pressed — the SwiftUI `.onSubmit { … }` analogue and the search field's submit affordance. Native form submission is untouched (the component never calls `preventDefault`). Never fires while `disabled` or `readOnly`, nor when a caller's {@link TextFieldSingleLineProps.onKeyDown} prevented the event. Single-line only — multiline `Enter` inserts a newline. Not on the multiline branch — `Enter` inserts a newline in a `<textarea>`, so there is no submit key to hang `onSubmit` on. See {@link TextFieldMultilineProps.secure}.
- `pattern`: `string | undefined`. Native validation pattern (a `RegExp` source string) — single-line only, the SwiftUI surface has no textarea pattern analogue. Not on the multiline branch — `pattern` is single-line-only. See {@link TextFieldMultilineProps.secure}.
- `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`: `((instance: HTMLTextAreaElement | null) => void | (() => VoidOrUndefinedOnly) | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES[keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES]) | RefObject<HTMLTextAreaElement | null> | ((instance: HTMLInputElement | null) => void | (() => VoidOrUndefinedOnly) | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES[keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES]) | RefObject<HTMLInputElement | null> | null | 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`.
- `secure`: `boolean | undefined`; default ``false``. When `true`, the element is `<input type="password">` and entered text renders as platform masked dots. Single-line only — combining with `multiline` is a type error (was silently ignored pre-Phase-3). Not on the multiline branch — `secure` is single-line-only (SwiftUI has no `SecureField(axis: .vertical)`). Typed `never` for the same real- type-error reason as {@link TextFieldSingleLineProps.sizing}.
- `sizing`: `TextFieldSizing | undefined`; default ``"relative"``. Not on the single-line branch — `sizing` is multiline-only. Typed `never` (rather than simply absent) so a caller combining it with a single-line field is a real type error, not a leniency gap in TypeScript's excess- property checking against union types. Multiline height behaviour — `"fixed"` locks to `lineLimit.min` rows, `"relative"` grows `min`→`max` rows then scrolls, `"content"` grows to fit all content. Resolved via `data-sizing` in CSS (multiline-only attribute).
- `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).
- `type`: `TextFieldType | undefined`; default ``"text"``. Native `type` of the `<input>` — single-line only. `"search"` is the search-field setting (the browser's own cancel button is suppressed in CSS so {@link TextFieldChromeProps.clearable} stays the single clear affordance). {@link TextFieldSingleLineProps.secure} wins when both are supplied. Not on the multiline branch — a `<textarea>` has no `type` attribute. Typed `never` for the same real-type-error reason as {@link TextFieldMultilineProps.secure}.
- `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}.
- `variant`: `TextFieldVariant | undefined`; default ``"rounded"``. Visual style of the field (the SwiftUI `.textFieldStyle(…)` family). `"automatic"`/`"plain"` are bare text containers (no glass); `"rounded"` draws the translucent-glass rounded-rect shell; `"prominent"` is a full-pill glassy "main field" for the one or two hero fields per screen.

## SwiftUI mapping

- ``TextField(_ title:text:)` title arg` → ``placeholder`` (direct): The title string is the field's placeholder/prompt. Demo values: `"Placeholder"`, `"Editable"`, `"Multiline"`.
- ``text: Binding<String>`` → ``value` (controlled) / `defaultValue` (uncontrolled seed) + `onChange(next: string)`` (direct): String-bearing controlled triad (ADR-0015), wired through the shared `useControllableState` machine. A `Binding<String>` splits into controlled `value`+`onChange`, or uncontrolled `defaultValue` (read once, on mount). `onChange` fires the next string on every edit, in both modes, never while `disabled` or `readOnly`. Default `defaultValue=""`.
- ``.textFieldStyle(.automatic | .plain | .roundedBorder)` + `prominent` (extension)` → ``variant?: "automatic" | "plain" | "rounded" | "prominent"`` (extension): The SwiftUI style family → one `variant` prop, resolved via `data-variant` in CSS. Default `"rounded"`. `prominent` is a library extension (full-pill hero field) with no exact `.textFieldStyle` source (charter §6 rule 3).
- ``SecureField(_:text:)`` → ``secure?: boolean` (single-line branch only)` (direct): Masked-dot rendering; renders as `<input type="password">`. Same rounded shell as the default variant. Combining with `multiline` is now a type error. Wins over `type` when both are supplied (`secure` is the SwiftUI-sourced prop; `type` is the native escape hatch).
- ``.keyboardType` / `UISearchTextField` / native `<input type>`` → ``type?: "text" | "search" | "email" | "tel" | "url" | "password"` (single-line branch only)` (direct): The native `type` attribute, default `"text"`. `"search"` is what makes a TextField a search field for AT and for platform affordances (the browser's own cancel button is suppressed in CSS so `clearable` is the single clear affordance). Independent of `inputMode`, which only hints the on-screen keyboard. `secure` takes precedence (`secure` → `type="password"` regardless). Not on the multiline branch — a `<textarea>` has no `type`.
- ``Label(_:systemImage:)` inside the field / `.searchable` magnifier` → ``icon?: ReactNode`` (direct): Optional decorative leading node, rendered `aria-hidden` in `.lq-text-field-icon` (the `Label` / `EmptyState` icon convention). Never the accessible name — pass `aria-label` separately. Available on both branches.
- ``.searchable` in-field clear / `UISearchTextField.clearButtonMode`` → ``clearable?: boolean` + `onClear?()` + `clearLabel?: string`` (direct): `clearable` (default `false`) enables the trailing clear button; it renders only while the value is non-empty and the field is neither `disabled` nor `readOnly`. Pressing it commits `""` through the controlled triad (`onChange("")`), then fires `onClear()`, then refocuses the field. `clearLabel` is its accessible name, default `"Clear text"`. Available on both branches.
- ``.onSubmit { … }`` → ``onSubmit?(value: string)` (single-line branch only)` (direct): Fires with the current value when `Enter` is pressed on a single-line field — the search-submit affordance. Native form submission is unaffected (the handler does not `preventDefault`). Never fires while `disabled`; never on the multiline branch, where `Enter` inserts a newline.
- `— (low-level native passthrough)` → ``onKeyDown?(event)`` (direct): Raw `KeyboardEvent` passthrough on the branch's concrete element, called **before** the component's own `Enter` / `Escape` handling; calling `event.preventDefault()` in it suppresses that handling. Never fires while `disabled`.
- `— (no SwiftUI textarea pattern)` → ``pattern?: string` (single-line branch only)` (direct): Native validation `RegExp` source passthrough.
- ``TextField(…, axis: .vertical)`` → ``multiline?: false` (single-line) | `multiline: true` (multiline)` (direct): The union discriminant. Vertical-growing field; `multiline: true` renders as `<textarea>` and narrows `ref` to `Ref<HTMLTextAreaElement>`.
- ``.lineLimit(2...4)`` → ``lineLimit?: { min: number; max: number }` (multiline branch only)` (direct): Row-range bound; demo uses `min: 2, max: 4`. **Sanitised at render**: a non-finite bound falls back to the `{2,4}` default, both bounds floor to an integer and clamp to a minimum of 1, and an inverted range (`min > max`) dev-warns once and clamps `max` up to `min` (audit §6.6 P2 — previously passed straight to `rows` + CSS vars unsanitised).
- ``.lineLimit(_:reservesSpace:)` / CSS `field-sizing`` → ``sizing?: "fixed" | "relative" | "content"` (multiline branch only)` (direct): Multiline height behaviour: `fixed` locks to `lineLimit.min` rows; `relative` grows `min`→`max` then scrolls (default); `content` grows to fit all content. Resolved via `data-sizing` in CSS, stamped **only when `multiline`** (previously stamped, meaninglessly, on every field — audit §6.6 P2). Values mirror `.lineLimit(reservesSpace:)` / CSS `field-sizing`, not the `ControlSize` scale.
- `— (web layout axis, no SwiftUI source)` → ``distribution?: "fill" | "fit"`` (renamed): Root layout: `fill` (default) fills the container width; `fit` hugs its content. Renamed from `display`/`data-display` (charter §5): the fill/hug axis has exactly one name across the catalog, `distribution`, with `NSSegmentedControl.Distribution` pedigree — `display` collided with the CSS property and had none. Resolved via `data-distribution` in CSS.
- ``.disabled(true)`` → ``disabled?: boolean`` (direct): Inherited from `ControlProps`; uses native `disabled` attribute on the inner element (Shape N, charter §3).
- `— (native form passthroughs)` → ``autoComplete?`, `inputMode?`, `readOnly?`, `maxLength?`, `minLength?`` (direct): React-DOM camelCase native passthroughs (audit §6.6 P1.6 — previously absent). `readOnly` hard-suppresses `onChange` in the component itself (charter §3), not only via the native attribute.
- `— (low-level native passthrough)` → ``onFocus?(event)`, `onBlur?(event)`` (direct): "Low-level native passthrough" (charter §1 rule 2 wording), typed with the branch's concrete `FocusEvent<HTMLInputElement | HTMLTextAreaElement>`. Never fire while `disabled`.
- ``name` / `id` binding, form participation` → ``name?`, `id?`, `required?`` (direct): Native form field name / explicit id (falls back to a generated stable id) / native `required` + `aria-required` (A16).
- `— (validation state)` → ``invalid?: boolean` + `aria-invalid?`` (direct): `invalid` sets `aria-invalid="true"`. An explicit `aria-invalid` on the same element always wins — including `"grammar"` / `"spelling"` / explicit `false` — via `ariaInvalid ?? (invalid || undefined)`; `invalid=true` no longer clobbers a caller-supplied richer value (audit §6.6 P2 CONFIRMED).
- `— (surface override)` → ``className?`` (direct): The overridable glass-surface class (ADR-0004/0016).
- ``.tint(color)`` → ``tint?: LiquidTintValue`` (direct): Recolours caret + selection highlight only — not the shell fill/border. The resting field is tint-independent. Consumed (charter: a `.tint(_:)` analogue must be truthful).
- `Accessible name (SwiftUI label / prompt)` → ``aria-label` / `aria-labelledby`` (direct): The demo `LabeledRow` captions are demo chrome; the field's accessible name must be supplied by the caller.
- `— (error association)` → ``aria-describedby?` / `aria-errormessage?`` (direct): Forwarded to the native element for error-message association.
