feat: add command palette and global keyboard shortcuts - #19
Conversation
- New @openmapx/core/commands module: Command/CommandGroup/KeyChord/KeySequence types, parseShortcut/formatShortcut/matchChord/matchSequence with cross-platform Mod resolution and a KeyEventLike duck type so matchers stay testable in node, plus a substring/prefix scoreCommand with multi-word token fallback and a per-group priority bonus (layers > panels > overlays/categories/actions > search). 21 unit tests covering parser, formatter, matcher, scorer.
- Add useCommandPaletteStore (open/close/toggle/query) in @openmapx/core, exported from the package index alongside the new command primitives.
- Web: apps/web/src/components/command-palette/ scaffolds the Material Design Dialog (CommandPalette, CommandPaletteInput/List/Row/Footer), the ? help dialog (KeyboardShortcutsDialog), the global keydown listener (useGlobalKeybindings) with a 1.2s sequence buffer + Esc stack (palette → dialog → sidebar → menu → blur), and a GlobalKeybindings wrapper mounted via next/dynamic({ ssr: false }) in providers.tsx so it loads client-side on every route. Bindings: ⌘K (palette), ? (help), / (focus search), g s/d/n/m/l (panels), t (theme), . (my location); listener no-ops on touch-only devices.
- Command sources (useCommandSources) compose Command[] from existing stores: base layers + globe (useLayerStore), overlays via OVERLAY_REGISTRY + toggleOverlay with localeStrings/enStrings/id keywords, panels (Saved/Directions/Nearby/Menu/Layers — Directions also opens the sidebar, Nearby falls back to a synthetic Place at the map center, Layers dispatches openmapx:open-layer-selector handled by LayerSelector), categories with per-category SVG iconPath threaded through Command, and actions (share, theme cycle, my location, shortcuts, language). Search hand-off is appended as a real navigable Command at the bottom of the filtered list with auto-scrollIntoView on highlight change.
- SearchBar gains a desktop-only ⌘K/Ctrl+K hint chip; MapControls reuses the new useMyLocation hook (also tolerant of being mounted outside MapProvider via a new useMapOptional helper in MapContext).
- i18n: new commandPalette namespace in en+de with group headers, footer hints, search hand-off (uses curly quotes to avoid ICU '{...}' escaping), and command labels. Lift localeNames to packages/i18n so HamburgerMenu and the palette share the native-name map.
There was a problem hiding this comment.
Pull request overview
This PR introduces a cross-platform command/shortcut system in @openmapx/core and wires a new command palette + global keyboard shortcuts into the web app, including new i18n strings and a shared locale display-name map.
Changes:
- Add
@openmapx/core/commands(shortcut parse/format/match + command scoring) and auseCommandPaletteStore. - Add a web command palette UI (dialog, list, keyboard navigation) plus global keybindings and a shortcuts-help dialog.
- Add i18n strings for the command palette and centralize locale display names via
localeNames.
Reviewed changes
Copilot reviewed 31 out of 31 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/i18n/locales/en.json | Adds commandPalette translation namespace (EN). |
| packages/i18n/locales/de.json | Adds commandPalette translation namespace (DE). |
| packages/i18n/index.ts | Exports localeNames for shared native locale labels. |
| packages/core/src/stores/commandPaletteStore.ts | New zustand store for palette open/query state. |
| packages/core/src/index.ts | Re-exports commands utilities/types + useCommandPaletteStore. |
| packages/core/src/commands/types.ts | Defines Command + keybinding types. |
| packages/core/src/commands/score.ts | Implements command scoring/ranking. |
| packages/core/src/commands/score.test.ts | Tests for scoring behavior. |
| packages/core/src/commands/keybindings.ts | Shortcut parsing/formatting + chord/sequence matching. |
| packages/core/src/commands/keybindings.test.ts | Tests for shortcut parse/format/match. |
| packages/core/src/commands/index.ts | Barrel exports for the new commands module. |
| apps/web/src/lib/MapContext.tsx | Adds useMapOptional() for non-map routes. |
| apps/web/src/i18n/config.ts | Re-exports localeNames to web app. |
| apps/web/src/components/search/SearchBar.tsx | Adds a desktop discoverability chip to open the palette. |
| apps/web/src/components/menu/HamburgerMenu.tsx | Switches to shared localeNames instead of local map. |
| apps/web/src/components/map/layer-selector/LayerSelector.tsx | Listens for openmapx:open-layer-selector custom event. |
| apps/web/src/components/map/MapControls.tsx | Refactors “my location” behavior into shared hook. |
| apps/web/src/components/command-palette/useMyLocation.ts | New shared geolocation+flyTo hook. |
| apps/web/src/components/command-palette/useGlobalKeybindings.ts | Global key listener for palette + sequences + panels/actions. |
| apps/web/src/components/command-palette/useCommandSources.ts | Assembles Commands from existing stores/registries. |
| apps/web/src/components/command-palette/commandIcons.tsx | Icon map for command palette rows. |
| apps/web/src/components/command-palette/KeyboardShortcutsDialog.tsx | Dialog listing available keyboard shortcuts. |
| apps/web/src/components/command-palette/GlobalKeybindings.tsx | Mounts palette + shortcuts dialog + “/ to focus search”. |
| apps/web/src/components/command-palette/CommandPaletteRow.tsx | Renders a single command row with icon/shortcut/active state. |
| apps/web/src/components/command-palette/CommandPaletteList.tsx | Grouped vs ranked rendering of command rows. |
| apps/web/src/components/command-palette/CommandPaletteInput.tsx | Palette input (combobox-ish) header. |
| apps/web/src/components/command-palette/CommandPaletteFooter.tsx | Footer hints for keyboard navigation. |
| apps/web/src/components/command-palette/CommandPalette.tsx | Main palette dialog with ranking, fallback row, navigation. |
| apps/web/src/app/providers.tsx | Mounts GlobalKeybindings via next/dynamic (no SSR). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Make the "keep palette open" modifier platform-aware: ⌘+Enter on mac, Ctrl+Enter elsewhere (CommandPalette runCommand now checks getPlatform()), and render the footer hint with the same platform-aware modifier instead of always showing ⌘.
- SearchBar's ⌘K/Ctrl+K chip now reads from formatShortcut(parseShortcut("Mod+K"), getPlatform()) so it stays in sync with the matcher and works in privacy-restricted browsers (no longer eyeballing navigator.platform inline).
- Stable DOM identifiers for cross-locale wiring: new constants.ts exports SEARCH_INPUT_ID and COMMAND_PALETTE_LISTBOX_ID. The SearchBar input gets id=SEARCH_INPUT_ID, the palette's "Search on map" hand-off and the / focus handler look up that id (instead of the localised aria-label="search" / placeholder*="Suchen" selectors that previously broke in DE).
- Fix combobox/listbox semantics: aria-controls now points to the actual <List role="listbox"> via a new listboxId prop on CommandPaletteList, not the wrapping <div>.
- matchSequence now rejects events carrying the non-primary modifier (Ctrl on mac, Meta on other), matching matchChord's "no extra modifiers" rule. Without this, e.g. Ctrl+G on macOS would normalise to {key:"g"} and incorrectly advance a "g s" sequence. Added regression tests for both platforms.
- parseChord throws on multiple non-modifier keys in a single chord (e.g. "Ctrl+K+L") instead of silently using the last one. Added a regression test.
- useCommandSources destructures openShortcutsDialog from its options bag and the GlobalKeybindings caller wraps it in useCallback so the useMemo dependency is now stable instead of churning on every render.
- useMyLocation now early-returns when rendered outside <MapProvider>, so it's a true no-op (no geolocation prompt, no setUserLocation) — matches the docstring.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- "/" focus-search shortcut now no-ops while the command palette or keyboard-shortcuts dialog is open, so it can't focus the SearchBar through a modal.
- CommandPaletteRow gets tabIndex={-1}: the row is reachable via the input's aria-activedescendant, so it must NOT take focus itself or the combobox/listbox pattern breaks and Tab leaves the input.
- pr-title workflow: drop the unnecessary `statuses: write` permission and switch to `pull-requests: write` (which is what amannn/action-semantic-pull-request@v6 actually needs once `wip: true` is enabled — the action sets the PR check to pending while the title contains [WIP]).
- formatChord on non-mac now produces conventional `Ctrl+Shift+Alt+K` ordering (was rendering `Shift+Ctrl+K` because Shift used unshift while Ctrl used push); mac side rebuilt to push in Apple HIG order (Option, Shift, Command). Added regression tests.
- Hoist `parseShortcut("Mod+K")` etc. to module scope in SearchBar and KeyboardShortcutsDialog so the parser doesn't re-run on every render / memo recompute.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- SearchBar's ⌘K chip now has aria-label so it's announced as "Open command palette" instead of just "⌘K".
- New i18n key cmdFocusSearch ("Focus search" / "Suche fokussieren") used in the keyboard shortcuts dialog for "/" instead of the misleading t("placeholder") (which was the palette input placeholder).
- Extract LAYER_SELECTOR_OPEN_EVENT constant and reuse it in both the dispatcher (useCommandSources) and the listener (LayerSelector) so the event name can't drift between emitter and consumer.
- Hoist parseShortcut("?") to a module-scope HELP_SEQ in useGlobalKeybindings so it's no longer re-parsed on every keystroke.
- Clarify the misleading Esc handling comment: when the shortcuts dialog is open we deliberately leave Esc unhandled (no preventDefault) so MUI's Dialog onClose fires and closes it.
- "/" focus-search shortcut now ignores modifier combos (Ctrl+/, Cmd+/, Alt+/) so it doesn't hijack browser/OS bindings.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Command palette input now has an explicit aria-label via a new commandPalette.inputAriaLabel key ("Command palette" / "Befehlspalette"), so screen readers don't have to fall back to the placeholder. Same key reused for the listbox aria-label.
- Globe-view toggle reads the current value from useLayerStore.getState() at run time instead of the captured closure value, so rapid back-to-back invocations (e.g. ⌘+Enter held) toggle robustly instead of all flipping to the same stale value.
- Clarify the suppression comment in useGlobalKeybindings to say "our own dialogs (palette, shortcuts help) are open" instead of the misleading "in a Dialog", and note that other MUI dialogs aren't covered.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Lift `commands` to GlobalKeybindings: previously CommandPalette and GlobalKeybindings each called `useCommandSources` independently. Now GlobalKeybindings owns the single Command[] instance and passes it to CommandPalette as a prop, so the palette, the shortcuts dialog, and the keydown listener all reference the exact same closures. - Move the "/" focus-search shortcut into useGlobalKeybindings' existing keydown listener so it inherits the established suppression rules: it now no-ops on touch-only devices via isMobileLike() and treats <select> as a typing target via isTypingTarget(), matching the rest of the global shortcuts. Removes the separate document-level listener that previously didn't share those guards.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Drop redundant `?` keybinding path in useGlobalKeybindings; the
`actions.shortcuts` command already binds it via the regular path.
- Remove dead `commandPalette.showAll` i18n key (en + de).
- Harden synthetic `SEARCH_FALLBACK_ID` with `__` prefix to avoid any
future collision with real command ids.
- Add `focusable={false}` to category SVG icon for IE/legacy a11y.
- Drop unused `query` prop from CommandPaletteList. - `matchSequence` returns `matchedIndex`; export `chordsEqual` from core and remove the duplicated equality helper from useGlobalKeybindings. - Remove redundant `return` after console.error in runCommand. - Drop redundant `.toLowerCase()` on overlay/locale keywords (scoreCommand already lowercases defensively). - Assert PALETTE_TOGGLE is single-chord at module load; introduce PALETTE_TOGGLE_CHORD so the handler stops indexing `[0]`. - Theme cycle treats undefined mode as "system" so the first press is deterministic before MUI hydration. - Document why KeyboardShortcutsDialog filters actions.shortcuts. - Inline "+N more" expand row per group: lift expandedGroups state, synthesize an expand Command for truncated groups, reset on open. - Hide actions.myLocation outside <MapProvider> so the palette doesn't surface a no-op on non-map routes.
Summary
@openmapx/core/commandsmodule withCommand/KeyChord/KeySequencetypes, a cross-platform shortcut parser/formatter/matcher (Modresolves to ⌘ on macOS, Ctrl elsewhere), and a substring/prefix scorer with multi-word token fallback and per-group priority bonus. 22 unit tests covering parser, formatter, matcher, and scorer.apps/web/src/components/command-palette/: ⌘K opens aDialog(full-screen onxs) with grouped/ranked list, a synthetic "Search '⌘K(palette),?(help),/(focus SearchBar),g s/g d/g n/g m/g l(Saved / Directions / Nearby / Menu / Layers),t(theme cycle),.(My Location). Listener no-ops on touch-only devices and suppresses sequences when typing in inputs.OVERLAY_REGISTRY+ i18n-aware keywords), panels (Directions also opens its sidebar, Nearby falls back to the map center, Layers opens the full Map Details popover via a custom event the LayerSelector now listens for), categories with per-category SVG icons, and actions (share, theme, language switch via sharedlocaleNameslifted to@openmapx/i18n, my location, shortcuts).commandPalettei18n namespace in en+de; SearchBar gains a desktop-only⌘K/Ctrl+Kdiscoverability chip;MapControlsreuses the newuseMyLocationhook (also exposes a non-throwinguseMapOptionalso the palette works outside<MapProvider>).