Skip to content

PlCombobox

A field you can type into and also choose from. The text filters the list, and (unless you say otherwise) it can become the value itself.

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

<PlCombobox
  label="Framework"
  placeholder="Search…"
  items={[
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' }
  ]}
/>;
dart
import 'package:plass_ui/plass_ui.dart';

PlCombobox<String>(
  label: const Text('Framework'),
  placeholder: 'Search…',
  value: framework,
  onChanged: (String? next) => setState(() => framework = next),
  options: const <PlComboboxOption<String>>[
    PlComboboxOption<String>(value: 'react', label: 'React'),
    PlComboboxOption<String>(value: 'vue', label: 'Vue'),
  ],
);

The list lifts itself out of the tree, so a combobox 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 field, drawn on PlTextField's shell. solid is the well cut into the sheet
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Height and type scale
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 field. The popup has its own, fixed at 3 — it genuinely floats
items * readonly PlComboboxOption[]The options, as data — the same shape PlSelect takes, because what a caller has is almost always an array already
multiplebooleanfalseWhether more than one value may be held. The chosen ones become chips inside the field, and the input goes on filtering after each
valuestring | number | (string | number)[] | nullThe chosen value. Use with onValueChange for a controlled combobox; an array when multiple
defaultValuestring | number | (string | number)[] | nullThe initially chosen value
onValueChange(value: string | number | (string | number)[] | null) => voidCalled with the new value
onInputValueChange(inputValue: string) => voidCalled as the text in the input changes — the filter query, not the value
allowCustombooleantrueWhether a value the list does not contain may be committed. The typed text is offered as its own row, so committing it is a choice rather than something that happens on blur
customLabel(query: string) => ReactNodeAdd “{query}”What that row says
clearablebooleanfalseShows a × that empties the field. Off by default: a field that can be cleared in one click is a field that can be emptied by accident
emptyMessageReactNode'No matches'Shown in the popup when nothing matched and no value may be added
limitnumber-1The most rows the list will show at once. -1 is all of them
placeholderstringShown in the input while nothing is typed
labelReactNodeLabel above the field, wired to it by Base UI's Field
descriptionReactNodeHelper text below the field
errorReactNodeError message below. Its presence also turns the combobox invalid
invalidbooleanForces the invalid state without a message
startIconReactNodeContent before the input. Sized in em, so it tracks the text
fullWidthbooleanfalseStretches to the width of the container
disabledbooleanfalseUnavailable
readOnlybooleanfalseThe value is shown but cannot be changed
requiredbooleanfalseWhether a value must be chosen before the form is submitted
namestringIdentifies the field when a form is submitted
openbooleanThe popup is open. Use with onOpenChange for a controlled popup
defaultOpenbooleanWhether the popup starts open
onOpenChange(open: boolean) => voidCalled when the popup opens or closes
openLabelstring'Open'Accessible name of the button that opens the list
clearLabelstring'Clear'Accessible name of the clear button
removeLabel(label: string) => stringRemove {label}Accessible name of a chip's remove button. Receives the chip's label
inputRefRef<HTMLInputElement>A ref to the text input the user types into
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<PlComboboxOption<T>>The choices, in order. The filter reads their labels
value * T?The chosen value. Single form only
onChangedValueChanged<T?>?Called with the value that was chosen. On the multiple form it is a ValueChanged<List<T>>
values * List<T>The chosen values. PlCombobox.multiple only
onCreateT Function(String query)?Turns what was typed into a value, and **passing it is what allows one**. React can default this to on because a value there is always a string or a number; here it is a T, and only the caller knows how to make one
customLabelWidget Function(String query)?What that row says
onQueryChangedValueChanged<String>?Called as the text in the input changes — the filter query, not the value
placeholderString?Shown in the input while nothing is typed
emptyMessageString'No matches'Shown in the popup when nothing matched and no value may be added
limitint?nullThe most rows the list will show at once. -1 is all of them
clearableboolfalseShows a × that empties the field. Off by default: a field that can be cleared in one click is a field that can be emptied by accident
clearLabelString'Clear'Accessible name of the clear button
openLabelString'Open'Accessible name of the button that opens the list
removeLabelString Function(String label)Remove {label}Accessible name of a chip's remove button. Receives the chip's label
variantsharedPlassVariantPlassVariant.glassThe material of the field, drawn on PlTextField's shell. solid is the well cut into the sheet
sizesharedPlassSizePlassSize.mdHeight and type scale
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 field. The popup has its own, fixed at 3 — it genuinely floats
labelWidget?Label above the field, wired to it by Base UI's Field
descriptionWidget?Helper text below the field
errorWidget?Error message below. Its presence also turns the combobox invalid
invalidbool?Forces the invalid state without a message
startIconWidget?Content before the input. Sized in em, so it tracks the text
fullWidthboolfalseStretches to the width of the container
readOnlyboolfalseThe value is shown but cannot be changed
disabledboolfalseUnavailable
semanticLabelString?The name a screen reader gives a field with no visible label
focusNodeFocusNode?Drive focus from outside
autofocusboolfalseTakes focus as it is inserted into the tree
hotKeysPlassHotKeys?Chords this control answers to, spelled the way PlHotKeys draws them — { 'Mod+Enter': save, Escape: cancel }. A chord that matches is **consumed**

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 combobox 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 field's shell, chips and all, description and error.

