From 8911201eb84357060132ef23f3f76cd478b0becb Mon Sep 17 00:00:00 2001 From: Usman Abeeb <136495186+therealbibson@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:53:23 +0100 Subject: [PATCH] fix(components): portal keyboard shortcuts overlay out of the blurred header Closes #658 --- .../KeyboardShortcutsModal.portal.test.tsx | 196 ++++++++++++++++++ src/components/KeyboardShortcutsModal.tsx | 79 +++++-- 2 files changed, 258 insertions(+), 17 deletions(-) create mode 100644 src/__tests__/KeyboardShortcutsModal.portal.test.tsx diff --git a/src/__tests__/KeyboardShortcutsModal.portal.test.tsx b/src/__tests__/KeyboardShortcutsModal.portal.test.tsx new file mode 100644 index 00000000..77572414 --- /dev/null +++ b/src/__tests__/KeyboardShortcutsModal.portal.test.tsx @@ -0,0 +1,196 @@ +/** + * Unit tests for the KeyboardShortcutsModal overlay container. + * + * Covers the fix for the header `?` button appearing to "do nothing": the + * overlay is `fixed inset-0`, but it is mounted from the header, which sets a + * `backdrop-blur`, making the header a containing block for `position: fixed` + * descendants. That confined the overlay to the header box instead of the + * viewport, so it must be portalled to `document.body`. + * + * Covers: + * - the dialog renders outside the host that mounted the trigger (portal) + * - the dialog is attached to `document.body` + * - categories render in a stable order (Navigation, Invoices, Payments, General) + * - each section's `aria-labelledby` resolves to a real, DOM-id-safe element + * - the close button still dismisses the overlay + */ + +import React from "react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import HeaderShortcutsButton from "@/components/HeaderShortcutsButton"; +import KeyboardShortcutsModal from "@/components/KeyboardShortcutsModal"; +import { + ShortcutRegistryProvider, + useRegisterShortcuts, +} from "@/context/ShortcutRegistry"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => "/", + useSearchParams: () => new URLSearchParams(), +})); + +function renderWithRegistry(ui: React.ReactElement) { + return render({ui}); +} + +function openViaButton() { + act(() => { + screen.getByRole("button", { name: /keyboard shortcuts/i }).click(); + }); +} + +describe("KeyboardShortcutsModal overlay container", () => { + test("renders the dialog outside the host that mounted the header button", () => { + const { container } = renderWithRegistry( +
+ +
+ ); + + expect(screen.queryByRole("dialog")).toBeNull(); + openViaButton(); + + const dialog = screen.getByRole("dialog"); + + // The overlay must not live inside the header host — that is exactly what + // broke `fixed inset-0` under the header's `backdrop-blur`. + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(document.body.contains(dialog)).toBe(true); + expect(dialog.parentElement).toBe(document.body); + }); + + test("keeps the ? button in the header while the overlay is portalled out", () => { + const { container } = renderWithRegistry( +
+ +
+ ); + + openViaButton(); + + expect( + container.querySelector('[aria-label="Show keyboard shortcuts"]') + ).not.toBeNull(); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + test("close button still dismisses the portalled overlay", () => { + renderWithRegistry(); + + openViaButton(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + act(() => { + screen.getByRole("button", { name: /close keyboard shortcuts/i }).click(); + }); + + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); + +describe("KeyboardShortcutsModal category grouping", () => { + function Registrar() { + useRegisterShortcuts([ + { + id: "test:general", + keys: ["X"], + description: "General entry", + group: "General", + handler: () => {}, + }, + { + id: "test:payments", + keys: ["P"], + description: "Payments entry", + group: "Payments", + handler: () => {}, + }, + { + id: "test:navigation", + keys: ["Y"], + description: "Navigation entry", + group: "Navigation", + handler: () => {}, + }, + { + id: "test:invoices", + keys: ["I"], + description: "Invoices entry", + group: "Invoices", + handler: () => {}, + }, + { + id: "test:extra", + keys: ["Z"], + description: "Extras entry", + group: "Zebra Extras", + handler: () => {}, + }, + ]); + return null; + } + + function renderGrouped() { + return renderWithRegistry( + <> + + {}} /> + + ); + } + + test("leads with the documented categories and keeps General last", () => { + renderGrouped(); + + const headings = screen + .getAllByRole("heading", { level: 3 }) + .map((h) => h.textContent); + + expect(headings[0]).toBe("Navigation"); + expect(headings[1]).toBe("Invoices"); + expect(headings[2]).toBe("Payments"); + expect(headings[headings.length - 1]).toBe("General"); + // Uncategorised extras sort between the lead categories and General. + expect(headings).toContain("Zebra Extras"); + expect(headings.indexOf("Zebra Extras")).toBeLessThan( + headings.indexOf("General") + ); + }); + + test("section aria-labelledby resolves to a DOM-id-safe heading", () => { + renderGrouped(); + + const sections = Array.from( + document.body.querySelectorAll("section[aria-labelledby]") + ); + expect(sections.length).toBeGreaterThan(0); + + for (const section of sections) { + const id = section.getAttribute("aria-labelledby")!; + // Multi-word categories must not produce an id with whitespace. + expect(id).toMatch(/^kbd-group-[a-z0-9-]+$/); + expect(id.split(/\s+/)).toHaveLength(1); + const heading = document.getElementById(id); + expect(heading).not.toBeNull(); + expect(section.contains(heading)).toBe(true); + } + + // `Zebra Extras` slugifies rather than emitting an unusable space. + expect(document.getElementById("kbd-group-zebra-extras")).not.toBeNull(); + }); + + test("renders every registered entry so nothing is silently dropped", () => { + renderGrouped(); + + for (const label of [ + "Navigation entry", + "Invoices entry", + "Payments entry", + "Zebra Extras entry", + "General entry", + ]) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); +}); diff --git a/src/components/KeyboardShortcutsModal.tsx b/src/components/KeyboardShortcutsModal.tsx index 9a276e93..ebfb9fd9 100644 --- a/src/components/KeyboardShortcutsModal.tsx +++ b/src/components/KeyboardShortcutsModal.tsx @@ -1,6 +1,7 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; import FocusTrap from "@/components/FocusTrap"; import { useShortcutRegistry, type ShortcutDefinition } from "@/context/ShortcutRegistry"; @@ -10,32 +11,59 @@ interface Props { // ── Helpers ─────────────────────────────────────────────────────────────────── +/** Fallback category for shortcuts that do not declare a `group`. */ +const GENERAL_GROUP = "General"; + +/** + * Categories the reference overlay leads with, in this order. Anything the + * registry registers under another category is listed next (alphabetically), + * and `General` is always last so it never pushes real categories down. + */ +const LEAD_CATEGORIES = ["Navigation", "Invoices", "Payments"] as const; + +/** Turn a category label into a DOM-id-safe fragment (`Payments & Tips` → `payments-tips`). */ +function groupDomId(group: string): string { + const slug = group + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return `kbd-group-${slug || "group"}`; +} + /** - * Group shortcuts by their `group` field. - * Groups are returned in the order they first appear in the registry, - * with "General" always first when present. + * Group shortcuts by their `group` field and return them in a stable order: + * the categories above first, then any other category alphabetically, then + * `General` last. Ordering is derived from the data only, so the overlay never + * reshuffles between renders. */ function groupShortcuts( shortcuts: ShortcutDefinition[], -): Array<{ group: string; entries: ShortcutDefinition[] }> { +): Array<{ group: string; id: string; entries: ShortcutDefinition[] }> { const map = new Map(); - // Always seed General first so it stays at the top - map.set("General", []); - for (const s of shortcuts) { // Hide internal chord-activation entries from the overlay if (s.id === "global:g-chord") continue; - const group = s.group ?? "General"; + const group = s.group?.trim() || GENERAL_GROUP; if (!map.has(group)) map.set(group, []); map.get(group)!.push(s); } - // Remove empty groups + const rank = (group: string): number => { + const lead = LEAD_CATEGORIES.indexOf(group as (typeof LEAD_CATEGORIES)[number]); + if (lead !== -1) return lead; + if (group === GENERAL_GROUP) return Number.MAX_SAFE_INTEGER; + return LEAD_CATEGORIES.length + 1; + }; + return Array.from(map.entries()) .filter(([, entries]) => entries.length > 0) - .map(([group, entries]) => ({ group, entries })); + .sort(([a], [b]) => { + const diff = rank(a) - rank(b); + return diff !== 0 ? diff : a.localeCompare(b); + }) + .map(([group, entries]) => ({ group, id: groupDomId(group), entries })); } // ── Kbd chip ────────────────────────────────────────────────────────────────── @@ -54,20 +82,36 @@ function KbdKey({ label }: { label: string }) { * KeyboardShortcutsModal * * A help overlay that lists **all shortcuts registered via ShortcutRegistry**. - * Shortcuts are grouped by their `group` field (defaults to "General"). + * Shortcuts are grouped by their `group` field (defaults to "General") and the + * categories lead in a stable order (Navigation, Invoices, Payments, …, General). * * Triggered by pressing `?` outside text inputs, or clicking the `?` icon in * the header. Closed by pressing Escape (handled in useKeyboardShortcuts) or * clicking the backdrop / close button. * + * The overlay is rendered through a portal into `document.body`. It is mounted + * from the header (`HeaderShortcutsButton` → `Navbar`), and that header sets a + * `backdrop-blur`, which makes the header a containing block for `position: + * fixed` descendants. Rendering in place confined the `fixed inset-0` overlay + * to the header box instead of the viewport, so the dialog appeared clipped and + * the backdrop never covered the page. + * * Components register shortcuts with `useRegisterShortcuts` — the overlay * automatically reflects additions and removals without any manual wiring. */ export default function KeyboardShortcutsModal({ onClose }: Props) { const { shortcuts } = useShortcutRegistry(); const grouped = useMemo(() => groupShortcuts(shortcuts), [shortcuts]); + const [mounted, setMounted] = useState(false); - return ( + useEffect(() => { + setMounted(true); + }, []); + + // `createPortal` needs a live DOM; skip the server/first paint. + if (!mounted || typeof document === "undefined") return null; + + return createPortal(
) : (
- {grouped.map(({ group, entries }) => ( -
+ {grouped.map(({ group, id, entries }) => ( +

{group} @@ -193,6 +237,7 @@ export default function KeyboardShortcutsModal({ onClose }: Props) {

- + , + document.body, ); }