Skip to content

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.

React
tsx
import { PlSelect } from 'plass-ui';

<PlSelect
  label="City"
  placeholder="Pick a city"
  items={[
    { value: 'seoul', label: 'Seoul' },
    { value: 'lisbon', label: 'Lisbon' }
  ]}
/>;
dart
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

PropTypeDefaultDescription
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
elevationshared0 | 1 | 2 | 30Drop 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
valuestring | number | nullThe chosen value. Use with onValueChange for a controlled select
defaultValuestring | number | nullThe initially chosen value
onValueChange(value: string | number | null) => voidCalled with the new value
placeholderReactNodeShown in the trigger while nothing is chosen
labelReactNodeLabel above the trigger, wired to it by Base UI's Field
descriptionReactNodeHelper text below the trigger
errorReactNodeError message below. Its presence also turns the select invalid
invalidbooleanForces the invalid state without a message. Defaults to whether error has content
startIconReactNodeContent before the value. Sized in em, so it tracks the text
fullWidthbooleanfalseStretches to the width of the container
readOnlybooleanfalseThe value is shown but cannot be changed
disabledbooleanfalseUnavailable. The page shows through the sheet, and it leaves the tab order
requiredbooleanfalseWhether a value must be chosen before the form is submitted
namestringIdentifies the field when a form is submitted
hotKeysRecord<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
PropTypeDefaultDescription
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
onChangedValueChanged<T?>?Called with the new value
placeholderWidget?Shown in the trigger while nothing is chosen
variantsharedPlassVariantPlassVariant.glassThe material of the trigger, drawn on PlTextField's shell. solid is the well cut into the sheet
sizesharedPlassSizePlassSize.mdHeight and type scale of the trigger — the same ladder as PlTextField
colorsharedPlassColorPlassColor.primarySemantic colour role. Arbitrary colour values are not accepted
densitysharedPlassDensityPlassDensity.standardPadding only — never the height, never the type scale
elevationsharedint0Drop shadow depth of the trigger. The list has its own, fixed at the top of the ladder — it genuinely floats
labelWidget?Label above the trigger, wired to it by Base UI's Field
descriptionWidget?Helper text below the trigger
errorWidget?Error message below. Its presence also turns the select invalid
invalidbool?Forces the invalid state without a message. Defaults to whether error has content
startIconWidget?Content before the value, drawn at 1.2× it so it tracks the text
fullWidthboolfalseStretches to the width of the container
readOnlyboolfalseThe value is shown but cannot be changed, and the list does not open
disabledboolfalseUnavailable. The page shows through the sheet, and it leaves the tab order
semanticLabelString?The name a screen reader gives the select
focusNodeFocusNode?Drive focus from outside
autofocusboolfalseTakes focus as it is inserted into the tree
hotKeysPlassHotKeys?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

PropTypeDefaultDescription
value * string | numberSubmitted, and what value / onValueChange speak in
labelReactNodeShown in the list and in the trigger. Defaults to the value itself
disabledbooleanfalseUnavailable, but still listed — the option exists, it just cannot be picked
PropTypeDefaultDescription
value * TWhat PlSelect.value holds, and what onChanged reports
labelWidget?Shown in the list and in the trigger. The value's own toString if it is left out
disabledboolfalseUnavailable, 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.

React

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.

React

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.

React

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.

React

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.

React

Accessibility

  • Base UI renders a role="combobox" trigger and a listbox popup with real option rows, keeps aria-expanded and aria-activedescendant in step, and traps focus while the list is open.
  • label, description and error are wired to the trigger by Base UI's Field, so no htmlFor is needed.
  • The keyboard is the primitive's: Home End move, typing jumps by prefix, Enter chooses and Esc closes.
  • Rows light on data-highlighted rather 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-hidden and 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

ReactFlutterWhy
itemsoptionsThe word the rest of the package uses for a list of choices. A radio group's are options too.
a value of string | numbera generic TNothing is submitted here, so the value can be the thing itself and the type checker can hold you to it.
value / defaultValue / onValueChangevalue / onChangedFlutter's own controls are controlled, and its name for the callback.
typing jumps by prefixTypeahead 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 popupfocus stays on the triggerThe 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, requiredThere is no native form submission to be part of.
idNothing points at anything by id here; the label and the messages are part of the component.
role="combobox", aria-activedescendantan expanded button, and rows in a mutually exclusive setFlutter names the state on the node itself; there is no id to point at.
className, style, native attributesThere is no class list and no style attribute to pass through.

Released under the MIT License