@loke/ui
Version:
316 lines (266 loc) • 9.51 kB
Markdown
---
name: command
type: core
domain: navigation
requires: [loke-ui]
description: >
Command palettes and searchable lists with Command/CommandInput/CommandList/
CommandItem/CommandGroup/CommandGroupHeading/CommandEmpty/CommandLoading.
Built-in fuzzy filtering via commandScore. Custom filter function via filter prop.
Compose Popover+Command for combobox/async-select patterns — the recommended
replacement for Select when options are async, searchable, or dynamically loaded.
Vim bindings (ctrl+n/j/p/k) enabled by default.
---
# Command
## Setup
Command palette with search input, item list, and empty state.
```tsx
import {
Command,
CommandInput,
CommandList,
CommandItem,
CommandEmpty,
} from "@loke/ui/command";
function CommandPalette() {
return (
<Command label="Command palette">
<CommandInput placeholder="Type a command..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandItem onSelect={() => console.log("New file")}>
New file
</CommandItem>
<CommandItem onSelect={() => console.log("Open settings")}>
Open settings
</CommandItem>
<CommandItem onSelect={() => console.log("Git commit")}>
Git commit
</CommandItem>
</CommandList>
</Command>
);
}
```
## Core Patterns
### Popover + Command combobox (async select alternative)
This is the recommended pattern when `Select` does not work — specifically for async-loaded options, searchable dropdowns, or filterable option lists. `Select` assumes all items exist at open time; `Popover + Command` has no such constraint.
```tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@loke/ui/popover";
import {
Command,
CommandInput,
CommandList,
CommandItem,
CommandEmpty,
} from "@loke/ui/command";
type Option = { value: string; label: string };
const OPTIONS: Option[] = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
{ value: "solid", label: "Solid" },
];
function FrameworkPicker() {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<Option | null>(null);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button">
{selected ? selected.label : "Select framework..."}
</button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput placeholder="Search frameworks..." />
<CommandList>
<CommandEmpty>No framework found.</CommandEmpty>
{OPTIONS.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
setSelected(option);
setOpen(false);
}}
>
{option.label}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
```
For async options, fetch in a `useEffect` and show `CommandLoading` while pending — see the Loading state pattern below.
### Groups with headings
```tsx
import {
Command,
CommandInput,
CommandList,
CommandGroup,
CommandGroupHeading,
CommandItem,
CommandSeparator,
CommandEmpty,
} from "@loke/ui/command";
function GroupedPalette() {
return (
<Command label="Actions">
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup>
<CommandGroupHeading>Files</CommandGroupHeading>
<CommandItem value="new-file" onSelect={() => {}}>New file</CommandItem>
<CommandItem value="open-file" onSelect={() => {}}>Open file</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CommandGroupHeading>Git</CommandGroupHeading>
<CommandItem value="git-commit" onSelect={() => {}}>Commit</CommandItem>
<CommandItem value="git-push" onSelect={() => {}}>Push</CommandItem>
</CommandGroup>
</CommandList>
</Command>
);
}
```
Groups that have no matching items are hidden automatically during filtering.
### Custom filter function
Replace the default fuzzy scoring with your own function. The filter receives the item `value`, the current `search` string, and optional `keywords`. Return a number: higher scores rank higher; `0` hides the item.
```tsx
function exactPrefixFilter(value: string, search: string): number {
if (value.toLowerCase().startsWith(search.toLowerCase())) return 1;
return 0;
}
<Command filter={exactPrefixFilter}>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandItem value="apple">Apple</CommandItem>
<CommandItem value="apricot">Apricot</CommandItem>
<CommandItem value="banana">Banana</CommandItem>
</CommandList>
</Command>
```
Disable built-in filtering entirely with `shouldFilter={false}` — useful when filtering is done server-side.
### Loading state for async options
```tsx
import { useEffect, useState } from "react";
import {
Command,
CommandInput,
CommandList,
CommandItem,
CommandEmpty,
CommandLoading,
} from "@loke/ui/command";
function AsyncCommand() {
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<string[]>([]);
const [search, setSearch] = useState("");
useEffect(() => {
if (!search) return;
setLoading(true);
fetchItems(search).then((results) => {
setItems(results);
setLoading(false);
});
}, [search]);
return (
<Command shouldFilter={false} label="Search users">
<CommandInput
placeholder="Search users..."
value={search}
onValueChange={setSearch}
/>
<CommandList>
{loading && <CommandLoading>Searching...</CommandLoading>}
{!loading && items.length === 0 && search && (
<CommandEmpty>No users found.</CommandEmpty>
)}
{items.map((item) => (
<CommandItem key={item} value={item} onSelect={() => {}}>
{item}
</CommandItem>
))}
</CommandList>
</Command>
);
}
```
## Common Mistakes
### Using `Select` for async/searchable options — use Popover + Command instead
`Select` measures all `SelectItem` positions for item-aligned layout, portals `SelectItemText` into the trigger, and walks the collection for typeahead. All of these break with async-loaded or dynamically filtered options. Use the Popover + Command pattern instead.
```tsx
// Wrong — Select assumes all options exist at open time
<Select>
<SelectTrigger>...</SelectTrigger>
<SelectContent>
{asyncOptions.map(opt => (
<SelectItem key={opt.id} value={opt.id}>
<SelectItemText>{opt.label}</SelectItemText>
</SelectItem>
))}
</SelectContent>
</Select>
// Correct — Popover + Command handles async/searchable options
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild><button type="button">{label}</button></PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput onValueChange={setSearch} />
<CommandList>
{asyncOptions.map(opt => (
<CommandItem key={opt.id} value={opt.id} onSelect={handleSelect}>
{opt.label}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
```
Source: `domain_map.yaml` — `maintainer interview`; `src/components/command/command.tsx`.
### Missing `value` on `CommandItem` when children are complex — garbled filtering
`CommandItem` infers its `value` from `textContent` when no `value` prop is provided. Children containing icons, badges, or nested elements produce garbled values that cause incorrect filtering and broken selection callbacks. Always set `value` explicitly on complex items.
```tsx
// Wrong — textContent becomes "Edit✏️" or similar
<CommandItem onSelect={handleEdit}>
<PencilIcon /> Edit
</CommandItem>
// Correct
<CommandItem value="edit" onSelect={handleEdit}>
<PencilIcon /> Edit
</CommandItem>
```
Source: `src/components/command/command.tsx` — `useValue` hook extracts from `ref.current?.textContent`.
### Forgetting `CommandList` wrapper — missing ARIA semantics and broken layout
`CommandList` provides the `listbox` role, accessible label (`aria-label`), and sets the `--loke-cmd-list-height` CSS variable. Items rendered outside `CommandList` lack proper ARIA structure and the height measurement used for animated list resizing.
```tsx
// Wrong
<Command>
<CommandInput placeholder="Search..." />
<CommandItem value="a">Item A</CommandItem>
<CommandItem value="b">Item B</CommandItem>
</Command>
// Correct
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandItem value="a">Item A</CommandItem>
<CommandItem value="b">Item B</CommandItem>
</CommandList>
</Command>
```
Source: `src/components/command/command.tsx` — `CommandList` sets `role="listbox"`.
## Cross-references
- **Popover** (`@loke/ui/popover`) — required for the Popover + Command combobox pattern
- **Select** (`@loke/ui/select`) — use for static, non-searchable option lists
- **Choosing the Right Component** — Select vs Popover+Command decision guidance
- **Component reference** — [`references/command-components.md`](references/command-components.md)