The combobox is generic in its value's type (PlCombobox<String>, PlCombobox<Tag>), and it is controlled, like every other input in the package. Holding a set is a second constructor, PlCombobox.multiple, which takes values and reports a List<T>: one widget with a multiple flag would have to hold both shapes of value and neither would be typed.

onCreate is what React spells as allowCustom, and it is a callback rather than a flag for a reason React does not have: there a value is always a string or a number, so the field can build one out of the query on its own. Here it is a T, and only the caller knows how to make one, so the permission and the recipe are the same parameter. For a PlCombobox<String> that is (String query) => query.

PlComboboxOption

PropTypeDefaultDescription
value * string | numberSubmitted, and what value / onValueChange speak in
labelstringShown in the list, in the input and on the chip. Defaults to the value. A string rather than a ReactNode, because the filter types against it and it is written into a text input
disabledbooleanfalseUnavailable, but still listed
PropTypeDefaultDescription
value * TSubmitted, and what value / onValueChange speak in
label * StringShown in the list, in the input and on the chip. Defaults to the value. A string rather than a ReactNode, because the filter types against it and it is written into a text input
disabledboolfalseUnavailable, but still listed

What the shared axes (variant size color density elevation) mean across the library is in prop conventions.

Built on PlTextField

To the pixel, and so is PlSelect's trigger. The three have to be indistinguishable in a form or the form looks assembled rather than designed, which is why the shell lives in internal/styles and not in any one of them.

What is different is what the text does. On a select the text is the value; here it filters the list, and it can become the value.

Examples

Choosing and typing

A PlSelect is for a closed set you pick from. This is for a set you search, and (with allowCustom on, which is the default) one you can add to.

The typed text is offered as its own row at the end of the list, so committing it is a choice the user makes rather than something that happens to them on blur. Turn allowCustom off for a field whose values really are a closed set; you then have a searchable select.

React

multiple

The chosen values become PlChips inside the field and the input goes on filtering after each one, so a set of tags is built without the field ever closing.

The field then has no fixed height, the chips wrap, so its padding is (control height − chip height) / 2 instead, which makes a one-row combobox exactly as tall as the field beside it.

React

size

The same height ladder as every other control. With multiple the number is a minimum rather than a height, for the reason above.

React

readOnly · disabled · error

error also turns the combobox invalid, which re-points the whole colour family at danger, the edge, the ring, the caret and the message turn over together. invalid does the same without a message.

A readOnly combobox keeps its value and its focus but cannot be typed into, and its chips lose their ×. 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, an array of them with multiple, and never an object: a combobox 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.

Accessibility

  • Base UI renders the combobox/listbox pair, keeps aria-expanded and aria-activedescendant in step, and owns the filtering and its collator.
  • label, description and error are wired to the input by Base UI's Field, so no htmlFor is needed.
  • The keyboard is the primitive's: move through the list, Enter takes the highlighted row and Esc closes. With multiple, walk the chips and Backspace removes one.
  • The first match lights up as you type, so Enter commits without an arrow key first. That is also what makes the "add this" row reachable from the keyboard at all: a value the list does not have is the only match there is.
  • The "add this" row is a real option, not a special case in the key handling, so a click, Enter and the arrow keys all reach it the way every other row is reached.
  • Rows light on data-highlighted rather than on :hover, so the pointer and the arrow keys illuminate the same row.
  • Each chip's × is named after its chip (Remove Seoul, not Remove), because a screen reader reading a row of six identical buttons has told the reader nothing.
  • With name, Base UI renders the hidden input that makes the value part of a native form submission.
  • 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 field is announced as a text field that says whether its list is open. Each row is announced as one of a mutually exclusive set, taken or not.
  • The keys stay on the field, and so does focus: move the highlight, Enter takes the highlighted row and Escape closes without taking one. The list is the field's list, not a second place to be.
  • The first match lights up as the query changes, so Enter commits without an arrow key first, which is also what makes the create row reachable from the keyboard at all.
  • 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.
  • Each chip's × is named after its chip.
  • Nothing is committed when focus leaves: the query goes back to being the value, and a value the list does not have is only ever taken by taking its row.

Differences from the React build

ReactFlutterWhy
itemsoptionsThe word the rest of the package uses for a list of choices.
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.
multiple as a propPlCombobox.multiple, a second constructorOne widget with a flag would have to hold both shapes of value, and neither would be typed.
allowCustom (a boolean, on by default)onCreate (a T Function(String))A T cannot be built out of a query by the field. The permission and the recipe are the same parameter.
label of ReactNode, filtering by Base UI's collatora Widget, filtering by a case-folded containsThe label is still a String, for the same reason: the filter reads it and it is written into a field.
the hidden input, name, requiredThere is no native form submission to be part of.
className, style, native attributesThere is no class list and no style attribute to pass through.

Released under the MIT License