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

+3
View File
@@ -98,6 +98,9 @@ an earlier fold computed from part of a day.
`REINDEX INDEX CONCURRENTLY` per index, largest first.
- **Query statistics.** The bundled database loads `pg_stat_statements`;
`CREATE EXTENSION` once to read it (`docs/ops/query-statistics.md`).
- **Keyboard shortcuts.** `g` then `d`, `m`, `l`, `t`, `i`, `c` or `s` opens
a page, `n` the add menu, `[` and `]` step the open day, and `?` (or the
account menu) lists them; ignored while typing in a field.
### Changed
+214
View File
@@ -0,0 +1,214 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { mockDay } from "./setup/day-mock";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import { mockTimeline } from "./utils/mock-timeline";
/**
* v1.42 — the keyboard shortcuts.
*
* - `g t` opens the timeline while its module is on, and does nothing while
* it is off;
* - keys typed into a field stay in the field;
* - `?` opens the list: a titled dialog that keeps focus inside and closes
* on Escape; the account menu opens the same list;
* - `n` opens the add menu;
* - `[` and `]` step the open day, never past today.
*
* The module map is the account's real `/api/auth/me` with only `timeline`
* rewritten, so the switch is never moved on a shared account. Desktop only:
* a phone has no keyboard to press these on.
*/
test.use({ storageState: STORAGE_STATE_PATH });
const TODAY = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
function shiftDay(key: string, delta: number): string {
const d = new Date(`${key}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + delta);
return d.toISOString().slice(0, 10);
}
async function withTimelineModule(page: Page, on: boolean): Promise<void> {
await page.route("**/api/auth/me", async (route) => {
const res = await route.fetch();
const json = await res.json().catch(() => null);
if (!json?.data?.modules) return route.fulfill({ response: res });
json.data.modules.timeline = on;
return route.fulfill({
response: res,
body: JSON.stringify(json),
headers: { ...res.headers(), "content-type": "application/json" },
});
});
}
/** The shell is up and its listener registered. */
async function shellReady(page: Page): Promise<void> {
await expect(page.locator("#main-content")).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
}
const helpDialog = (page: Page) =>
page.getByRole("dialog", { name: "Keyboard shortcuts" });
test.describe("keyboard shortcuts", () => {
test.beforeEach(async ({ context }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"keyboard journeys run on the desktop project",
);
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
});
test("g t opens the timeline while its module is on", async ({ page }) => {
await withTimelineModule(page, true);
await mockTimeline(page, "ready", TODAY);
await page.goto("/");
await shellReady(page);
await page.keyboard.press("g");
await page.keyboard.press("t");
await expect(page).toHaveURL(/\/timeline$/);
});
test("g t does nothing while the timeline module is off", async ({
page,
}) => {
await withTimelineModule(page, false);
// Every client navigation, in order, so "nothing happened" is a fact
// rather than a wait.
await page.addInitScript(() => {
const log: string[] = [];
(window as unknown as { __pushed: string[] }).__pushed = log;
const push = history.pushState.bind(history);
history.pushState = (state, unused, url) => {
if (url != null) log.push(String(url));
push(state, unused, url);
};
});
await page.goto("/insights");
await shellReady(page);
await page.keyboard.press("g");
await page.keyboard.press("t");
// The positive control: the same reader opens a page that is on.
await page.keyboard.press("g");
await page.keyboard.press("d");
await expect(page).toHaveURL(/\/$/);
const pushed = await page.evaluate(
() => (window as unknown as { __pushed: string[] }).__pushed,
);
expect(pushed.some((url) => url.includes("/timeline"))).toBe(false);
});
test("keys typed into a field stay in the field", async ({ page }) => {
await withTimelineModule(page, true);
await page.goto("/");
await shellReady(page);
// A plain field in the page: the rule is about the focused element, not
// about which page carries it.
await page.evaluate(() => {
const field = document.createElement("input");
field.setAttribute("data-testid", "probe-field");
field.setAttribute("aria-label", "Probe");
document.getElementById("main-content")?.prepend(field);
});
const field = page.getByTestId("probe-field");
await field.focus();
await page.keyboard.type("gt n ? [");
await expect(field).toHaveValue("gt n ? [");
await expect(page).toHaveURL(/\/$/);
await expect(helpDialog(page)).toHaveCount(0);
await expect(page.getByTestId("capture-picker-options")).toHaveCount(0);
});
test("? opens a titled list that keeps focus and closes on Escape", async ({
page,
}) => {
await page.goto("/");
await shellReady(page);
await page.keyboard.press("Shift+?");
const dialog = helpDialog(page);
await expect(dialog).toBeVisible();
await expect(
dialog.locator('[data-slot="keyboard-shortcuts"] [data-group]'),
).toHaveCount(3);
await expect(dialog.locator('[data-shortcut="go-d"] kbd')).toHaveText([
"g",
"d",
]);
// Focus trap: Tab and Shift+Tab never leave the dialog.
for (const step of ["Tab", "Tab", "Tab", "Shift+Tab", "Shift+Tab"]) {
await page.keyboard.press(step);
expect(
await dialog.evaluate((el) => el.contains(document.activeElement)),
).toBe(true);
}
// While the list is open, the page's shortcuts are off.
await page.keyboard.press("g");
await page.keyboard.press("i");
await expect(page).toHaveURL(/\/$/);
const axe = await new AxeBuilder({ page })
.include('[role="dialog"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations).toEqual([]);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
});
test("the account menu opens the same list", async ({ page }) => {
await page.goto("/");
await shellReady(page);
await page
.getByRole("button", { name: "User menu" })
.locator("visible=true")
.first()
.click();
await page.locator('[data-slot="open-keyboard-shortcuts"]').click();
await expect(helpDialog(page)).toBeVisible();
});
test("n opens the add menu", async ({ page }) => {
await page.goto("/insights");
await shellReady(page);
await page.keyboard.press("n");
await expect(page.getByTestId("capture-picker-options")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("capture-picker-options")).toHaveCount(0);
});
test("[ and ] step the open day, never past today", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockDay(page);
const yesterday = shiftDay(TODAY, -1);
await page.goto(`/insights?day=${yesterday}`);
await shellReady(page);
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toHaveAttribute("data-shell", "docked");
// Focus on the page, not in the panel: docked, the keys still reach it.
await page.locator("#main-content").focus();
await page.keyboard.press("[");
await expect(page).toHaveURL(new RegExp(`[?&]day=${shiftDay(TODAY, -2)}`));
await page.keyboard.press("]");
await page.keyboard.press("]");
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
await page.keyboard.press("]");
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
});
});
+16
View File
@@ -12554,6 +12554,22 @@
"body": "Titel und Notiz sind danach nicht mehr da."
}
},
"shortcuts": {
"title": "Tastenkürzel",
"description": "Tasten, die überall in der App funktionieren, außer während du in ein Feld tippst.",
"menuEntry": "Tastenkürzel",
"then": "dann",
"help": "Diese Liste zeigen",
"close": "Dialog oder Bereich schließen",
"previousDay": "Vorheriger Tag",
"nextDay": "Nächster Tag",
"dayHint": "Während ein Tag geöffnet ist.",
"groups": {
"navigation": "Navigation",
"actions": "Aktionen",
"day": "Tag"
}
},
"aboutPage": {
"eyebrow": "Info",
"title": "Über HealthLog",
+16
View File
@@ -12554,6 +12554,22 @@
"body": "Its title and note will be gone."
}
},
"shortcuts": {
"title": "Keyboard shortcuts",
"description": "Keys that work anywhere in the app, except while you type in a field.",
"menuEntry": "Keyboard shortcuts",
"then": "then",
"help": "Show this list",
"close": "Close a dialog or panel",
"previousDay": "Previous day",
"nextDay": "Next day",
"dayHint": "While a day is open.",
"groups": {
"navigation": "Navigation",
"actions": "Actions",
"day": "Day"
}
},
"aboutPage": {
"eyebrow": "About",
"title": "About HealthLog",
+16
View File
@@ -12554,6 +12554,22 @@
"body": "Su título y su nota desaparecerán."
}
},
"shortcuts": {
"title": "Atajos de teclado",
"description": "Teclas que funcionan en toda la aplicación, salvo mientras escribes en un campo.",
"menuEntry": "Atajos de teclado",
"then": "luego",
"help": "Mostrar esta lista",
"close": "Cerrar un diálogo o panel",
"previousDay": "Día anterior",
"nextDay": "Día siguiente",
"dayHint": "Mientras hay un día abierto.",
"groups": {
"navigation": "Navegación",
"actions": "Acciones",
"day": "Día"
}
},
"aboutPage": {
"eyebrow": "Información",
"title": "Acerca de HealthLog",
+16
View File
@@ -12554,6 +12554,22 @@
"body": "Son titre et sa note disparaîtront."
}
},
"shortcuts": {
"title": "Raccourcis clavier",
"description": "Des touches qui fonctionnent partout dans l’application, sauf pendant la saisie dans un champ.",
"menuEntry": "Raccourcis clavier",
"then": "puis",
"help": "Afficher cette liste",
"close": "Fermer une boîte de dialogue ou un panneau",
"previousDay": "Jour précédent",
"nextDay": "Jour suivant",
"dayHint": "Lorsqu’un jour est ouvert.",
"groups": {
"navigation": "Navigation",
"actions": "Actions",
"day": "Jour"
}
},
"aboutPage": {
"eyebrow": "À propos",
"title": "À propos de HealthLog",
+16
View File
@@ -12554,6 +12554,22 @@
"body": "Titolo e nota non ci saranno più."
}
},
"shortcuts": {
"title": "Scorciatoie da tastiera",
"description": "Tasti che funzionano ovunque nell’app, tranne mentre scrivi in un campo.",
"menuEntry": "Scorciatoie da tastiera",
"then": "poi",
"help": "Mostra questo elenco",
"close": "Chiudi una finestra o un pannello",
"previousDay": "Giorno precedente",
"nextDay": "Giorno successivo",
"dayHint": "Quando un giorno è aperto.",
"groups": {
"navigation": "Navigazione",
"actions": "Azioni",
"day": "Giorno"
}
},
"aboutPage": {
"eyebrow": "Informazioni",
"title": "Informazioni su HealthLog",
+16
View File
@@ -12554,6 +12554,22 @@
"body": "제목과 메모가 사라져요."
}
},
"shortcuts": {
"title": "키보드 단축키",
"description": "입력란에 입력하는 중이 아니면 앱 어디에서나 작동하는 키입니다.",
"menuEntry": "키보드 단축키",
"then": "다음",
"help": "이 목록 보기",
"close": "대화 상자나 패널 닫기",
"previousDay": "이전 날",
"nextDay": "다음 날",
"dayHint": "하루가 열려 있을 때.",
"groups": {
"navigation": "탐색",
"actions": "작업",
"day": "하루"
}
},
"aboutPage": {
"eyebrow": "정보",
"title": "HealthLog 정보",
+16
View File
@@ -12554,6 +12554,22 @@
"body": "Tytuł i notatka znikną."
}
},
"shortcuts": {
"title": "Skróty klawiszowe",
"description": "Klawisze działające w całej aplikacji, z wyjątkiem pisania w polu.",
"menuEntry": "Skróty klawiszowe",
"then": "potem",
"help": "Pokaż tę listę",
"close": "Zamknij okno lub panel",
"previousDay": "Poprzedni dzień",
"nextDay": "Następny dzień",
"dayHint": "Gdy otwarty jest dzień.",
"groups": {
"navigation": "Nawigacja",
"actions": "Akcje",
"day": "Dzień"
}
},
"aboutPage": {
"eyebrow": "Informacje",
"title": "O HealthLog",
@@ -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>
@@ -0,0 +1,285 @@
import { describe, expect, it } from "vitest";
import { visibleNavDestinations } from "@/components/layout/nav-model";
import {
SEQUENCE_TIMEOUT_MS,
blockedByModifier,
createShortcutReader,
isEditableTarget,
resolveGoTo,
shortcutScope,
type ShortcutKeyEvent,
} from "@/lib/keyboard/global-shortcuts";
function key(
k: string,
over: Partial<ShortcutKeyEvent> = {},
): ShortcutKeyEvent {
return {
key: k,
altKey: false,
ctrlKey: false,
metaKey: false,
target: null,
...over,
};
}
/** An element whose `closest` answers for the selectors it matches. */
function element(matches: (selector: string) => boolean) {
return {
closest: (selector: string) =>
selector.split(", ").some((s) => matches(s)) ? {} : null,
};
}
const input = element((s) => s === "input");
const textarea = element((s) => s === "textarea");
const select = element((s) => s === "select");
const combobox = element((s) => s === '[role="combobox"]');
const editable = { isContentEditable: true, closest: () => null };
const button = element(() => false);
describe("isEditableTarget", () => {
it("treats fields, comboboxes and contenteditable as the field's", () => {
for (const target of [input, textarea, select, combobox, editable]) {
expect(isEditableTarget(target)).toBe(true);
}
});
it("leaves buttons, the body and non-elements to the shortcuts", () => {
expect(isEditableTarget(button)).toBe(false);
expect(isEditableTarget(null)).toBe(false);
expect(isEditableTarget(undefined)).toBe(false);
expect(isEditableTarget({})).toBe(false);
});
});
describe("the reader ignores keys typed into a field", () => {
it.each([
["input", input],
["textarea", textarea],
["select", select],
["combobox", combobox],
["contenteditable", editable],
])("%s: no action for g d, n, ? or [", (_name, target) => {
const reader = createShortcutReader();
expect(reader.read(key("g", { target }), "page", 0)).toBeNull();
expect(reader.read(key("d", { target }), "page", 10)).toBeNull();
expect(reader.read(key("n", { target }), "page", 20)).toBeNull();
expect(reader.read(key("?", { target }), "page", 30)).toBeNull();
expect(reader.read(key("[", { target }), "page", 40)).toBeNull();
});
it("a field between g and d breaks the sequence", () => {
const reader = createShortcutReader();
expect(reader.read(key("g"), "page", 0)).toBe("pending");
expect(reader.read(key("d", { target: input }), "page", 10)).toBeNull();
expect(reader.read(key("d"), "page", 20)).toBeNull();
});
});
describe("modifiers", () => {
it("Cmd, Ctrl and Alt make a letter somebody else's", () => {
const reader = createShortcutReader();
for (const over of [
{ metaKey: true },
{ ctrlKey: true },
{ altKey: true },
]) {
expect(reader.read(key("n", over), "page", 0)).toBeNull();
expect(reader.read(key("g", over), "page", 0)).toBeNull();
}
expect(reader.read(key("?", { metaKey: true }), "page", 0)).toBeNull();
expect(reader.read(key("?", { ctrlKey: true }), "page", 0)).toBeNull();
});
it("a modified second key does not complete g", () => {
const reader = createShortcutReader();
expect(reader.read(key("g"), "page", 0)).toBe("pending");
expect(reader.read(key("d", { metaKey: true }), "page", 10)).toBeNull();
expect(reader.read(key("d"), "page", 20)).toBeNull();
});
it("Shift alone, pressed to type ?, neither acts nor breaks a sequence", () => {
const reader = createShortcutReader();
expect(reader.read(key("Shift"), "page", 0)).toBeNull();
expect(reader.read(key("?"), "page", 5)).toEqual({ type: "help" });
expect(reader.read(key("g"), "page", 10)).toBe("pending");
expect(reader.read(key("Shift"), "page", 20)).toBeNull();
expect(reader.read(key("D"), "page", 30)).toEqual({ type: "go", key: "d" });
});
it("[ typed with Option (German Mac) or AltGr (German PC) still steps the day", () => {
expect(blockedByModifier(key("[", { altKey: true }))).toBe(false);
expect(
blockedByModifier(
key("]", { altKey: true, ctrlKey: true, altGraph: true }),
),
).toBe(false);
// Ctrl+Alt without AltGr is a command, not a layout key.
expect(blockedByModifier(key("[", { altKey: true, ctrlKey: true }))).toBe(
true,
);
expect(blockedByModifier(key("[", { metaKey: true }))).toBe(true);
// An Alt+arrow is the day layer's own step and stays its own.
expect(blockedByModifier(key("ArrowLeft", { altKey: true }))).toBe(true);
});
});
describe("sequence timing", () => {
it("g then a destination within the window goes there", () => {
const reader = createShortcutReader();
expect(reader.read(key("g"), "page", 1000)).toBe("pending");
expect(reader.read(key("t"), "page", 1000 + SEQUENCE_TIMEOUT_MS)).toEqual({
type: "go",
key: "t",
});
});
it("a second key after the window does nothing", () => {
const reader = createShortcutReader();
reader.read(key("g"), "page", 0);
expect(reader.read(key("t"), "page", SEQUENCE_TIMEOUT_MS + 1)).toBeNull();
});
it("a destination key without g does nothing", () => {
const reader = createShortcutReader();
for (const k of ["d", "t", "c", "m", "l", "i", "s"]) {
expect(reader.read(key(k), "page", 0)).toBeNull();
}
});
it("an unknown second key cancels; it does not fall through to n", () => {
const reader = createShortcutReader();
reader.read(key("g"), "page", 0);
expect(reader.read(key("n"), "page", 10)).toBeNull();
expect(reader.read(key("d"), "page", 20)).toBeNull();
});
it("g g restarts the window", () => {
const reader = createShortcutReader();
reader.read(key("g"), "page", 0);
expect(reader.read(key("g"), "page", 900)).toBe("pending");
expect(reader.read(key("s"), "page", 1800)).toEqual({
type: "go",
key: "s",
});
});
it("a held key does not repeat the action", () => {
const reader = createShortcutReader();
expect(reader.read(key("n", { repeat: true }), "page", 0)).toBeNull();
});
it("reset forgets a pending g", () => {
const reader = createShortcutReader();
reader.read(key("g"), "page", 0);
reader.reset();
expect(reader.read(key("d"), "page", 10)).toBeNull();
});
});
describe("scope", () => {
it("an open dialog, menu or popover takes every key", () => {
const reader = createShortcutReader();
expect(reader.read(key("n"), "off", 0)).toBeNull();
expect(reader.read(key("?"), "off", 0)).toBeNull();
expect(reader.read(key("["), "off", 0)).toBeNull();
reader.read(key("g"), "page", 0);
expect(reader.read(key("d"), "off", 10)).toBeNull();
});
it("over the day sheet only [ and ] act", () => {
const reader = createShortcutReader();
expect(reader.read(key("["), "day-sheet", 0)).toEqual({
type: "day",
delta: -1,
});
expect(reader.read(key("]"), "day-sheet", 0)).toEqual({
type: "day",
delta: 1,
});
expect(reader.read(key("n"), "day-sheet", 0)).toBeNull();
expect(reader.read(key("g"), "day-sheet", 0)).toBeNull();
});
it("Escape is never ours", () => {
const reader = createShortcutReader();
expect(reader.read(key("Escape"), "page", 0)).toBeNull();
});
it("reads the scope from the open surfaces", () => {
const none = { querySelectorAll: () => [] };
expect(shortcutScope(none)).toBe("page");
const daySheet = {
querySelectorAll: () => [
{
closest: (s: string) => (s === '[data-slot="day-panel"]' ? {} : null),
},
],
};
expect(shortcutScope(daySheet)).toBe("day-sheet");
const other = { querySelectorAll: () => [{ closest: () => null }] };
expect(shortcutScope(other)).toBe("off");
const both = {
querySelectorAll: () => [
{
closest: (s: string) => (s === '[data-slot="day-panel"]' ? {} : null),
},
{ closest: () => null },
],
};
expect(shortcutScope(both)).toBe("off");
});
});
describe("module gating", () => {
function offer(modules: Record<string, boolean> | undefined) {
return {
navHrefs: visibleNavDestinations(modules).map((d) => d.href),
settingsHref: "/settings/account",
};
}
it("g t goes nowhere while the timeline module is off", () => {
expect(resolveGoTo("t", offer({ timeline: false }))).toBeNull();
expect(resolveGoTo("t", offer({ timeline: true }))).toBe("/timeline");
});
it("g m and g l follow the medications and labs modules", () => {
expect(resolveGoTo("m", offer({ medications: false }))).toBeNull();
expect(resolveGoTo("l", offer({ labs: false }))).toBeNull();
expect(resolveGoTo("m", offer({ medications: true }))).toBe("/medications");
expect(resolveGoTo("l", offer({ labs: true }))).toBe("/labs");
});
it("g c follows the coach entry, which carries the AI capability", () => {
// `useNavModules` folds the capability into `coach`.
expect(resolveGoTo("c", offer({ coach: false }))).toBeNull();
expect(resolveGoTo("c", offer({ coach: true }))).toBe("/coach");
});
it("g d and g i belong to no module", () => {
const off = offer({ timeline: false, labs: false, medications: false });
expect(resolveGoTo("d", off)).toBe("/");
expect(resolveGoTo("i", off)).toBe("/insights");
});
it("inside a shared record the doors sharing does not cover stay shut", () => {
const shared = {
navHrefs: visibleNavDestinations({}, true, true).map((d) => d.href),
settingsHref: null,
};
expect(resolveGoTo("i", shared)).toBeNull();
expect(resolveGoTo("c", shared)).toBeNull();
expect(resolveGoTo("s", shared)).toBeNull();
expect(resolveGoTo("d", shared)).toBe("/");
});
it("g s lands where the Settings entry lands", () => {
expect(
resolveGoTo("s", { navHrefs: [], settingsHref: "/settings/profile" }),
).toBe("/settings/profile");
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* The app-wide keyboard shortcuts, as pure logic.
*
* The listener in `src/components/layout/global-shortcuts.tsx` hands every
* keydown to `createShortcutReader().read()` together with the scope the page
* is in, and gets back an action, a "wait for the second key", or nothing.
* Everything that decides whether a key is ours lives here, so it can be
* pinned without a DOM:
*
* - a key typed into a field is the field's (`isEditableTarget`);
* - a key held with Cmd, Ctrl or Alt belongs to the browser, the operating
* system or a screen reader (`blockedByModifier`), with one exception: a
* punctuation key the layout itself needs Alt or AltGr to type (`[` is
* Option+5 on a German Mac, AltGr+8 on a German PC) still counts, because
* for that person there is no other way to press it;
* - while a dialog, sheet, menu or popover is open the keys are its own,
* except over the day sheet, where `[` and `]` step the day it shows.
*
* `g` starts a two-key sequence; the second key has to follow within
* `SEQUENCE_TIMEOUT_MS`. Escape is never handled here: every surface closes
* itself on it, as before.
*/
/** How long `g` waits for its second key. */
export const SEQUENCE_TIMEOUT_MS = 1000;
/**
* `g` + key: where it goes. The destination is a nav href, or `settings`,
* which lands wherever the Settings entry of the account menu lands (it
* differs inside a shared record). Listed in navigation order, which is the
* order the help dialog shows them in.
*/
export const GO_TO_SHORTCUTS = [
{ key: "d", destination: "/" },
{ key: "m", destination: "/medications" },
{ key: "l", destination: "/labs" },
{ key: "t", destination: "/timeline" },
{ key: "i", destination: "/insights" },
{ key: "c", destination: "/coach" },
{ key: "s", destination: "settings" },
] as const;
export type GoToKey = (typeof GO_TO_SHORTCUTS)[number]["key"];
export type ShortcutAction =
| { type: "go"; key: GoToKey }
| { type: "capture" }
| { type: "help" }
| { type: "day"; delta: -1 | 1 };
/**
* Where the keyboard is:
* - `page`: nothing modal is open, every shortcut applies;
* - `day-sheet`: the day sheet is the only modal surface, `[` / `]` apply;
* - `off`: another dialog, sheet, menu or popover has the keys.
*/
export type ShortcutScope = "page" | "day-sheet" | "off";
/** What the reader needs from a `KeyboardEvent`. */
export interface ShortcutKeyEvent {
key: string;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
/** `event.getModifierState("AltGraph")`. */
altGraph?: boolean;
repeat?: boolean;
isComposing?: boolean;
target?: unknown;
}
/** Keys a layout may need Alt or AltGr for, and that still count then. */
const LAYOUT_CHARACTERS: ReadonlySet<string> = new Set(["[", "]", "?"]);
/** Pressing one of these alone never starts, ends or breaks a shortcut. */
const MODIFIER_KEYS: ReadonlySet<string> = new Set([
"Shift",
"Control",
"Alt",
"AltGraph",
"Meta",
"CapsLock",
"Fn",
"OS",
]);
/** Something a person types into, by element or by role. */
const EDITABLE_SELECTOR = [
"input",
"textarea",
"select",
'[contenteditable]:not([contenteditable="false"])',
'[role="combobox"]',
'[role="textbox"]',
'[role="searchbox"]',
'[role="spinbutton"]',
].join(", ");
/** Open surfaces that own the keyboard while they are up. */
export const MODAL_SURFACE_SELECTOR = [
'[role="dialog"][data-state="open"]',
'[role="alertdialog"][data-state="open"]',
'[aria-modal="true"]',
'[role="menu"][data-state="open"]',
'[role="listbox"][data-state="open"]',
].join(", ");
/** The day layer's frame, docked or as a sheet (`day-layer.tsx`). */
const DAY_PANEL_SELECTOR = '[data-slot="day-panel"]';
/** True when the key went to a field, which keeps every key it gets. */
export function isEditableTarget(target: unknown): boolean {
if (target === null || typeof target !== "object") return false;
const el = target as {
isContentEditable?: boolean;
closest?: (selector: string) => unknown;
};
if (el.isContentEditable === true) return true;
if (typeof el.closest !== "function") return false;
return el.closest(EDITABLE_SELECTOR) != null;
}
/**
* True when a modifier makes the key somebody else's: Cmd and Ctrl always
* (browser and screen-reader commands), Alt unless the layout needs it to
* type one of our punctuation keys. Shift is free: it is how `?` is typed.
*/
export function blockedByModifier(event: ShortcutKeyEvent): boolean {
if (event.metaKey) return true;
const altGraph = event.altGraph === true;
// AltGr reports as Ctrl+Alt on Windows; it is a layout key, not a command.
if (event.ctrlKey && !altGraph) return true;
if ((event.altKey || altGraph) && !LAYOUT_CHARACTERS.has(event.key)) {
return true;
}
return false;
}
/** The scope the page is in, read from the open surfaces. */
export function shortcutScope(root: {
querySelectorAll: (selector: string) => ArrayLike<unknown>;
}): ShortcutScope {
const open = Array.from(root.querySelectorAll(MODAL_SURFACE_SELECTOR));
if (open.length === 0) return "page";
const onlyDay = open.every((el) => {
const node = el as { closest?: (selector: string) => unknown };
return (
typeof node.closest === "function" &&
node.closest(DAY_PANEL_SELECTOR) != null
);
});
return onlyDay ? "day-sheet" : "off";
}
function normalise(key: string): string {
return key.length === 1 ? key.toLowerCase() : key;
}
function isGoToKey(key: string): key is GoToKey {
return GO_TO_SHORTCUTS.some((shortcut) => shortcut.key === key);
}
/**
* A reader with the one piece of state a sequence needs: when `g` was
* pressed. `read` returns the action a key completes, `"pending"` after `g`,
* or `null` for a key that is not ours (which also drops a pending `g`).
*/
export function createShortcutReader(timeoutMs = SEQUENCE_TIMEOUT_MS) {
let pendingSince: number | null = null;
function read(
event: ShortcutKeyEvent,
scope: ShortcutScope,
now: number,
): ShortcutAction | "pending" | null {
if (event.isComposing) return null;
if (MODIFIER_KEYS.has(event.key)) return null;
if (event.repeat) return null;
const pending = pendingSince;
pendingSince = null;
if (scope === "off") return null;
if (isEditableTarget(event.target)) return null;
if (blockedByModifier(event)) return null;
const key = normalise(event.key);
if (key === "[" || key === "]") {
return { type: "day", delta: key === "[" ? -1 : 1 };
}
if (scope !== "page") return null;
if (pending !== null && now - pending <= timeoutMs) {
// The second key of a sequence: a destination, or nothing at all, so
// a mistyped `g n` does not open the add menu.
if (isGoToKey(key)) return { type: "go", key };
if (key !== "g") return null;
}
if (key === "g") {
pendingSince = now;
return "pending";
}
if (key === "n") return { type: "capture" };
if (key === "?") return { type: "help" };
return null;
}
return {
read,
/** Forget a pending `g`. */
reset() {
pendingSince = null;
},
};
}
/** What this session may open, as the navigation offers it. */
export interface ShortcutOffer {
/** The hrefs of the visible nav destinations. */
navHrefs: ReadonlyArray<string>;
/** Where the Settings entry lands, or null when there is none. */
settingsHref: string | null;
}
/**
* The href `g` + `key` opens, or null when the destination is not offered: a
* switched-off module, a Coach without an AI provider, a page a shared record
* does not cover. Read from the same lists the navigation renders, so a
* shortcut never opens a door the sidebar does not show.
*/
export function resolveGoTo(key: GoToKey, offer: ShortcutOffer): string | null {
const shortcut = GO_TO_SHORTCUTS.find((s) => s.key === key);
if (!shortcut) return null;
if (shortcut.destination === "settings") return offer.settingsHref;
return offer.navHrefs.includes(shortcut.destination)
? shortcut.destination
: null;
}