PlCommandPalette
Everything an application can do, behind one field. The shape a keyboard-first product takes once it has more actions than a menu bar can hold: a reader types what they want instead of remembering where it was put.
import { PlCommandPalette } from 'plass-ui';
<PlCommandPalette
items={[{ value: 'new', label: 'New document', group: 'File', shortcut: 'Mod+N' }]}
onSelect={(item) => run(item.value)}
/>;import 'package:plass_ui/plass_ui.dart';
PlCommandPalette(
open: open,
onOpenChanged: (bool next) => setState(() => open = next),
onSelect: (PlCommandItem item) => run(item.value),
items: const <PlCommandItem>[
PlCommandItem(value: 'new', label: 'New document', group: 'File', shortcut: 'Mod+N'),
],
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items * | readonly PlCommandItem[] | — | Everything the palette can do |
| open | boolean | — | Whether the palette is open. Use with onOpenChange for a controlled one |
| defaultOpen | boolean | false | Whether it starts open, for an uncontrolled one |
| onOpenChange | (open: boolean) => void | — | Called when it opens or closes |
| onSelect | (item: PlCommandItem) => void | — | Called when a command is run, after its own onSelect. The palette closes either way |
| shortcut | string | false | 'Mod+K' | The keystroke that opens the palette, bound on the window. Written the way PlHotKeys writes them, so Mod is Command on a Mac and Control everywhere else. false binds nothing |
| width | number | string | — | How wide the sheet may get. A number of pixels or any CSS length |
| maxHeight | number | string | 320 | How tall the list may get before it scrolls |
| placeholder | string | 'Search commands' | The placeholder in the field |
| emptyMessage | ReactNode | 'No commands found' | The line where the rows would be, when nothing matched |
| label | string | 'Command palette' | The accessible name of the dialog, which has no visible title |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The sheet's width, the field's height and the rows' type scale |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. It reaches the highlight, the caret and the focus rings; the sheet is never dyed |
| densityshared | 'default' | 'compact' | 'default' | The height of a row, and nothing else |
| className | string | — | Classes on the sheet, alongside the component's own rather than in place of them |
| style | CSSProperties | — | Inline styles on the sheet, applied over the custom properties it wrote |
| classNames | { backdrop?: string } | — | Classes on the parts a className does not reach. backdrop is the scrim drawn behind the surface |
| Prop | Type | Default | Description |
|---|---|---|---|
| items * | List<PlCommandItem> | — | Everything the palette can do |
| open * | bool | — | Whether the palette is open. There is no uncontrolled mode: the thing that opens a palette is a key bound on the whole app, and an app that binds one already holds the state |
| onOpenChanged | ValueChanged<bool>? | — | Called when it opens or closes |
| onSelect | ValueChanged<PlCommandItem>? | — | Called when a command is run, after its own onSelect. The palette closes either way |
| shortcut | String? | 'Mod+K' | The keystroke that opens the palette, bound on the keyboard. Written the way PlHotKeys writes them, so Mod is Command on a Mac and Control everywhere else. null binds nothing |
| width | double? | — | How wide the sheet may get. A number of pixels or any CSS length |
| maxHeight | double | 320 | How tall the list may get before it scrolls |
| placeholder | String | 'Search commands' | The placeholder in the field |
| emptyMessage | String | 'No commands found' | The line where the rows would be, when nothing matched |
| label | String | 'Command palette' | The accessible name of the dialog, which has no visible title |
| sizeshared | PlassSize | PlassSize.md | The sheet's width, the field's height and the rows' type scale |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. It reaches the highlight, the caret and the focus rings; the sheet is never dyed |
| densityshared | PlassDensity | PlassDensity.standard | The height of a row, and nothing else |
No native attribute passes through: the palette renders a portalled dialog rather than an element in your tree, so there is nothing for a stray id or onClick to land on. className and style are the two that reach it, and both land on the sheet. The scrim behind it is what classNames.backdrop reaches.
PlCommandItem
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | string | — | What identifies the command |
| label * | string | — | What the row says, and what the query is matched against |
| description | ReactNode | — | A second line under it — where the command goes, or what it changes |
| icon | ReactNode | — | A glyph before the label |
| shortcut | string | — | The keystroke that does the same thing, set at the end of the row. The palette does not bind it — the application does |
| group | string | — | The heading this command sits under. Commands are drawn in the order they are given and a heading is drawn each time the group changes, so a group's commands have to be listed together |
| keywords | readonly string[] | — | Extra words the query is matched against but that are never drawn — the name somebody else's product gives the same command, an abbreviation, the word a reader would have searched for |
| disabled | boolean | false | In the list but not runnable |
| onSelect | () => void | — | What running it does |
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | String | — | What identifies the command |
| label * | String | — | What the row says, and what the query is matched against |
| description | String? | — | A second line under it — where the command goes, or what it changes |
| icon | Widget? | — | A glyph before the label |
| shortcut | String? | — | The keystroke that does the same thing, set at the end of the row. The palette does not bind it — the application does |
| group | String? | — | The heading this command sits under. Commands are drawn in the order they are given and a heading is drawn each time the group changes, so a group's commands have to be listed together |
| keywords | List<String> | const [] | Extra words the query is matched against but that are never drawn — the name somebody else's product gives the same command, an abbreviation, the word a reader would have searched for |
| disabled | bool | false | In the list but not runnable |
| onSelect | VoidCallback? | — | What running it does |
What the shared axes mean across the library is in prop conventions.
Command palette or menu
- It is not a
PlMenu. A menu is a short list in one place, and every row is visible before you go looking for it. - It is not a
PlComboboxeither. What comes back is not a value. It is something happening.
Reach for it when the answer to "where is that command?" has become "I do not remember".
Examples
Groups, descriptions and keywords
Commands are drawn in the order they are given, and a heading appears each time group changes, so a group's commands have to be listed together. That is the whole layout rule, and it means the order on screen is the order in the array rather than something the component sorted behind your back.
keywords are matched and never drawn: the name somebody else's product gives the same command, an abbreviation, the word a reader would have searched for.
The filter folds case and combining marks, so cafe finds Café. Each command's searchable text is folded once per list rather than once per comparison. A normalize on every command for every character typed is exactly the cost that makes a palette feel slow.
import { useState } from 'react';
import { PlButton, PlCommandPalette, type PlCommandItem } from 'plass-ui';
const commands: PlCommandItem[] = [
{ value: 'new', label: 'New document', group: 'File' },
{ value: 'open', label: 'Open…', group: 'File', keywords: ['load', 'import'] },
{ value: 'copy', label: 'Copy', group: 'Edit', description: 'Put it on the clipboard' },
{ value: 'paste', label: 'Paste', group: 'Edit', disabled: true },
{ value: 'zen', label: 'Zen mode', group: 'View', keywords: ['focus', 'distraction free'] }
];
export default function CommandPaletteGroups() {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col items-center gap-2">
<PlButton variant="glass" color="secondary" onClick={() => setOpen(true)}>
Try “load”, or “distraction”
</PlButton>
<PlCommandPalette items={commands} open={open} onOpenChange={setOpen} shortcut={false} />
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class CommandPaletteGroups extends StatefulWidget {
const CommandPaletteGroups({super.key});
@override
State<CommandPaletteGroups> createState() => _CommandPaletteGroupsState();
}
class _CommandPaletteGroupsState extends State<CommandPaletteGroups> {
bool _open = false;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: <Widget>[
PlButton(
variant: PlassVariant.glass,
color: PlassColor.secondary,
onPressed: () => setState(() => _open = true),
child: const Text('Try “load”, or “distraction”'),
),
PlCommandPalette(
open: _open,
shortcut: null,
onOpenChanged: (bool next) => setState(() => _open = next),
items: const <PlCommandItem>[
PlCommandItem(value: 'new', label: 'New document', group: 'File'),
PlCommandItem(
value: 'open',
label: 'Open…',
group: 'File',
keywords: <String>['load', 'import'],
),
PlCommandItem(
value: 'copy',
label: 'Copy',
group: 'Edit',
description: 'Put it on the clipboard',
),
PlCommandItem(value: 'paste', label: 'Paste', group: 'Edit', disabled: true),
PlCommandItem(
value: 'zen',
label: 'Zen mode',
group: 'View',
keywords: <String>['focus', 'distraction free'],
),
],
),
],
);
}
}shortcut
Two different things share the name, and only one of them is bound.
A row's shortcut is displayed, at the end of the row, with PlHotKeys. The palette does not bind it: the application already has, and a component that bound it too would be a second listener nobody asked for.
The palette's shortcut is bound, on the window, and defaults to Mod+K. It is read with the same Mod-aware vocabulary PlHotKeys draws, so the cap on the screen and the key that works cannot drift apart. false binds nothing.
size
The sheet's width, the field's height and the rows' type scale. The field sits one step above the control ladder, md is 48px, because a palette's field is not a control in a row of controls: it is the top of a sheet, and it is the only thing on screen.
density moves the row height and nothing else.
import { useState } from 'react';
import { PlButton, PlCommandPalette, type PlassSize } from 'plass-ui';
const commands = [
{ value: 'new', label: 'New document' },
{ value: 'open', label: 'Open…' },
{ value: 'copy', label: 'Copy' }
];
export default function CommandPaletteSizes() {
const [size, setSize] = useState<PlassSize | null>(null);
return (
<div className="flex flex-wrap items-center justify-center gap-2">
{(['xs', 'sm', 'md', 'lg', 'xl'] as PlassSize[]).map((step) => (
<PlButton
key={step}
size="sm"
variant="glass"
color="secondary"
onClick={() => setSize(step)}
>
{step}
</PlButton>
))}
<PlCommandPalette
items={commands}
size={size ?? 'md'}
open={size !== null}
onOpenChange={(next) => setSize(next ? size : null)}
shortcut={false}
/>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class CommandPaletteSizes extends StatefulWidget {
const CommandPaletteSizes({super.key});
@override
State<CommandPaletteSizes> createState() => _CommandPaletteSizesState();
}
class _CommandPaletteSizesState extends State<CommandPaletteSizes> {
PlassSize? _size;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: <Widget>[
for (final PlassSize size in PlassSize.values)
PlButton(
size: PlassSize.sm,
variant: PlassVariant.glass,
color: PlassColor.secondary,
onPressed: () => setState(() => _size = size),
child: Text(size.name),
),
PlCommandPalette(
open: _size != null,
shortcut: null,
size: _size ?? PlassSize.md,
onOpenChanged: (bool next) => setState(() => _size = next ? _size : null),
items: const <PlCommandItem>[
PlCommandItem(value: 'new', label: 'New document'),
PlCommandItem(value: 'open', label: 'Open…'),
PlCommandItem(value: 'copy', label: 'Copy'),
],
),
],
);
}
}Controlled
Pass open with onOpenChange. The palette still asks, the keystroke fires onOpenChange(true), and does not open until the caller says so, which is what a route guard or a "not while the editor is busy" rule needs.
The query is dropped on the way out rather than on the way in, so the sheet never flashes the last search as it fades.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
open / defaultOpen | open, required | There is no uncontrolled mode: what opens a palette is a key bound on the whole app, and an app that binds one already holds the state. |
shortcut: false | shortcut: null | Dart's way of saying "bind nothing". |
| the list keys handled by Base UI's Autocomplete | handled before the focus system, in the palette's own key handler | The field has the focus and an EditableText consumes the arrow keys and Enter itself. Reading them first is the only way the field keeps every character while the list keeps its four keys. |
a combobox with aria-activedescendant | a field and a list of buttons, one marked selected | Flutter's semantics tree has no activedescendant. What survives is the thing that matters: the highlight is one mark, and it is announced on the row it is on. |
| the fold strips case and combining marks | case only | Dart's core has no String.normalize, and this package has no dependencies. |
width, maxHeight as a number or a CSS length | double | There is no second unit to name. |
className, style | — | There is no class list and no style attribute to pass through. |
Accessibility
- The sheet is a dialog with a focus trap, a scrim, Esc to close, and focus returned to wherever the reader was. It has no visible title, so
labelis its accessible name. - The field is a
comboboxand the list is itslistbox, wired witharia-activedescendantby Base UI, so the arrow keys move a highlight without moving focus, and the field keeps every keystroke. - The highlight is one mark: the pointer and the arrow keys move the same thing, so a reader is never looking at two highlighted rows wondering which Enter would run.
- A group heading is
role="presentation". It is a visual grouping of the same list, not a second list. - A
disabledcommand stays in the list and cannot be run. An option that vanishes when it cannot be chosen is one the reader will keep looking for. - The whole thing is portalled to the end of
<body>, and the backdrop and the viewport carry.plass-portal, which is where a host that scopes a CSS reset hangs the same reset.