UNPKG

@loke/ui

Version:
316 lines (266 loc) 9.51 kB
--- 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)