PlSelect
One value chosen from a list of them. The trigger is a PlTextField's shell wearing a chevron, so a select and a field in the same form are the same object.
import { PlSelect } from 'plass-ui';
<PlSelect
label="City"
placeholder="Pick a city"
items={[
{ value: 'seoul', label: 'Seoul' },
{ value: 'lisbon', label: 'Lisbon' }
]}
/>;import 'package:plass_ui/plass_ui.dart';
PlSelect<String>(
label: const Text('City'),
placeholder: const Text('Pick a city'),
value: city,
onChanged: (String? next) => setState(() => city = next),
options: const <PlSelectOption<String>>[
PlSelectOption<String>(value: 'seoul', label: Text('Seoul')),
PlSelectOption<String>(value: 'lisbon', label: Text('Lisbon')),
],
);The list lifts itself out of the tree, so a select needs an Overlay above it, WidgetsApp with a navigator and MaterialApp both provide one.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'glass' | 'ghost' | 'glass' | The material of the trigger, drawn on PlTextField's shell. solid is the well cut into the sheet |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Height and type scale of the trigger — the same ladder as PlTextField |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | Padding only — never the height, never the type scale |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth of the trigger. The popup has its own, fixed at 3 — it genuinely floats |
| items * | readonly PlSelectOption[] | — | The options, as data — the trigger has to know the labels before the popup has ever been opened |
| value | string | number | null | — | The chosen value. Use with onValueChange for a controlled select |
| defaultValue | string | number | null | — | The initially chosen value |
| onValueChange | (value: string | number | null) => void | — | Called with the new value |
| placeholder | ReactNode | — | Shown in the trigger while nothing is chosen |
| label | ReactNode | — | Label above the trigger, wired to it by Base UI's Field |
| description | ReactNode | — | Helper text below the trigger |
| error | ReactNode | — | Error message below. Its presence also turns the select invalid |
| invalid | boolean | — | Forces the invalid state without a message. Defaults to whether error has content |
| startIcon | ReactNode | — | Content before the value. Sized in em, so it tracks the text |
| fullWidth | boolean | false | Stretches to the width of the container |
| readOnly | boolean | false | The value is shown but cannot be changed |
| disabled | boolean | false | Unavailable. The page shows through the sheet, and it leaves the tab order |
| required | boolean | false | Whether a value must be chosen before the form is submitted |
| name | string | — | Identifies the field when a form is submitted |
| 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 |
|---|---|---|---|
| options * | List<PlSelectOption<T>> | — | The choices, as a list of descriptions rather than children — the trigger has to know the labels before the list has ever been opened |
| value * | T? | — | The chosen value. Use with onValueChange for a controlled select |
| onChanged | ValueChanged<T?>? | — | Called with the new value |
| placeholder | Widget? | — | Shown in the trigger while nothing is chosen |
| variantshared | PlassVariant | PlassVariant.glass | The material of the trigger, drawn on PlTextField's shell. solid is the well cut into the sheet |
| sizeshared | PlassSize | PlassSize.md | Height and type scale of the trigger — the same ladder as PlTextField |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | PlassDensity | PlassDensity.standard | Padding only — never the height, never the type scale |
| elevationshared | int | 0 | Drop shadow depth of the trigger. The list has its own, fixed at the top of the ladder — it genuinely floats |
| label | Widget? | — | Label above the trigger, wired to it by Base UI's Field |
| description | Widget? | — | Helper text below the trigger |
| error | Widget? | — | Error message below. Its presence also turns the select invalid |
| invalid | bool? | — | Forces the invalid state without a message. Defaults to whether error has content |
| startIcon | Widget? | — | Content before the value, drawn at 1.2× it so it tracks the text |
| fullWidth | bool | false | Stretches to the width of the container |
| readOnly | bool | false | The value is shown but cannot be changed, and the list does not open |
| disabled | bool | false | Unavailable. The page shows through the sheet, and it leaves the tab order |
| semanticLabel | String? | — | The name a screen reader gives the select |
| focusNode | FocusNode? | — | Drive focus from outside |
| autofocus | bool | false | Takes focus as it is inserted into the tree |
| hotKeys | PlassHotKeys? | — | Chords the trigger answers to, spelled the way PlHotKeys draws them — { 'Mod+Enter': save, 'Escape': cancel }. A chord that matches is **consumed** — binding a bare Enter takes it from the trigger, which otherwise uses it to open and commit the list |
Every native <div> attribute passes straight through to the field wrapper. color is excluded because it collides with the color in the table above, defaultValue because the select spells it as a value rather than a DOM attribute, and children because the options are items.
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 trigger) description and error.
The select is generic in its value's type (PlSelect<String>, PlSelect<Currency>), so value and onChanged are typed rather than restrained by convention, and it is controlled, like every other input in the package.
That generic is the one place this parts company with the React build's advice. There a value is a string or a number on purpose, because it is what a form submits; here nothing is submitted, so the value can be the thing itself and the type checker can hold you to it.
PlSelectOption
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | string | number | — | Submitted, and what value / onValueChange speak in |
| label | ReactNode | — | Shown in the list and in the trigger. Defaults to the value itself |
| disabled | boolean | false | Unavailable, but still listed — the option exists, it just cannot be picked |
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | T | — | What PlSelect.value holds, and what onChanged reports |
| label | Widget? | — | Shown in the list and in the trigger. The value's own toString if it is left out |
| disabled | bool | false | Unavailable, but still listed — the option exists, it just cannot be picked |
What the shared axes (variant size color density elevation) mean across the library is in prop conventions.
Examples
variant
The same three materials a PlTextField wears, on the same shell. solid is the well (the glass at its most opaque with an inset shadow falling into it) rather than a tinted pane, because a value read off a gradient is a value that has to be read off a gradient.
size
The same height ladder as every other control, which is the point of drawing the trigger on the field's shell: a form where the select is a different height, radius or material from the fields around it is a form that looks assembled rather than designed.
readOnly · disabled · error
error also turns the select invalid, which re-points the whole colour family at danger, the edge, the ring and the message turn over together. invalid does the same without a message, for when an external form library owns the validity.
A readOnly select keeps its value and its focus but will not open. A disabled one leaves the tab order.
An option may be disabled on its own: it stays in the list, because an option that vanishes when it cannot be picked is an option the reader will look for.
Controlled
Pass value with onValueChange. The value is a string or a number, never an object. A select is a form control, and its value is what the form submits. Keep the identifier here and look the object up on the other side.
This is the only mode: value with onChanged. The value is a T (an enum, an id, the object itself), and null is a select with nothing chosen.
startIcon
Drawn at 1.2× the value beside it, so it tracks the text. There is no endIcon: the end of the trigger belongs to the chevron.
Accessibility
- Base UI renders a
role="combobox"trigger and alistboxpopup with realoptionrows, keepsaria-expandedandaria-activedescendantin step, and traps focus while the list is open. label,descriptionanderrorare wired to the trigger by Base UI's Field, so nohtmlForis needed.- The keyboard is the primitive's: ↑ ↓ Home End move, typing jumps by prefix, Enter chooses and Esc closes.
- Rows light on
data-highlightedrather than on:hover, so the pointer and the arrow keys illuminate the same row. - With
name, Base UI renders the hidden input that makes the value part of a native form submission. - The trigger is held open at the width of the longest label it could show, so choosing a shorter option does not shrink the field out from under the pointer that chose it. Those samples are
aria-hiddenand drawn as generated content, so nothing extra is read out or found by find-in-page. - The popup is portalled to the end of
<body>and its positioner carries.plass-portal, which is where a host that scopes a CSS reset can hang the same reset.
- The trigger is announced as a button that says what is chosen and whether the list is open. Each row is announced as one of a mutually exclusive set, taken or not.
- The keys stay on the trigger, and so does focus: ↑ ↓ move the highlight, Home and End go to the ends, Enter takes the highlighted row and Escape closes without taking one. The list is the trigger's list, not a second place to be.
- The highlight is one number rather than a hover state per row, which is what makes the pointer and the arrow keys light the same row.
- A row that cannot be taken stays in the list and is announced as unavailable. An option that vanishes when it cannot be picked is an option the reader will look for.
- The trigger is held open at the width of the longest label it could ever say. Those samples are laid out and not painted, and they are excluded from semantics, so nothing extra is read out.
- Opening the list takes focus to the trigger, because the list's keys are bound there: an open select nothing is focused on is a list the arrow keys cannot reach.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
items | options | The word the rest of the package uses for a list of choices. A radio group's are options too. |
a value of string | number | a generic T | Nothing is submitted here, so the value can be the thing itself and the type checker can hold you to it. |
value / defaultValue / onValueChange | value / onChanged | Flutter's own controls are controlled, and its name for the callback. |
| typing jumps by prefix | — | Typeahead needs the text of every label, and a label here is a widget. Long lists want a field above them rather than a guess. |
| focus moves into the popup | focus stays on the trigger | The list is the trigger's list. Keeping focus where it started is also what makes closing put it back with nothing to restore. |
the hidden input, name, required | — | There is no native form submission to be part of. |
id | — | Nothing points at anything by id here; the label and the messages are part of the component. |
role="combobox", aria-activedescendant | an expanded button, and rows in a mutually exclusive set | Flutter names the state on the node itself; there is no id to point at. |
className, style, native attributes | — | There is no class list and no style attribute to pass through. |