frontend · intermediate · 3d
Command palette
A ⌘K command palette with fuzzy ranking, async action sources, and complete keyboard control (arrows, Enter, Escape, scoping) — the interaction layer every power-user tool needs.
Deliverable
A palette that opens with ⌘K, fuzzy-filters registered commands as you type, supports nested scoping (e.g., entering a 'Theme >' submenu), and is fully keyboard-only operable.
Milestones
0/3 · 0%- 01Registry + keyboard control
Build a command registry with synchronous commands, open/close via ⌘K, and arrow + Enter + Escape keyboard handling.
Definition of done- ⌘K opens/closes the palette; Arrow keys move the active item, Enter runs it, Escape closes — all without the mouse.
- Focus is trapped inside the open palette and restored to the trigger on close.
- 02Fuzzy ranking
Add fuzzy filtering with a scoring function that ranks exact-prefix matches above scattered matches, highlighting matched characters.
Definition of done- Typing ranks exact-prefix matches above scattered subsequence matches, and matched characters are highlighted.
- An empty query shows all commands; no match shows an explicit empty state.
- 03Async sources with debounce
Support async action sources (e.g., a search API) with debouncing, a loading indicator, and graceful cancellation of stale requests.
Definition of done- An async source is debounced, shows a loading state, and a stale in-flight request is cancelled when input changes.
- Results from a cancelled request never overwrite results from the latest query.
Starter
- README.md
- src/palette.ts
- test/palette.test.ts
Unzip, implement the stubs, then run the tests until they pass: bun test
Rubric
| Junior | Mid | Senior | |
|---|---|---|---|
| Match quality | Filters items by substring inclusion; all matching items surface regardless of position in the string. | Implements subsequence matching (not substring) and ranks prefix / contiguous matches above scattered ones so the intended item appears first. | Scoring function is pure and separately testable; ties are broken deterministically; the function handles Unicode boundaries correctly and its time complexity is stated and justified (O(n·m) per item, total O(N·m log N)). |
| Keyboard model correctness | Arrow keys move the selection and Enter triggers the highlighted item; wrapping is absent or breaks at boundaries. | Selection wraps correctly in both directions including with a single-item or empty list; reduce is a pure function with no side effects. | Reducer is the single source of truth for all keyboard state; focus management (trap on open, restore on close) is wired outside the reducer so the pure logic remains testable in Node without a DOM; edge cases (empty list, rapid key presses) are covered by unit tests. |
| Performance under load | Re-ranks the full list on every keypress synchronously; no noticeable issue with short lists (< 50 items). | Async sources are debounced; the ranking function is memoized or skipped when the query is unchanged; stale in-flight requests are cancelled. | A list of 10 000+ commands renders without jank: either virtualized (only visible rows in the DOM) or pre-indexed (trie / inverted index built once on registration so per-keystroke work is sub-linear). The choice is justified with a measured trade-off between memory and CPU. |
Reference walkthrough (spoiler)
Subsequence vs substring: fuzzy matching checks that every character of the query appears in the item in order, but not necessarily adjacently — 'fc' matches 'fuzzyCheck' because 'f' appears before 'c'. Levenshtein edit-distance is a different measure (minimum insertions/deletions/substitutions) that penalises gaps uniformly; subsequence scoring is cheaper (O(n) per candidate) and feels more natural for command search because it rewards matching the initials of camelCase words.
Keeping ranking a pure function: the scoring logic should take (items, query) and return a sorted array with no side effects — no state mutations, no DOM access, no async work. This boundary keeps it unit-testable in Node, makes memoization trivial (same inputs → same output), and allows the rendering layer to stay a thin view that only maps ranked results to DOM nodes.
Debouncing and cancellation for async sources: debounce delays invoking the async source until the user pauses typing (typically 150–250 ms), avoiding a network round trip on every keystroke. AbortController lets you cancel the in-flight fetch when the next keypress fires before the previous result arrives, preventing a slow response from an earlier query overwriting a faster response from a later one.
Virtualizing a large command list: rendering thousands of DOM nodes at once causes layout thrashing. A virtualizer (e.g. only rendering the 8–12 visible rows plus a small overscan buffer) keeps the DOM constant-size regardless of list length. The trade-off is scroll-position bookkeeping complexity; for lists under ~200 items the overhead of virtualization exceeds its benefit, so measure before adding it.
Make it senior
- Add scoping: a command can push a new context (e.g., 'Switch project >') with its own filtered list, with breadcrumb navigation and Backspace to pop the scope.
- Make the palette accessible: correct ARIA combobox + listbox roles, live region announcements for result count, and a focus trap that restores focus on close.