Cmd K Command Palette
Checklist for building a Cmd+K palette in a frappe-ui Vue app. Built from doing it for wiki; Gameplan's frontend/src/components/CommandPalette/ is the reference.
Decide what it is before you build it
- It navigates, it does not search. It cuts clicks to a destination the user can already name. Full-text search needs a results page, snippets, paging and ranking: a different feature.
- List the searches the app already has and say what the palette replaces. Usually nothing. A filter over a visible list is a different job from jumping somewhere else.
- It crosses boundaries. If every existing box searches one container, the palette's whole reason to exist is finding the thing in another container.
- No modes. Context biases the ranking, it never scopes the results. A palette that hides destinations fails the case it was built for.
Checklist
1. The search endpoint
- One whitelisted method, not the reader's search index. A published-only content index misses drafts, which is exactly what an editor works on.
- Use
frappe.get_list, neverget_all. It applies the app's permission query conditions, so restricted records stay hidden with no new permission code. - Pull the parent's fields through the link join (
parent.title as parent_title) instead of a second query. - Filter out what cannot be opened: groups, external links, records with no parent.
- Decide what the query matches, route or title, and write it down. Matching the route means a renamed record is found by its old words, because
set_routeonly runs while the route is empty. - Normalise the query to the stored form (spaces to hyphens for routes).
- Order by
modified descand cap at ~20 rows. The client re-ranks. - Annotate the argument (
query: str), and frappe rejects a wrong type for you.
2. Ranking on the client
- Rank with
fuzzysort(already a frappe-ui dependency). Do not add a search library. - Put ranking in a plain
lib/module that takes sources and returns groups. Testable with no component. - Two thresholds: a loose one for short labels, a stricter one for long paths, because a short query scatter-matches letters across a long path.
- Match a page on its path inside its parent, not the full route: every sibling shares the parent segment.
- Bias, do not filter: give items in the user's current context a
scoreScale(1.5 is enough to win a tie, not enough to bury a better match). - Re-rank whatever rows are in hand against the current query. Stale rows from an in-flight request then drop out on their own, no request tracking.
- Give action items a
searchstring of synonyms ("new page create page add page") and rank on that, not the label. - Drop empty groups.
3. Groups and rows
- Fixed group order: Jump to, Spaces / parents, Actions, Pages. Flatten to one list for keyboard movement.
- An empty query answers before a key is pressed: app destinations, actions, and Recent.
- Two-line rows: title, then the
/routemuted below. Titles repeat across containers, the route is what tells them apart and what the query matched. A one-line row with the route right-aligned looks ragged: truncation starts each route at a different x. - Mark state that changes where a click lands (unpublished, private) with an icon at the right edge.
- Fetch the local sources (spaces, parents) once, on first open, not on app boot.
- Debounce the server query 300ms and only fire at 2+ characters.
4. Keyboard
- Register with frappe-ui
useKeyboardShortcut({ combo: 'Mod+K' }).Modis Cmd on macOS, Ctrl elsewhere. -
allowInInput: trueso it works inside the editor (contenteditable counts as input), plusallowInDialog: true. -
preventDefault: false, and bail out onevent.defaultPrevented. That is what lets a feature that already claimed Cmd+K (an editor link popup) keep it where it means something, with no change to the other keymap. - Do not "fix" the other keymap to give the key back conditionally. If its handler already returns
falsewhen it has nothing to do, the key falls through by itself, and a conditional break the case where it should win. - Arrow keys, Enter and Escape on the input. Escape comes free from the dialog.
- Track the active row by item key, not list index. A group that loads late otherwise moves the row under the user's finger. Unknown key falls back to the top row.
- Reset query and active key on close, not on open, so the closing animation does not flash an empty list.
-
scrollIntoView({ block: 'nearest' })on move.
5. Accessibility
- Input is
role="combobox"witharia-autocomplete="list",aria-controlsandaria-activedescendantpointing at the active row's id. - Results container is
role="listbox", each grouprole="group"witharia-labelledby, each rowrole="option"witharia-selected. - Dialog needs a title even when it is not drawn:
<Dialog.Title class="sr-only">. - Icons that repeat the text are
aria-hidden="true". - Footer hints with
KeyboardShortcutso the shortcuts are discoverable.
6. Recents
- Recents are the pages this user opened, not the records recently modified. A
modified descquery is the app's edit activity and fills the palette with pages the user has never seen. - So they belong in localStorage, next to pins, written when a page opens and has a title.
- Key the storage by user id. localStorage outlives a logout, so one key leaks the previous user's titles to the next person in that browser.
- Store everything a row needs (id, title, parent, route). A row that has to look up the route renders the wrong subtitle for old entries.
- Cap at five, newest first, a revisit moves to top, leave out the page the user is on: it is not somewhere to go.
7. Discoverability
- A shortcut alone tells nobody the palette exists. Add one sidebar row with a
Mod+Khint. - One entry point only. A second search affordance next to an existing filter box is clutter.
- No shortcut on mobile, so no palette there.
8. Do not build
- No command registry. One palette registering its own items is not a framework. Add a registry when a second place needs to register into it.
- No match highlighting, no aliases with score penalties, no scope chips, no full-text results row unless there is a results page to land on.
9. Tests
- Endpoint integration tests: the match rule, excluded kinds, the parent join, and a restricted record hidden from an outsider and shown to a member. Swap
get_listforget_alland that test must fail. - Unit tests on the ranking lib: empty-query groups, empty groups dropped, path match not title, spaced query against a hyphenated path, stale rows, minimum query length, context bias.
- Unit tests on recents: newest first, revisit moves to top, rename, cap, untitled ignored, per-user lists.
- Playwright: a record in another container, open from the sidebar row, Escape, recents without the current page, the active row staying put while a group loads, and the shortcut shared with the other feature that binds it.
Spec notes worth keeping
Record the wrong turns in the spec. The ones that came up here:
- ⌘K focusing the existing search box instead of opening a palette. Neither box crossed a container, which was the missing thing.
- Context modes (spaces only here, pages only there). Hides the record in the other container, the gap the feature closes.
- A recents endpoint ordered by
modified. That is edit activity, not history.