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.
import { PlCombobox } from 'plass-ui';
<PlCombobox
label="Framework"
placeholder="Search…"
items={[
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' }
]}
/>;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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop 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 |
| multiple | boolean | false | Whether more than one value may be held. The chosen ones become chips inside the field, and the input goes on filtering after each |
| value | string | number | (string | number)[] | null | — | The chosen value. Use with onValueChange for a controlled combobox; an array when multiple |
| defaultValue | string | number | (string | number)[] | null | — | The initially chosen value |
| onValueChange | (value: string | number | (string | number)[] | null) => void | — | Called with the new value |
| onInputValueChange | (inputValue: string) => void | — | Called as the text in the input changes — the filter query, not the value |
| allowCustom | boolean | true | Whether 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) => ReactNode | Add “{query}” | What that row says |
| clearable | boolean | false | Shows 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 |
| emptyMessage | ReactNode | 'No matches' | Shown in the popup when nothing matched and no value may be added |
| limit | number | -1 | The most rows the list will show at once. -1 is all of them |
| placeholder | string | — | Shown in the input while nothing is typed |
| label | ReactNode | — | Label above the field, wired to it by Base UI's Field |
| description | ReactNode | — | Helper text below the field |
| error | ReactNode | — | Error message below. Its presence also turns the combobox invalid |
| invalid | boolean | — | Forces the invalid state without a message |
| startIcon | ReactNode | — | Content before the input. Sized in em, so it tracks the text |
| fullWidth | boolean | false | Stretches to the width of the container |
| disabled | boolean | false | Unavailable |
| readOnly | boolean | false | The value is shown but cannot be changed |
| required | boolean | false | Whether a value must be chosen before the form is submitted |
| name | string | — | Identifies the field when a form is submitted |
| open | boolean | — | The popup is open. Use with onOpenChange for a controlled popup |
| defaultOpen | boolean | — | Whether the popup starts open |
| onOpenChange | (open: boolean) => void | — | Called when the popup opens or closes |
| openLabel | string | 'Open' | Accessible name of the button that opens the list |
| clearLabel | string | 'Clear' | Accessible name of the clear button |
| removeLabel | (label: string) => string | Remove {label} | Accessible name of a chip's remove button. Receives the chip's label |
| inputRef | Ref<HTMLInputElement> | — | A ref to the text input the user types into |
| 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<PlComboboxOption<T>> | — | The choices, in order. The filter reads their labels |
| value * | T? | — | The chosen value. Single form only |
| onChanged | ValueChanged<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 |
| onCreate | T 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 |
| customLabel | Widget Function(String query)? | — | What that row says |
| onQueryChanged | ValueChanged<String>? | — | Called as the text in the input changes — the filter query, not the value |
| placeholder | String? | — | Shown in the input while nothing is typed |
| emptyMessage | String | 'No matches' | Shown in the popup when nothing matched and no value may be added |
| limit | int? | null | The most rows the list will show at once. -1 is all of them |
| clearable | bool | false | Shows 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 |
| clearLabel | String | 'Clear' | Accessible name of the clear button |
| openLabel | String | 'Open' | Accessible name of the button that opens the list |
| removeLabel | String Function(String label) | Remove {label} | Accessible name of a chip's remove button. Receives the chip's label |
| variantshared | PlassVariant | PlassVariant.glass | The material of the field, drawn on PlTextField's shell. solid is the well cut into the sheet |
| sizeshared | PlassSize | PlassSize.md | Height and type scale |
| 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 field. The popup has its own, fixed at 3 — it genuinely floats |
| label | Widget? | — | Label above the field, wired to it by Base UI's Field |
| description | Widget? | — | Helper text below the field |
| error | Widget? | — | Error message below. Its presence also turns the combobox invalid |
| invalid | bool? | — | Forces the invalid state without a message |
| startIcon | Widget? | — | Content before the input. Sized in em, 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 |
| disabled | bool | false | Unavailable |
| semanticLabel | String? | — | The name a screen reader gives a field with no visible label |
| focusNode | FocusNode? | — | Drive focus from outside |
| autofocus | bool | false | Takes focus as it is inserted into the tree |
| 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 <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
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | string | number | — | Submitted, and what value / onValueChange speak in |
| label | string | — | Shown 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 |
| disabled | boolean | false | Unavailable, but still listed |
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | T | — | Submitted, and what value / onValueChange speak in |
| label * | String | — | Shown 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 |
| disabled | bool | false | Unavailable, 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.
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.
size
The same height ladder as every other control. With multiple the number is a minimum rather than a height, for the reason above.
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.
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/listboxpair, keepsaria-expandedandaria-activedescendantin step, and owns the filtering and its collator. label,descriptionanderrorare wired to the input by Base UI's Field, so nohtmlForis 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-highlightedrather than on:hover, so the pointer and the arrow keys illuminate the same row. - Each chip's × is named after its chip (
Remove Seoul, notRemove), 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
| React | Flutter | Why |
|---|---|---|
items | options | The word the rest of the package uses for a list of choices. |
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. |
multiple as a prop | PlCombobox.multiple, a second constructor | One 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 collator | a Widget, filtering by a case-folded contains | The label is still a String, for the same reason: the filter reads it and it is written into a field. |
the hidden input, name, required | — | There is no native form submission to be part of. |
className, style, native attributes | — | There is no class list and no style attribute to pass through. |