feat(shell): add global keyboard shortcuts and their help dialog

g then d/m/l/t/i/c/s opens a page through the same visible-destination
lists the navigation renders, so a switched-off module, a Coach without a
provider or a door a shared record does not cover stays shut. n opens the
capture picker, [ and ] step the open day, ? lists everything. Keys typed
into a field, held with Cmd/Ctrl/Alt, or pressed while a dialog or menu is
open are left alone; [ and ] still act over the day sheet and when the
layout needs Alt or AltGr to type them. The list is also reachable from
the sidebar account menu.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 16:03:54 +02:00
1 parent 1d080ee166
commit 44841c65fc
16 files changed
+1331

No files matched your search

@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// The sheet primitive portals through Radix, which renders nothing on the
// server; this stand-in keeps its contract (title + description name the
// surface) so the list itself can be read. Focus trap and Escape are the
// primitive's and are proven in `e2e/keyboard-shortcuts.spec.ts`.
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
open,
title,
description,
children,
}: {
open: boolean;
title: React.ReactNode;
description?: React.ReactNode;
children: React.ReactNode;
}) =>
open ? (
<section role="dialog" aria-label={String(title)}>
<h2>{title}</h2>
<p>{description}</p>
{children}
</section>
) : null,
}));
import { KeyboardShortcutsDialog } from "../keyboard-shortcuts-dialog";
const EVERYTHING = {
navHrefs: ["/", "/medications", "/labs", "/timeline", "/insights", "/coach"],
settingsHref: "/settings/account",
};
function render(
props: Partial<React.ComponentProps<typeof KeyboardShortcutsDialog>> = {},
locale = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale as "en"}>
<KeyboardShortcutsDialog
open
onOpenChange={() => undefined}
offer={EVERYTHING}
canCapture
{...props}
/>
</I18nProvider>,
);
}
describe("<KeyboardShortcutsDialog>", () => {
it("renders nothing while closed", () => {
expect(render({ open: false })).toBe("");
});
it("is titled and lists three groups", () => {
const html = render();
expect(html).toContain('aria-label="Keyboard shortcuts"');
expect(html).toContain('data-group="navigation"');
expect(html).toContain('data-group="actions"');
expect(html).toContain('data-group="day"');
});
it("shows every destination as g then its key, in kbd", () => {
const html = render();
for (const k of ["d", "m", "l", "t", "i", "c", "s"]) {
expect(html).toContain(`data-shortcut="go-${k}"`);
}
expect(html).toMatch(/<kbd[^>]*>g<\/kbd>/);
expect(html).toContain(">then<");
expect(html).toMatch(/<kbd[^>]*>\?<\/kbd>/);
expect(html).toMatch(/<kbd[^>]*>\[<\/kbd>/);
expect(html).toMatch(/<kbd[^>]*>\]<\/kbd>/);
expect(html).toMatch(/<kbd[^>]*>Esc<\/kbd>/);
});
it("drops a destination the session does not offer", () => {
const html = render({
offer: { navHrefs: ["/", "/insights"], settingsHref: null },
});
expect(html).toContain('data-shortcut="go-d"');
expect(html).toContain('data-shortcut="go-i"');
for (const k of ["m", "l", "t", "c", "s"]) {
expect(html).not.toContain(`data-shortcut="go-${k}"`);
}
});
it("drops n where there is nothing to log", () => {
expect(render({ canCapture: false })).not.toContain(
'data-shortcut="capture"',
);
expect(render()).toContain('data-shortcut="capture"');
});
it("uses token classes for the keys, no raw colour", () => {
const kbd = render().match(/<kbd class="([^"]+)"/)?.[1] ?? "";
expect(kbd).toContain("bg-muted");
expect(kbd).toContain("text-foreground");
expect(kbd).not.toMatch(/#[0-9a-f]{3,6}|dracula|\[/i);
});
it.each(["de", "es", "fr", "it", "pl", "ko"])(
"is translated in %s",
(locale) => {
const html = render({}, locale);
expect(html).not.toContain('aria-label="Keyboard shortcuts"');
expect(html).not.toContain("shortcuts.");
},
);
});
+5
View File
@@ -24,6 +24,7 @@ import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { isManageDelegateSettingsDestination } from "@/lib/record-settings/classification";
import { isDestinationInSharedRecord } from "./nav-model";
import { BottomNav } from "./bottom-nav";
import { GlobalShortcuts } from "./global-shortcuts";
import { DemoBanner } from "./demo-banner";
import { OfflineBanner } from "./offline-banner";
import { SharedRecordBanner } from "./shared-record-banner";
@@ -579,6 +580,10 @@ export function AuthShell({
got a raw 403. Demo is read-only by design; the FAB has no job
here, so it's mounted only outside demo mode. */}
{!demoMode && !inSharedRecord && <LayoutCoachFab />}
{/* v1.42 — the keyboard shortcuts (`g d`, `n`, `?` …) and their list.
Mounted here and only here, so they never run on the sign-in pages
or in the setup flow, which return above. */}
<GlobalShortcuts />
</CoachLaunchProvider>
);
}
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useRouter } from "next/navigation";
import {
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { stepDay, useOpenDay } from "@/components/day/day-layer-controller";
import { useTodayKey } from "@/components/day/use-today-key";
import {
CAPTURE_KIND_ORDER,
CapturePicker,
visibleCaptureKinds,
} from "@/components/layout/capture-picker";
import { KeyboardShortcutsDialog } from "@/components/layout/keyboard-shortcuts-dialog";
import {
isSettingsUtilityDestination,
visibleNavDestinations,
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { useAuth } from "@/hooks/use-auth";
import { useNavModules } from "@/hooks/use-nav-modules";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
createShortcutReader,
resolveGoTo,
shortcutScope,
type ShortcutOffer,
} from "@/lib/keyboard/global-shortcuts";
/**
* The app-wide keyboard shortcuts, mounted once by the authenticated shell
* (never on the sign-in pages or in the setup flow, which the shell renders
* without it).
*
* `g` + a key opens a page, `n` the add menu, `[` / `]` step the open day,
* `?` the list of all of them. Which key does what, and when a key is not
* ours, is decided in `src/lib/keyboard/global-shortcuts.ts`; this component
* reads the page's state and carries the action out. A destination comes from
* the same lists the navigation renders, so `g t` with the timeline module
* off, or `g c` without an AI provider, does nothing at all.
*
* The account menu opens the list too (`openShortcutsHelp`), for anyone who
* does not know there is a `?` to press.
*/
let helpOpen = false;
const helpListeners = new Set<() => void>();
function setShortcutsHelpOpen(open: boolean): void {
if (helpOpen === open) return;
helpOpen = open;
for (const listener of helpListeners) listener();
}
function subscribeHelp(listener: () => void): () => void {
helpListeners.add(listener);
return () => helpListeners.delete(listener);
}
/** Open the shortcut list from anywhere inside the signed-in shell. */
export function openShortcutsHelp(): void {
setShortcutsHelpOpen(true);
}
function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } {
const { user } = useAuth();
const navModules = useNavModules();
const capabilities = useRecordCapabilities();
const { inSharedRecord, sections, recordKind, level } = capabilities;
const manageableDomains = user?.accountAccess?.active?.manageableDomains;
const canCapture =
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules)
.length > 0;
const navHrefs = useMemo(
() =>
visibleNavDestinations(navModules, true, inSharedRecord, sections).map(
(d) => d.href,
),
[navModules, inSharedRecord, sections],
);
const settingsHref = useMemo(
() =>
visibleUtilityDestinations({
record: inSharedRecord
? { recordKind, level, manageableDomains: manageableDomains ?? [] }
: null,
}).find(isSettingsUtilityDestination)?.href ?? null,
[inSharedRecord, recordKind, level, manageableDomains],
);
return { navHrefs, settingsHref, canCapture };
}
export function GlobalShortcuts() {
const router = useRouter();
const open = useSyncExternalStore(
subscribeHelp,
() => helpOpen,
() => false,
);
const [captureOpen, setCaptureOpen] = useState(false);
const offer = useShortcutOffer();
const openDay = useOpenDay();
const today = useTodayKey();
// The listener is registered once; it reads the current state from here.
const latest = useRef({ offer, openDay, today, router });
useEffect(() => {
latest.current = { offer, openDay, today, router };
});
useEffect(() => {
const reader = createShortcutReader();
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
const action = reader.read(
{
key: event.key,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
altGraph: event.getModifierState?.("AltGraph") ?? false,
repeat: event.repeat,
isComposing: event.isComposing,
target: event.target,
},
shortcutScope(document),
event.timeStamp,
);
if (action === null || action === "pending") return;
const state = latest.current;
switch (action.type) {
case "go": {
const href = resolveGoTo(action.key, state.offer);
if (href === null) return;
event.preventDefault();
state.router.push(href);
return;
}
case "capture":
if (!state.offer.canCapture) return;
event.preventDefault();
setCaptureOpen(true);
return;
case "help":
event.preventDefault();
setShortcutsHelpOpen(true);
return;
case "day": {
const date = state.openDay;
if (date === null) return;
event.preventDefault();
// No day after today: the layer refuses it, so the step does too.
if (action.delta > 0 && date >= state.today) return;
stepDay(date, action.delta);
return;
}
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
// Leaving the shell (signing out) leaves the list closed.
setShortcutsHelpOpen(false);
};
}, []);
return (
<>
<KeyboardShortcutsDialog
open={open}
onOpenChange={setShortcutsHelpOpen}
offer={offer}
canCapture={offer.canCapture}
/>
{offer.canCapture ? (
<CapturePicker open={captureOpen} onOpenChange={setCaptureOpen} />
) : null}
</>
);
}
@@ -0,0 +1,158 @@
"use client";
import { Fragment } from "react";
import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
import {
GO_TO_SHORTCUTS,
resolveGoTo,
type ShortcutOffer,
} from "@/lib/keyboard/global-shortcuts";
/**
* The list of keyboard shortcuts, opened with `?` or from the account menu.
*
* It lists what works for this session: a destination whose module is off,
* or a Coach without an AI provider, is not offered by `g` and is not listed
* either. Key names are literal keys, not translated words.
*/
export interface KeyboardShortcutsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
offer: ShortcutOffer;
/** Whether `n` has anything to open (a read-only record has not). */
canCapture: boolean;
}
interface Row {
id: string;
label: string;
/** Keys pressed one after the other. */
sequence: ReadonlyArray<string>;
}
function Kbd({ children }: { children: string }) {
// Foreground, not muted: a key name has to be readable at a glance, and
// the muted token sits under the contrast floor on the muted wash.
return (
<kbd className="border-border bg-muted text-foreground inline-flex min-w-6 items-center justify-center rounded border px-1.5 py-0.5 font-mono text-xs">
{children}
</kbd>
);
}
export function KeyboardShortcutsDialog({
open,
onOpenChange,
offer,
canCapture,
}: KeyboardShortcutsDialogProps) {
const { t } = useTranslations();
const navigation: Row[] = GO_TO_SHORTCUTS.flatMap((shortcut) => {
if (resolveGoTo(shortcut.key, offer) === null) return [];
const tKey =
shortcut.destination === "settings"
? "nav.settings"
: NAV_DESTINATIONS.find((d) => d.href === shortcut.destination)?.tKey;
if (!tKey) return [];
return [
{
id: `go-${shortcut.key}`,
label: t(tKey),
sequence: ["g", shortcut.key],
},
];
});
const actions: Row[] = [
...(canCapture
? [
{
id: "capture",
label: t("nav.capture.title"),
sequence: ["n"],
},
]
: []),
{ id: "help", label: t("shortcuts.help"), sequence: ["?"] },
{ id: "close", label: t("shortcuts.close"), sequence: ["Esc"] },
];
const day: Row[] = [
{ id: "day-previous", label: t("shortcuts.previousDay"), sequence: ["["] },
{ id: "day-next", label: t("shortcuts.nextDay"), sequence: ["]"] },
];
const groups = [
{
id: "navigation",
title: t("shortcuts.groups.navigation"),
rows: navigation,
},
{ id: "actions", title: t("shortcuts.groups.actions"), rows: actions },
{
id: "day",
title: t("shortcuts.groups.day"),
hint: t("shortcuts.dayHint"),
rows: day,
},
].filter((group) => group.rows.length > 0);
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("shortcuts.title")}
description={t("shortcuts.description")}
>
<div data-slot="keyboard-shortcuts" className="flex flex-col gap-6">
{groups.map((group) => (
<section
key={group.id}
data-group={group.id}
aria-labelledby={`shortcuts-group-${group.id}`}
className="flex flex-col gap-2"
>
<div className="flex flex-col gap-1">
<h3
id={`shortcuts-group-${group.id}`}
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
>
{group.title}
</h3>
{"hint" in group && group.hint ? (
<p className="text-muted-foreground text-xs">{group.hint}</p>
) : null}
</div>
<dl className="flex flex-col gap-2">
{group.rows.map((row) => (
<div
key={row.id}
data-shortcut={row.id}
className="flex items-center justify-between gap-3"
>
<dt className="min-w-0 flex-1 text-sm">{row.label}</dt>
<dd className="flex shrink-0 items-center gap-1.5">
{row.sequence.map((key, index) => (
<Fragment key={`${row.id}-${index}`}>
{index > 0 ? (
<span className="text-muted-foreground text-xs">
{t("shortcuts.then")}
</span>
) : null}
<Kbd>{key}</Kbd>
</Fragment>
))}
</dd>
</div>
))}
</dl>
</section>
))}
</div>
</ResponsiveSheet>
);
}
+16
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Bell,
Keyboard,
LogOut,
Monitor,
Moon,
@@ -17,6 +18,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu";
import { openShortcutsHelp } from "@/components/layout/global-shortcuts";
import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
import {
isNavDestinationActive,
@@ -46,6 +48,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
@@ -200,6 +203,19 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
</Link>
</DropdownMenuItem>
)}
{/* v1.42 — the keyboard shortcuts, findable without knowing that `?`
lists them. Desktop only, like this menu: the phone's account menu
has no keyboard to describe. */}
<DropdownMenuItem
data-slot="open-keyboard-shortcuts"
aria-keyshortcuts="?"
onSelect={() => openShortcutsHelp()}
className="cursor-pointer"
>
<Keyboard className="mr-2 h-4 w-4" />
{t("shortcuts.menuEntry")}
<DropdownMenuShortcut aria-hidden="true">?</DropdownMenuShortcut>
</DropdownMenuItem>
{/* The about section lives at the end of the settings shell nav;
the avatar menu stays focused on account-level actions. */}
<DropdownMenuSub>