PlTextField
Single- or multi-line text input. The label, the helper text and the error message are part of the component rather than three elements you wire together yourself.
import { PlTextField } from 'plass-ui';
<PlTextField label="Email" type="email" description="We never share it." />;import 'package:plass_ui/plass_ui.dart';
PlTextField(
controller: email,
label: const Text('Email'),
keyboardType: TextInputType.emailAddress,
description: const Text('We never share it.'),
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'glass' | 'ghost' | 'glass' | What the surface is made of. On a field solid is not a tinted pane but a well cut into the sheet — a field holds user data |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Height and type scale. The same heights as PlButton, so a row's baseline holds |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. The glass is never dyed, so it reaches the edge, the focus ring and the caret |
| densityshared | 'default' | 'compact' | 'default' | Padding only — never the height, never the type scale |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. A field is a well rather than a surface that floats, so the default is 0 |
| label | ReactNode | — | Label above the control, wired to it by Base UI's Field |
| description | ReactNode | — | Helper text below the control |
| error | ReactNode | — | Error message below the control. Its presence also turns the field invalid |
| invalid | boolean | !!error | Forces the invalid state without a message, for when a form library owns validity |
| multiline | boolean | false | Renders a textarea instead of an input. Every other axis stays identical |
| rows | number | 3 | Visible rows in multiline mode |
| resize | 'none' | 'vertical' | 'horizontal' | 'both' | 'vertical' | Which way the user may drag a multiline control. Horizontal resizing breaks a form's column, so only the vertical axis is on |
| startIcon | ReactNode | — | Content before the control. Sized in em, so it tracks the text |
| endIcon | ReactNode | — | Content after the control |
| loading | boolean | false | Spinner in place of endIcon, and the field is marked busy. Typing is still allowed |
| readOnly | boolean | false | The value can be read and copied but not rewritten. Goes flat and loses most of its saturation |
| disabled | boolean | false | Unavailable. The page shows through the sheet, and it leaves the tab order |
| fullWidth | boolean | false | Stretches to the width of the container |
| hotKeys | Record<string, () => void> | — | Chords this control answers to, spelled the way PlHotKeys draws them — { 'Mod+Enter': save, Escape: cancel }. A chord that matches is **consumed** |
| classNames | { label?, control?, description?, error?: string } | — | Classes on the parts a className does not reach. control is the part a reader acts on |
| Prop | Type | Default | Description |
|---|---|---|---|
| controller | TextEditingController? | — | The text being edited — where Flutter keeps text. Left out, the field owns one of its own |
| onChanged | ValueChanged<String>? | — | Called on every change |
| onSubmitted | ValueChanged<String>? | — | Called when the field is submitted from the keyboard |
| variantshared | PlassVariant | PlassVariant.solid | What the surface is made of. On a field solid is not a tinted pane but a well cut into the sheet — a field holds user data |
| sizeshared | PlassSize | PlassSize.md | Height and type scale. The same heights as PlButton, so a row's baseline holds |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. The glass is never dyed, so it reaches the edge, the focus ring and the caret |
| densityshared | PlassDensity | PlassDensity.standard | Padding only — never the height, never the type scale |
| elevationshared | int | 0 | Drop shadow depth. A field is a well rather than a surface that floats, so the default is 0 |
| multiline | bool | false | Renders a textarea instead of an input. Every other axis stays identical |
| rows | int | 3 | Visible rows in multiline mode |
| label | Widget? | — | Label above the control, wired to it by Base UI's Field |
| description | Widget? | — | Helper text below the control |
| error | Widget? | — | Error message below the control. Its presence also turns the field invalid |
| invalid | bool? | !!error | Forces the invalid state without a message, for when a form library owns validity |
| placeholder | String? | — | What is shown while the field is empty. A native attribute in React, so it has no row there |
| startIcon | Widget? | — | Content before the control. Sized in em, so it tracks the text |
| endIcon | Widget? | — | Content after the control |
| loading | bool | false | Spinner in place of endIcon, and the field is marked busy. Typing is still allowed |
| fullWidth | bool | false | Stretches to the width of the container |
| readOnly | bool | false | The value can be read and copied but not rewritten. Goes flat and loses most of its saturation |
| disabled | bool | false | Unavailable. The page shows through the sheet, and it leaves the tab order |
| obscureText | bool | false | Hides what is typed, for a password |
| keyboardType | TextInputType? | — | Which keyboard to raise on a touch device |
| maxLength | int? | — | How many characters the field will take. A formatter rather than a counter: nothing is drawn under the field |
| semanticLabel | String? | — | The name a screen reader announces, for a field with no visible label. A placeholder is not a name |
| hotKeys | PlassHotKeys? | — | Chords this control answers to, spelled the way PlHotKeys draws them — { 'Mod+Enter': save, Escape: cancel }. A chord that matches is **consumed** |
Every native <input> attribute passes straight through, and in multiline mode every <textarea> attribute does. The exceptions are color and size, which are the shared axes above.
A className lands on the stack that holds the label, the control and the two lines under it. classNames reaches the four parts inside it: label, control (the box the text goes in) description and error.
The value lives in a TextEditingController, which is where Flutter keeps text. Left out, the field owns one, but a field whose value the app needs is a field the app should hand a controller to.
Under it is EditableText rather than a TextField: the latter is Material, and this package imports neither Material nor Cupertino. What Material adds on top (the decoration, the counter, the ripple) is what this component is instead of.
What the shared axes mean across the library is in prop conventions.
Examples
variant
glass is the default: a sheet with a hairline round it, drawn in --plass-border rather than the sheet's own white edge. A field usually sits on a card, and a white line round a near-white box on a white card has no visible shape. solid is the well, the glass at its most opaque with the library's one inset shadow in it, for a field that should read as recessed. ghost has no surface until the pointer is on it, for a field in a table cell.
A solid field is deliberately not a tinted pane. A gradient under a caret, a text selection and a placeholder is not legible, so the family shows up in the hairline, the ring and the caret instead.
size
The same ladder PlButton uses, xs 24px · sm 32px · md 40px · lg 48px · xl 56px, so a field and a button of the same size line up on one row.
label, description and error
All three are nodes, and all three are wired to the control by Base UI's Field: the label points at it, and both messages end up in its aria-describedby.
All three are widgets, and all three are part of the field's own semantics node, so a screen reader reads the label, the field and the message as one thing rather than as three.
There is no floating-label variant. A floating label needs a transform on the thing being typed into, and a label that moves under the caret is the one effect this library rules out on a control.
Validation
error carries a message and turns the field invalid, which re-points the whole slot family at danger, the hairline, the focus ring, the caret and the message all turn over together.
Two escape hatches, for when a form library owns the validity: invalid forces the state without a message, and invalid={false}invalid: false shows a message without it.
multiline
Every other axis is identical, and a one-row multiline field is exactly as tall as the single-line field of the same size. The vertical padding is derived from the height ladder, so density never touches it.
Renders a <textarea>. resize decides which way the user may drag it; horizontal resizing breaks a form's column, so only the vertical axis is on by default.
There is no resize. A textarea's drag handle is the browser's, and Flutter has no equivalent to offer, a field that has to change size is one the layout around it resizes.
startIcon and endIcon
Sized against the text rather than against the row. They sit on the shell rather than inside the control, and they answer its focus. An adornment goes from muted to the accent colour when the field is focused.
An adornment is centred on the control's first line, so it stays where it is when a multiline field grows.
loading · readOnly · disabled
| prop | Appearance | Typing | Focus |
|---|---|---|---|
loading | A spinner takes the endIcon slot | Allowed | Kept |
readOnly | Keeps its colour, goes flat, drains saturation | Blocked, but selectable | Kept |
disabled | The page shows through the sheet | Blocked | Lost |
loading deliberately still allows typing: a field is usually loading because of what was typed into it.
hotKeys
A field that saves on Mod+Enter and clears on Escape is a form of keyboard affordance that has nowhere else to live. hotKeys is a map from a chord to what pressing it does, written in the same vocabulary PlHotKeys draws, so the cap printed beside the field and the key that actually works come from one string, and cannot drift apart.
Mod resolves per platform: one entry is ⌘ on a Mac and Ctrl everywhere else. Esc, Return, Cmd and Option fold onto the same keys their caps do.
A chord that matches is consumed. The handler runs and the key goes no further, so Escape bound here does not also close the dialog around the field, and Enter does not also submit the form. That is what binding a key means, and it is why these are chords rather than letters: { a: … } is a field that cannot type an a.
Controlled
value and onChange behave exactly as they do on a native input; onChange is typed to accept either element so the same handler works in multiline mode.
The controller is the value, and onChanged is told about every change. maxLength is a formatter rather than a counter: it stops the twenty-fifth character from arriving, and nothing is drawn under the field unless you draw it.
Accessibility
- Renders a native
<input>, or a<textarea>undermultiline. Both take every attribute their element takes. labelis a real<label>pointing at the control. Without one, give the field anaria-labelor aplaceholderthat is not the only name it has.descriptionanderrorboth land inaria-describedby, so a screen reader reads the message with the field rather than after it.errorandinvalidsetaria-invalid.- The focus ring is drawn on the shell rather than on the control, so it traces the glass edge instead of a rectangle floating inside it. It appears on
:focus-visibleonly. - Clicking the shell's padding puts the caret in the field, the way clicking inside a native input does.
- Announced as a text field, and as read-only or unavailable when it is.
- The label, the field, the description and the message are one semantics node, so a screen reader reads them together rather than one after another. Without a visible label, give the field a
semanticLabel. Aplaceholderis not a name. - The focus ring is drawn on the shell rather than on the editor, so it traces the glass edge instead of a rectangle floating inside it. It appears only on what CSS calls
:focus-visible. - Pressing the shell's padding puts the caret in the field, the way pressing inside a native input does.
- A selection is made by dragging and has no handles to adjust afterwards: the drag handles a touch platform puts under one belong to Material and Cupertino, and this package imports neither.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
value / onChange | controller / onChanged | A TextEditingController is where Flutter keeps text, and it is what a caller already has. |
type="email" | keyboardType | Flutter's way of saying which keyboard to raise. |
resize | — | A textarea's drag handle is the browser's, and there is no equivalent to offer. |
a <textarea> under multiline | the same widget, taller | There is one editor either way, so switching to multiline genuinely changes nothing but the height. |
aria-describedby wiring | one merged semantics node | The same result by a different route. |
| selection handles on touch | — | They belong to Material and Cupertino, which this package does not import. |
className, style, native attributes | — | There is no class list and no style attribute to pass through. |