CommandBar
A modal command palette. CommandBar is a dialog that also installs a global
Cmd/Ctrl+K shortcut on window to open itself.
Mount it once near the root of your app and forget about it.
It provides the shell only. Put a Command inside it for the
searchable list of actions.
import {
CommandBar,
CommandBarPanel,
CommandBarOverlay,
CommandBarTitle,
CommandBarDescription,
} from 'terracotta/command-bar';
import { DisclosureStateChild, useDisclosureState } from 'terracotta/states';Anatomy
<CommandBar> {/* role="dialog", Cmd/Ctrl+K opens it */}
<CommandBarOverlay/> {/* backdrop; closes on click */}
<CommandBarPanel> {/* focus trap */}
<CommandBarTitle/> {/* labels the dialog */}
<CommandBarDescription/> {/* describes the dialog */}
{/* usually a <Command> here */}
</CommandBarPanel>
</CommandBar>Examples
Uncontrolled, where the shortcut just works
With defaultOpen, the bar owns its state and the global shortcut needs no
wiring:
Click inside the frame first so it owns the keyboard, then press the shortcut.
Controlled, with a Command palette inside
import type { JSX } from '@solidjs/web';
import { For, createSignal } from 'solid-js';
import {
Command,
CommandInput,
CommandOption,
CommandOptions,
} from 'terracotta/command';
import {
CommandBar,
CommandBarDescription,
CommandBarOverlay,
CommandBarPanel,
CommandBarTitle,
} from 'terracotta/command-bar';
interface Action { id: string; label: string; run: () => void }
export function Palette(props: { actions: Action[] }): JSX.Element {
const [open, setOpen] = createSignal(false);
return (
<CommandBar
class="commandbar"
isOpen={open()}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
>
<CommandBarOverlay class="commandbar-overlay" />
<CommandBarPanel class="commandbar-panel">
<CommandBarTitle class="visually-hidden">Command palette</CommandBarTitle>
<CommandBarDescription class="visually-hidden">
Search for an action and press Enter to run it.
</CommandBarDescription>
<Command<Action>
defaultValue={undefined as unknown as Action}
by={(a, b) => a.id === b.id}
matchBy={(action, query) =>
action.label.toLowerCase().includes(query.toLowerCase())
}
onChange={action => {
action?.run();
setOpen(false);
}}
>
<CommandInput class="commandbar-input" placeholder="Type a command…" />
<CommandOptions class="commandbar-list">
<For each={props.actions}>
{action => (
<CommandOption class="commandbar-option" value={action}>
{action.label}
</CommandOption>
)}
</For>
</CommandOptions>
</Command>
</CommandBarPanel>
</CommandBar>
);
}.commandbar-input {
inline-size: 100%;
border: none;
border-block-end: 1px solid #e4e4e7;
padding: 0.875rem 1rem;
font: inherit;
}
.commandbar-input:focus { outline: none; }
.commandbar-list {
max-block-size: 18rem;
overflow-y: auto;
margin: 0;
padding: 0.25rem;
list-style: none;
}
.commandbar-option {
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
}
/* Hide entries that do not match the query */
.commandbar-option:not([tc-matches]) { display: none; }
.commandbar-option[tc-active] { background: #f4f4f5; }
.commandbar-option[tc-disabled] { color: #a1a1aa; }The keyboard shortcut calls the state's open() directly. A controlled
CommandBar therefore needs onOpen, or onChange, wired back to your signal.
Without that, the shortcut fires but nothing appears.
Adding your own trigger
<button type="button" class="search-trigger" onClick={() => setOpen(true)}>
Search <kbd>⌘K</kbd>
</button>.search-trigger kbd {
border: 1px solid #d4d4d8;
border-radius: 0.25rem;
padding: 0.0625rem 0.25rem;
font-size: 0.75rem;
}Closing after running an action
<CommandBar class="commandbar" defaultOpen={false}>
{({ close }) => (
<>
<CommandBarOverlay class="commandbar-overlay" />
<CommandBarPanel class="commandbar-panel">
<button type="button" onClick={() => { openSettings(); close(); }}>
Settings
</button>
</CommandBarPanel>
</>
)}
</CommandBar>Yielding the shortcut to another handler
The global listener ignores events that already had preventDefault() called on
them. A more specific handler can therefore claim the combination:
// Inside a code editor that wants Cmd+K for its own "insert link"
editor.addEventListener('keydown', event => {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault(); // CommandBar will now skip this event
insertLink();
}
});With a transition
<Transition show={open()}>
<CommandBar class="commandbar" isOpen={open()} onClose={() => setOpen(false)} unmount={false}>
<TransitionChild
enter="fade-enter" enterFrom="fade-from" enterTo="fade-to"
leave="fade-leave" leaveFrom="fade-to" leaveTo="fade-from"
>
<CommandBarOverlay class="commandbar-overlay" />
</TransitionChild>
<TransitionChild
enter="drop-enter" enterFrom="drop-from" enterTo="drop-to"
leave="drop-leave" leaveFrom="drop-to" leaveTo="drop-from"
>
<CommandBarPanel class="commandbar-panel">…</CommandBarPanel>
</TransitionChild>
</CommandBar>
</Transition>.fade-enter { transition: opacity 180ms ease-out; }
.fade-leave { transition: opacity 120ms ease-in; }
.fade-from { opacity: 0; }
.fade-to { opacity: 1; }
.drop-enter { transition: opacity 180ms ease-out, translate 180ms ease-out; }
.drop-leave { transition: opacity 120ms ease-in, translate 120ms ease-in; }
.drop-from { opacity: 0; translate: 0 -0.5rem; }
.drop-to { opacity: 1; translate: none; }State attributes
| Element | Attribute | Present when |
|---|---|---|
CommandBar |
tc-command-bar |
Always (whenever rendered) |
CommandBar |
tc-expanded |
The bar is open |
CommandBar |
tc-disabled |
The bar is disabled |
CommandBarOverlay |
tc-command-bar-overlay |
Always |
CommandBarOverlay |
tc-expanded, tc-disabled |
Mirrors the bar |
CommandBarPanel |
tc-command-bar-panel |
Always |
CommandBarPanel |
tc-expanded, tc-disabled |
Mirrors the bar |
CommandBarTitle |
tc-command-bar-title |
Always |
CommandBarTitle |
tc-expanded, tc-disabled |
Mirrors the bar |
CommandBarDescription |
tc-command-bar-description |
Always |
CommandBarDescription |
tc-expanded, tc-disabled |
Mirrors the bar |
Styling
/* With unmount={false}, you own visibility */
[tc-command-bar]:not([tc-expanded]) { display: none; }
[tc-command-bar-overlay] { position: fixed; inset: 0; background: rgb(0 0 0 / 0.4); }
[tc-command-bar-panel] { border-radius: 0.75rem; background: #ffffff; overflow: hidden; }
@media (prefers-reduced-motion: no-preference) {
[tc-command-bar-panel] { animation: commandbar-in 160ms ease-out; }
}
@keyframes commandbar-in {
from { opacity: 0; translate: 0 -0.5rem; }
to { opacity: 1; translate: none; }
}Reading the state in code
| Member | Type | Description |
|---|---|---|
isOpen() |
boolean |
Whether the bar is open. |
open() / close() |
() => void |
Opens / closes it. No-op while disabled. |
toggle() |
() => void |
Flips the state. |
disabled() |
boolean |
Whether the bar is disabled. |
You can reach this state as the render-prop argument on CommandBar and each of
its parts, through <DisclosureStateChild>, or with useDisclosureState() in
any descendant. Full reference in
disclosure state.
Keyboard
| Key | Action |
|---|---|
| Cmd/Ctrl+K | Opens the bar, from anywhere on the page |
| Escape | Closes it (handled by CommandBarPanel) |
| Tab / Shift+Tab | Cycles focus within the panel |
The global listener is registered for as long as the CommandBar is mounted,
open or not. Mount only one.
API
<CommandBar>
Owns the disclosure state and renders a <div>
by default. Does not take a ref.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
defaultOpen |
boolean |
none | Initial state, uncontrolled. Mutually exclusive with isOpen. |
isOpen |
boolean |
none | Current state, controlled. Mutually exclusive with defaultOpen. |
disabled |
boolean |
false |
Blocks opening and closing, including via the shortcut. |
onChange |
(state: boolean) => void |
none | Called with the new state on every change. |
onOpen |
() => void |
none | Called when it opens, including from the keyboard shortcut. |
onClose |
() => void |
none | Called when it closes, including via Escape and overlay clicks. |
unmount |
boolean | 'offscreen' |
true |
How the bar behaves while closed. See unmount. |
children |
JSX.Element | (state: DisclosureStateProperties) => JSX.Element |
none | Contents, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Rendered attributes:
role="dialog".aria-modal="true"while open.aria-labelledbyandaria-describedby, each only while itsCommandBarTitleorCommandBarDescriptionis mounted. Without a title, passaria-label.inertandaria-hidden="true"while closed. This only shows withunmount={false}, where the closed command bar stays in the DOM.
Your own props are applied last, so they override any of these.
While it is open, everything outside the command bar gets inert. Live regions,
such as a Toaster, are left out so they can still announce. Content you made
inert yourself is left as it is. Stacked command bars each restore only what they
changed, in any order.
<CommandBarPanel>
The focus trap. It focuses its first focusable child when the bar opens, so put
the CommandInput first and typing starts immediately. With no focusable child,
it gets tabindex="-1" and focuses itself, so Escape still works.
Renders a <div> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
disabled |
boolean |
false |
Stops the panel's own key handling. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element | (state: DisclosureStateProperties) => JSX.Element |
none | Contents, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<CommandBarOverlay>
The backdrop. Closes the bar when clicked. Renders a <div> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element | (state: DisclosureStateProperties) => JSX.Element |
none | Contents, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<CommandBarTitle>
The accessible name. Renders an <h2> by default. Does not take a ref. Hide
it visually if the palette has no visible heading.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'h2' |
Element or component to render as. |
children |
JSX.Element | (state: DisclosureStateProperties) => JSX.Element |
none | Title text, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<CommandBarDescription>
Renders a <p> by default. Does not take a ref.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'p' |
Element or component to render as. |
children |
JSX.Element | (state: DisclosureStateProperties) => JSX.Element |
none | Description text, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Every descendant throws if rendered outside a <CommandBar>.