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>
@@ -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;
}