Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 46 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,11 @@ import type {

**`ResolveRouteResult`** — `{ path: string; unresolved: UnresolvedParam[]; selections: Record<string, string> }`. The path with resolved params replaced, unresolved params listed with optional selectable options, and accumulated param selections.

**`UnresolvedParam`** — `{ name: string; options?: string[] }`. A param that still needs a value, optionally with options for the user to choose from.
**`UnresolvedParam`** — `{ name: string; options?: ResolvedOption[] }`. A param that still needs a value, optionally with options for the user to choose from.

**`ResolvedOption`** — `string | LabeledValue`. A param-selection option. Plain strings render as a single-line item using the string itself as both the display label and the route value. Use `LabeledValue` when you want a different display label, or to attach an icon, description, or extra search keywords.

**`LabeledValue`** — `{ label: string; value: string; description?: string; icon?: ReactNode; keywords?: string[] }`. The `label` shows in the option row (and breadcrumb); `value` is what gets substituted into the route. Optional `icon`, `description`, and `keywords` mirror the corresponding fields on `Command`.

## Resolver pattern

Expand All @@ -159,9 +163,11 @@ Routes can contain `:param` placeholders that are resolved at runtime via the `r
3. `selections` contains values from earlier params, including auto-resolved strings and user-selected options. `search` is the current text typed while choosing param options, or an empty string during initial resolution.
4. For each param, the resolver returns:
- A **string** → the param is replaced in the path immediately, added to `selections`, and the next param is resolved.
- A **string array** → the palette shows a sub-layer where the user picks one option. Remaining params are resolved after the user selects a value.
- A **`ResolvedOption[]`** array → the palette shows a sub-layer where the user picks one option. Each entry can be a plain string or a `LabeledValue` carrying an icon, description, and search keywords. Remaining params are resolved after the user selects a value.
- **Anything else** → the param is listed as unresolved with no options.
5. Once all params are resolved, `onNavigate` fires with the final path.
5. While the resolver runs, a small spinner appears on the right side of the search input and the previously visible items stay in place; the new options replace them as soon as the resolver settles.
6. While the user is several levels deep, a breadcrumb at the top of the palette shows the originating command title followed by each picked option's label, so the path is visible without leaving the active param input.
7. Once all params are resolved, `onNavigate` fires with the final path.

### Example: dependent params

Expand All @@ -170,7 +176,14 @@ import type { CommandsProps } from '@automattic/commands';

const resolver: CommandsProps[ 'resolver' ] = async ( param, selections, search ) => {
if ( param === 'appId' ) {
return [ 'my-app', 'other-app' ];
const apps = await fetchApps();
return apps.map( app => ( {
label: app.name,
value: app.id,
description: app.url,
icon: <AppLogo src={ app.iconUrl } />,
keywords: app.tags,
} ) );
}

if ( param === 'env' ) {
Expand Down Expand Up @@ -299,21 +312,35 @@ The default theme is bundled with `<Commands />` — no CSS import needed. Overr
| `--cmdk-item-description-line-height` | `16px` | Description line height. |
| `--cmdk-item-description-gap` | `2px` | Gap between title and description. |

#### Shortcut badge and type label

| Variable | Default | Description |
| ------------------------------ | -------------------------- | ---------------------------------------- |
| `--cmdk-shortcut` | `#646970` | Shortcut text color (fallback). |
| `--cmdk-shortcut-text` | inherits `--cmdk-shortcut` | Shortcut text color. |
| `--cmdk-shortcut-bg` | `#f6f7f7` | Shortcut badge background. |
| `--cmdk-shortcut-border` | `#dcdcde` | Shortcut badge border color. |
| `--cmdk-shortcut-border-width` | `1px` | Shortcut badge border width. |
| `--cmdk-shortcut-radius` | `4px` | Shortcut badge border radius. |
| `--cmdk-shortcut-padding-y` | `2px` | Shortcut badge vertical padding. |
| `--cmdk-shortcut-padding-x` | `6px` | Shortcut badge horizontal padding. |
| `--cmdk-type-label` | `#646970` | Type label color ("Link" / "Action"). |
| `--cmdk-item-meta-font-size` | `12px` | Font size for shortcut and type label. |
| `--cmdk-item-meta-line-height` | `16px` | Line height for shortcut and type label. |
#### Shortcut badge

| Variable | Default | Description |
| ------------------------------ | -------------------------- | ---------------------------------- |
| `--cmdk-shortcut` | `#646970` | Shortcut text color (fallback). |
| `--cmdk-shortcut-text` | inherits `--cmdk-shortcut` | Shortcut text color. |
| `--cmdk-shortcut-bg` | `#f6f7f7` | Shortcut badge background. |
| `--cmdk-shortcut-border` | `#dcdcde` | Shortcut badge border color. |
| `--cmdk-shortcut-border-width` | `1px` | Shortcut badge border width. |
| `--cmdk-shortcut-radius` | `4px` | Shortcut badge border radius. |
| `--cmdk-shortcut-padding-y` | `2px` | Shortcut badge vertical padding. |
| `--cmdk-shortcut-padding-x` | `6px` | Shortcut badge horizontal padding. |
| `--cmdk-item-meta-font-size` | `12px` | Font size for shortcut. |
| `--cmdk-item-meta-line-height` | `16px` | Line height for shortcut. |

#### Breadcrumb

The breadcrumb shown above the input during multi-step param selection.

| Variable | Default | Description |
| --------------------------------------- | ------------------------------- | -------------------------------------- |
| `--cmdk-breadcrumb-text` | inherits `--cmdk-group-heading` | Default breadcrumb segment text color. |
| `--cmdk-breadcrumb-current` | inherits `--cmdk-text` | Current (last) segment text color. |
| `--cmdk-breadcrumb-current-font-weight` | `500` | Current segment font weight. |
| `--cmdk-breadcrumb-font-size` | `12px` | Breadcrumb font size. |
| `--cmdk-breadcrumb-line-height` | `16px` | Breadcrumb line height. |
| `--cmdk-breadcrumb-padding-y` | `8px` | Breadcrumb vertical padding. |
| `--cmdk-breadcrumb-padding-x` | `16px` | Breadcrumb horizontal padding. |
| `--cmdk-breadcrumb-gap` | `6px` | Gap between breadcrumb segments. |

#### Empty and loading states

Expand Down
26 changes: 23 additions & 3 deletions playground/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,26 @@ const envsByApp: Record< string, string[] > = {

const resolver: CommandsProps[ 'resolver' ] = async ( param, selections ) => {
if ( param === 'appId' ) {
return [ 'my-cool-app', 'my-other-app', 'non-existent-app' ];
return [
{
label: 'My cool app',
value: 'my-cool-app',
description: 'apps.example.com/my-cool-app',
icon: '\ud83d\ude80',
keywords: [ 'primary', 'flagship' ],
},
{
label: 'My other app',
value: 'my-other-app',
description: 'apps.example.com/my-other-app',
icon: '\ud83e\uddea',
},
{
label: 'Non-existent app (will error)',
value: 'non-existent-app',
icon: '\u26a0\ufe0f',
},
];
}

if ( param === 'env' ) {
Expand Down Expand Up @@ -131,8 +150,9 @@ export function App() {
Press <kbd>Mod+k</kbd> to open the command palette.
</p>
<p style={ { fontSize: 14, color: '#666' } }>
Try &ldquo;Audit log&rdquo; to see the loading state followed by the param-selection
sub-layer.
Try &ldquo;Audit log&rdquo; to see the input spinner during loading, the param-selection
sub-layer with icons and descriptions, and the breadcrumb at the top of the palette as you
drill down.
</p>
<Commands
commands={ commands.map( command => ( {
Expand Down
32 changes: 18 additions & 14 deletions src/command-list-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@ const themeAttributes = {
itemTitle: { 'cmdk-item-title': '' },
itemDescription: { 'cmdk-item-description': '' },
itemShortcut: { 'cmdk-item-shortcut': '' },
itemType: { 'cmdk-item-type': '' },
error: { 'cmdk-error': '' },
} as const;

export function CommandListContent( {
resolving,
resolveError,
paramSelection,
currentParam,
Expand All @@ -27,10 +25,6 @@ export function CommandListContent( {
const search = useCommandState( state => state.search );
const shouldShowRecent = showRecent && search === '' && recentCommands.length > 0;

if ( resolving ) {
return <CommandPrimitive.Loading>Loading...</CommandPrimitive.Loading>;
}
Comment on lines -30 to -32
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the scenario where all the items are still loading and there is nothing to show on the list, we do want to show the Loading text here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in #45 — the empty-state copy now becomes "Loading..." while a resolve is pending. Items still stay visible when they exist; this only swaps the otherwise-misleading "No results" text.


if ( resolveError ) {
return (
<div { ...themeAttributes.error } role="alert">
Expand Down Expand Up @@ -112,17 +106,32 @@ function optionValue( option: ResolvedOption ): string {

interface OptionItemProps {
option: ResolvedOption;
onSelect: ( value: string ) => void;
onSelect: ( option: ResolvedOption ) => void;
}

function OptionItem( { option, onSelect }: OptionItemProps ) {
const label = optionLabel( option );
const value = optionValue( option );
const isLabeled = typeof option !== 'string';
const icon = isLabeled ? option.icon : undefined;
const description = isLabeled ? option.description : undefined;
const extraKeywords = isLabeled ? option.keywords ?? [] : [];
const keywords = [ label, ...extraKeywords ];

return (
<CommandPrimitive.Item value={ `${ label }:${ value }` } onSelect={ () => onSelect( value ) }>
<CommandPrimitive.Item
value={ `${ label }:${ value }` }
keywords={ keywords }
onSelect={ () => onSelect( option ) }
>
{ icon && (
<span { ...themeAttributes.itemIcon } aria-hidden="true">
{ icon }
</span>
) }
<span { ...themeAttributes.itemContent }>
<span { ...themeAttributes.itemTitle }>{ label }</span>
{ description && <span { ...themeAttributes.itemDescription }>{ description }</span> }
</span>
</CommandPrimitive.Item>
);
Expand All @@ -137,7 +146,6 @@ interface CommandItemProps {
}

function CommandItem( { command, value = command.id, onSelect }: CommandItemProps ) {
const typeLabel = command.route ? 'Link' : 'Action';
const keywords = [ command.title, ...( command.keywords ?? [] ) ];

return (
Expand All @@ -153,11 +161,7 @@ function CommandItem( { command, value = command.id, onSelect }: CommandItemProp
<span { ...themeAttributes.itemDescription }>{ command.description }</span>
) }
</span>
{ command.shortcut ? (
<span { ...themeAttributes.itemShortcut }>{ command.shortcut }</span>
) : (
<span { ...themeAttributes.itemType }>{ typeLabel }</span>
) }
{ command.shortcut && <span { ...themeAttributes.itemShortcut }>{ command.shortcut }</span> }
</CommandPrimitive.Item>
);
}
Loading