App Switcher Keyboard Shortcuts
Keyboard navigation for switching between Finaisse apps, in the app-switcher menu of every page header.
Code: blitz-ui — packages/shared/src/composables/useAppSwitcherShortcuts.ts (logic) and components/fin-wrappers/FinAppSwitcher.vue (UI). Keys are declared per app in navigation/modules.ts.
Why
The platform is nine separate apps (Close Hub, Reconciliation, Journal, CashApp, Collections, Invoice Hub, Settings, FinHub, Reporting) and finance users move between them constantly during a close — reconciling in one, posting a journal in another, chasing an invoice in a third.
Before this, every switch cost a mouse trip to the grid icon and a click on a tile. Small individually, but it is the single most repeated navigation action in the product, and it interrupts keyboard-driven work in the grids where these users spend their day.
The shortcuts
Switching apps
Press G, release it, then the app's letter. Not held together — two separate presses, within 2 seconds.
| App | Shortcut | App | Shortcut | |
|---|---|---|---|---|
| Close Hub | G C | CashApp | G A | |
| Reconciliation | G R | Collections | G L | |
| Journal | G J | Invoice Hub | G I | |
| Settings | G S | Reporting | G P | |
| FinHub | G F |
G G opens the switcher menu itself — the fallback when you remember the leader but not the letter.
Inside the open menu
| Key | Action |
|---|---|
| ← → ↑ ↓ | Move the highlight (wraps at the edges, skips apps you can't open) |
| Enter / Space | Open the highlighted app |
| Esc | Close the menu |
The two switches
There are two controls, and they do different things. Both gate the global chord only — arrow/Enter/Escape navigation inside the open menu always works, because that is standard accessible menu behaviour keyboard-only and screen-reader users depend on.
| Switch | Where | Effect |
|---|---|---|
| App Switch Shortcuts | Theme Customizer (gear, right edge) | Master on/off for the G chord. On by default. |
| Show shortcut keys | App switcher footer | Shows/hides the per-tile G C badges. Off by default — display only, never affects whether the chord fires. |
The badges are opt-in because they were reported as visual clutter in a nine-tile grid; the keys are documented here instead.
The two interact deliberately: with the master switch off, the footer toggle is hidden entirely, the badges are suppressed even if "show keys" was previously left on, and aria-keyshortcuts is dropped. Offering to reveal — or announcing to a screen reader — a chord that cannot fire just looks broken.
The master switch lives in the Customizer, not the tenant Settings app, because Settings stores one row shared by the whole tenant: a personal preference there would turn shortcuts off for everybody.
Both persist per browser (30-day cookies, the same mechanism as theme and nav settings). They survive logout, but do not follow a user across devices, and on a shared machine they apply to whoever logs in next. Backing UI preferences with a real per-user store is tracked in blitz#1172.
Why these keys
Why a chord rather than a modifier combo
The obvious designs are all taken:
- Cmd/Ctrl+1..9 is browser tab switching and cannot be reliably overridden.
- Alt+1..9 collides with menu accelerators on Windows and Linux.
A plain-letter chord has no OS or browser conflict at all. Its one real risk is firing while someone is typing — which is handled explicitly (see below).
Why G
Convention, not analysis. "Go" is the established leader in Gmail, GitHub, Linear, Jira and Slack, so anyone arriving from those tools already has the muscle memory.
Nothing in the implementation depends on it: the leader is a single CHORD_LEADER constant, and B or P would work identically. G was chosen because it is what users are most likely to guess.
Why the letters aren't just first initials
App names collide. Close Hub, CashApp and Collections all start with C; Reconciliation and Reporting both start with R. Deriving keys from names would make the losers unreachable.
So the first letter goes to the most-used app, and the others take a distinctive letter from their name:
- G A — cashApp
- G L — collections
- G P — reporting
Keys are declared explicitly as shortcutKey in modules.ts. A unit test fails if a future edit introduces a duplicate, so two apps can never silently claim the same letter.
Why no Home/End
They were built and then removed. On Mac laptops they require Fn+←/→, which few users discover — effectively dead bindings for most of the team. The arrows wrap at the grid edges instead, so with nine tiles any app is a few presses away. A test asserts Home/End are not handled, so re-adding them is a deliberate choice rather than an accident.
Safety rules
Two rules matter more than the convenience.
Never hijack a keystroke while someone is typing. The guard (isTypingContext()) suppresses the chord inside:
<input>,<textarea>,<select>- anything
contenteditable(TipTap rich-text editors) - elements with ARIA role
textbox,comboboxorsearchbox— Syncfusion grid filters and Vuetify autocompletes render these without a native<input>, so the role check is what protects them - open Syncfusion batch/inline edit cells (
.e-editedbatchcell,.e-gridform)
It deliberately errs toward "the user is typing": a missed shortcut is a minor annoyance, whereas a hijacked keystroke mid-edit can lose someone's work. Verified manually against plain inputs, the Syncfusion Excel filter dialog, and card/form edit fields.
Never reach an app the user can't open. Access and enabled-state are checked in the shortcut path, not just in the menu UI, so a keystroke cannot bypass what the tiles show as blocked. Pressing a blocked app's chord (no access, or coming-soon) opens the menu with an explanation rather than doing nothing — silence reads as a broken shortcut.
Two further details: the chord self-cancels after 2 seconds so a stray G never leaves the app armed and swallows the next keystroke; and modifier combos (Cmd+G is browser find-next) are ignored entirely.
Discoverability
A shortcut nobody knows about is dead code — but permanent badges on all nine tiles were clutter. The compromise: the keys live in this document, and the switcher's "Show shortcut keys" toggle reveals the G C badges on demand for anyone still learning them.
aria-keyshortcuts is always present for screen readers while the master switch is on, independent of whether the visual badges are shown — and dropped when it is off, so assistive tech never announces a chord that will not fire.
This is a deliberate trade: discoverability now costs one toggle-flip rather than permanent visual noise. If usage data ever shows nobody finds the shortcuts, the default is one line to change.
Notes for maintainers
- Adding an app: give it a
shortcutKeyinmodules.ts. The duplicate-key test will fail if the letter is taken. - Changing the leader: edit
CHORD_LEADER, plus the affected test strings. - The two preferences are separate cookies.
shortcutsEnabled(master, defaults on) andshortcutHintsVisible(badges, defaults off). Do not collapse them: an earlier version had one flag meaning "shortcuts on", and reinterpreting it as "hints visible" would silently flip meaning for anyone who had already set it — which is why the visibility flag got a new cookie key rather than reusing the old one. - The customizer closes when the menu opens. Its drawer is pinned at
z-index: 1001whileVMenurenders at Vuetify's default2400, so the two overlap. Rather than fight the z-index, the switcher closes the customizer via the shareduseCustomizerStateref — reachable becauseG Gcan be pressed while the customizer is open. VMenuteleports its content, which has two consequences the implementation works around: template refs inside the menu never populate (tiles are found by a scopedidquery, and the roving highlight is written to the elements directly), and VMenu keeps DOM focus on the activator button (so the key listener runs in the capture phase, or Enter would just re-trigger the activator and close the menu). Both only reproduce in a real browser — under happy-dom focus lands on the tile — so component tests dispatch onwindowand assert on the visible highlight class rather thandocument.activeElement.
References
- blitz-ui#765 — implementation (merged to
main) - blitz-ui#746 — the vitest harness this is tested with (the repo had no test framework before)
- blitz-ui#747 — the original PR; merged into a since-deleted staging branch rather than
main, so #765 is the commit that actually landed. Kept here only because the review discussion lives on it. - blitz-ui#800 — hint-visibility toggle + customizer master switch (the current behaviour above)
- blitz#1172 — per-user preference storage, which both toggles should migrate to