diff --git a/docs/KEYBOARD_SHORTCUTS.md b/docs/KEYBOARD_SHORTCUTS.md new file mode 100644 index 00000000..a8cab23f --- /dev/null +++ b/docs/KEYBOARD_SHORTCUTS.md @@ -0,0 +1,38 @@ +# Keyboard Shortcuts + +The authenticated app shell exposes a keyboard-shortcuts overlay so users can +discover global commands without leaving the current page. + +## Usage + +`AppShellLayout` mounts `KeyboardShortcutsOverlay` and listens for `?` +(`Shift+/`) on `window`. The shortcut is ignored while focus is inside an +`input`, `textarea`, `select`, or `contenteditable` element. + +```tsx + + + +``` + +## Shortcut Registry + +Shortcut metadata lives in `src/lib/shortcuts/shortcutRegistry.ts`. Add new +global shortcuts there first, then wire their behavior in the relevant shell, +hook, or dialog code. This keeps the help overlay and real bindings aligned. + +Current groups: + +- Global: command palette and shortcut help. +- Navigation: skip-link behavior. +- Dialogs: Escape-to-close behavior. + +## Accessibility + +- The overlay uses the shared `Dialog` primitive for focus trapping, scroll lock, + Escape-to-close, and focus restoration. +- The close button receives initial focus when the overlay opens. +- `prefers-reduced-motion: reduce` disables dialog animation classes through the + shared primitive. +- Shortcuts are grouped in labelled sections and rendered as semantic keyboard + tokens with readable descriptions. diff --git a/docs/README.md b/docs/README.md index c258e8aa..1f88e929 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ Welcome to the CommitLabs documentation index. This document serves as a single - **[CIRCULAR_DEPS.md](CIRCULAR_DEPS.md)** — Circular-dependency check with madge: config, the blocking CI gate, and how to break a reported cycle. - **[MODAL_SYSTEM.md](MODAL_SYSTEM.md)** — Architecture of the modal managers, custom context triggers, and backdrop animations. - **[TOAST_SYSTEM.md](TOAST_SYSTEM.md)** — Toast notification service, status emitters, and action triggers. +- **[KEYBOARD_SHORTCUTS.md](KEYBOARD_SHORTCUTS.md)** — Global shortcut registry, `?` help overlay behavior, and accessibility notes. ## 🚀 Features & User Flows - **[settlement-and-early-exit-flows.md](settlement-and-early-exit-flows.md)** — Eligibility calculations, exit premiums, smart contract validations, and confirmation modal states. diff --git a/src/components/shell/AppShellLayout.test.tsx b/src/components/shell/AppShellLayout.test.tsx index 7aabcedc..7198ca40 100644 --- a/src/components/shell/AppShellLayout.test.tsx +++ b/src/components/shell/AppShellLayout.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { AppShellLayout } from './AppShellLayout' vi.mock('./AppSidebar', () => ({ @@ -10,7 +10,27 @@ vi.mock('./AppSidebar', () => ({ ), })) +function installMatchMedia() { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) +} + describe('AppShellLayout skip link', () => { + beforeEach(() => { + installMatchMedia() + }) + it('renders the skip link before sidebar navigation as the first focus target', () => { const { container } = render( diff --git a/src/components/shell/AppShellLayout.tsx b/src/components/shell/AppShellLayout.tsx index 6908396d..9bda91c5 100644 --- a/src/components/shell/AppShellLayout.tsx +++ b/src/components/shell/AppShellLayout.tsx @@ -1,13 +1,30 @@ 'use client' -import React from 'react' +import React, { useEffect, useState } from 'react' import { AppSidebar } from './AppSidebar' +import { KeyboardShortcutsOverlay } from './KeyboardShortcutsOverlay' export interface AppShellLayoutProps { children: React.ReactNode } export const AppShellLayout: React.FC = ({ children }) => { + const [isShortcutsOpen, setIsShortcutsOpen] = useState(false) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!isKeyboardShortcutHelpEvent(event) || isEditableTarget(event.target)) { + return + } + + event.preventDefault() + setIsShortcutsOpen(true) + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + const handleSkipToMain = (event: React.MouseEvent) => { const mainContent = document.getElementById('main-content') @@ -33,6 +50,33 @@ export const AppShellLayout: React.FC = ({ children }) => { > {children} + setIsShortcutsOpen(false)} + /> ) } + +function isKeyboardShortcutHelpEvent(event: KeyboardEvent) { + if (event.metaKey || event.ctrlKey || event.altKey) { + return false + } + + return event.key === '?' || (event.key === '/' && event.shiftKey) +} + +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) { + return false + } + + const tagName = target.tagName.toLowerCase() + + return ( + tagName === 'input' || + tagName === 'textarea' || + tagName === 'select' || + target.isContentEditable + ) +} diff --git a/src/components/shell/KeyboardShortcutsOverlay.test.tsx b/src/components/shell/KeyboardShortcutsOverlay.test.tsx new file mode 100644 index 00000000..31b473df --- /dev/null +++ b/src/components/shell/KeyboardShortcutsOverlay.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AppShellLayout } from './AppShellLayout' + +vi.mock('./AppSidebar', () => ({ + AppSidebar: () => ( + + ), +})) + +function installMatchMedia(matches = false) { + const listeners = new Set<(event: MediaQueryListEvent) => void>() + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn((_event: string, listener: (event: MediaQueryListEvent) => void) => { + listeners.add(listener) + }), + removeEventListener: vi.fn((_event: string, listener: (event: MediaQueryListEvent) => void) => { + listeners.delete(listener) + }), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) +} + +describe('KeyboardShortcutsOverlay', () => { + beforeEach(() => { + installMatchMedia() + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('opens the shortcuts overlay with ? from the app shell', async () => { + render( + +

Dashboard

+
, + ) + + fireEvent.keyDown(window, { key: '?' }) + + expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument() + expect(screen.getAllByText('Open command palette')).toHaveLength(2) + expect(screen.getByText('Show keyboard shortcuts')).toBeInTheDocument() + expect(screen.getByText('Close overlay or dialog')).toBeInTheDocument() + }) + + it('ignores ? while typing in editable fields', () => { + render( + + + + , + ) + + fireEvent.keyDown(screen.getByLabelText('Search'), { key: '?' }) + + expect(screen.queryByRole('dialog', { name: /keyboard shortcuts/i })).not.toBeInTheDocument() + }) + + it('closes with Escape and restores focus to the original trigger', async () => { + render( + + + , + ) + + const trigger = screen.getByRole('button', { name: 'Trigger' }) + trigger.focus() + + fireEvent.keyDown(window, { key: '?' }) + + const dialog = await screen.findByRole('dialog', { name: /keyboard shortcuts/i }) + expect(dialog).toBeInTheDocument() + + fireEvent.keyDown(document, { key: 'Escape' }) + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: /keyboard shortcuts/i })).not.toBeInTheDocument() + }) + expect(trigger).toHaveFocus() + }) + + it('supports Shift+/ keyboard events for layouts that report slash', async () => { + render( + +

Dashboard

+
, + ) + + fireEvent.keyDown(window, { key: '/', shiftKey: true }) + + expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument() + }) + + it('omits animation classes when reduced motion is preferred', async () => { + installMatchMedia(true) + + render( + +

Dashboard

+
, + ) + + fireEvent.keyDown(window, { key: '?' }) + + expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument() + expect(screen.getByTestId('dialog-backdrop')).not.toHaveClass('animate-in') + }) +}) diff --git a/src/components/shell/KeyboardShortcutsOverlay.tsx b/src/components/shell/KeyboardShortcutsOverlay.tsx new file mode 100644 index 00000000..d9600815 --- /dev/null +++ b/src/components/shell/KeyboardShortcutsOverlay.tsx @@ -0,0 +1,92 @@ +'use client' + +import React, { useId, useRef } from 'react' +import { Keyboard, X } from 'lucide-react' + +import { Dialog } from '@/components/ui/Dialog' +import { getShortcutGroups } from '@/lib/shortcuts' + +export interface KeyboardShortcutsOverlayProps { + isOpen: boolean + onClose: () => void +} + +export function KeyboardShortcutsOverlay({ isOpen, onClose }: KeyboardShortcutsOverlayProps) { + const titleId = useId() + const descriptionId = useId() + const closeButtonRef = useRef(null) + const groups = getShortcutGroups() + + return ( + +
+
+
+ +
+

+ Keyboard shortcuts +

+

+ Global app-shell shortcuts and dialog controls. +

+
+
+ +
+ +
+ {Object.entries(groups).map(([area, shortcuts]) => ( +
+

+ {area} +

+
    + {shortcuts.map((shortcut) => ( +
  • +
    + {shortcut.keys.map((key) => ( + + {key} + + ))} +
    +

    {shortcut.label}

    +

    {shortcut.description}

    +
  • + ))} +
+
+ ))} +
+
+
+ ) +} diff --git a/src/hooks/useCommandPalette.ts b/src/hooks/useCommandPalette.ts index ff1b0526..f1c5ac35 100644 --- a/src/hooks/useCommandPalette.ts +++ b/src/hooks/useCommandPalette.ts @@ -2,6 +2,8 @@ import { useState, useEffect, useCallback } from 'react'; +import { COMMAND_PALETTE_SHORTCUTS, matchesKeyboardShortcut } from '@/lib/shortcuts'; + /** * Manages global command palette open/close state and wires up the * Cmd+K / Ctrl+K keyboard shortcut. @@ -19,7 +21,7 @@ export function useCommandPalette() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Cmd+K (macOS) or Ctrl+K (Windows / Linux) - if (event.key === 'k' && (event.metaKey || event.ctrlKey)) { + if (COMMAND_PALETTE_SHORTCUTS.some((shortcut) => matchesKeyboardShortcut(event, shortcut))) { event.preventDefault(); toggle(); } diff --git a/src/lib/shortcuts/index.ts b/src/lib/shortcuts/index.ts new file mode 100644 index 00000000..b95ebf54 --- /dev/null +++ b/src/lib/shortcuts/index.ts @@ -0,0 +1,7 @@ +export { + COMMAND_PALETTE_SHORTCUTS, + KEYBOARD_SHORTCUTS, + getShortcutGroups, + matchesKeyboardShortcut, +} from './shortcutRegistry' +export type { KeyboardShortcut, ShortcutArea } from './shortcutRegistry' diff --git a/src/lib/shortcuts/shortcutRegistry.ts b/src/lib/shortcuts/shortcutRegistry.ts new file mode 100644 index 00000000..accfd055 --- /dev/null +++ b/src/lib/shortcuts/shortcutRegistry.ts @@ -0,0 +1,85 @@ +export type ShortcutArea = 'Global' | 'Navigation' | 'Dialogs' + +export interface KeyboardShortcut { + id: string + area: ShortcutArea + keys: readonly string[] + label: string + description: string +} + +export const KEYBOARD_SHORTCUTS = [ + { + id: 'command-palette', + area: 'Global', + keys: ['Cmd', 'K'], + label: 'Open command palette', + description: 'Search routes and commitments from anywhere in the app shell.', + }, + { + id: 'command-palette-windows', + area: 'Global', + keys: ['Ctrl', 'K'], + label: 'Open command palette', + description: 'Windows and Linux alternative for opening the command palette.', + }, + { + id: 'keyboard-shortcuts', + area: 'Global', + keys: ['?'], + label: 'Show keyboard shortcuts', + description: 'Open this help overlay when focus is outside editable fields.', + }, + { + id: 'escape-dialog', + area: 'Dialogs', + keys: ['Esc'], + label: 'Close overlay or dialog', + description: 'Dismiss the active dialog and restore focus to the trigger.', + }, + { + id: 'skip-link', + area: 'Navigation', + keys: ['Tab', 'Enter'], + label: 'Skip to main content', + description: 'Focus the skip link and activate it to bypass sidebar navigation.', + }, +] as const satisfies readonly KeyboardShortcut[] + +export const COMMAND_PALETTE_SHORTCUTS = KEYBOARD_SHORTCUTS.filter( + (shortcut) => shortcut.id === 'command-palette' || shortcut.id === 'command-palette-windows', +) + +export function matchesKeyboardShortcut(event: KeyboardEvent, shortcut: KeyboardShortcut) { + const keys = shortcut.keys.map((key) => key.toLowerCase()) + const requiresMeta = keys.includes('cmd') + const requiresCtrl = keys.includes('ctrl') + const requiresShift = keys.includes('shift') + const requiresAlt = keys.includes('alt') + const primaryKey = keys.find( + (key) => !['cmd', 'ctrl', 'shift', 'alt'].includes(key), + ) + + return ( + Boolean(primaryKey) && + event.key.toLowerCase() === primaryKey && + event.metaKey === requiresMeta && + event.ctrlKey === requiresCtrl && + event.shiftKey === requiresShift && + event.altKey === requiresAlt + ) +} + +export function getShortcutGroups(shortcuts: readonly KeyboardShortcut[] = KEYBOARD_SHORTCUTS) { + return shortcuts.reduce>( + (groups, shortcut) => { + groups[shortcut.area].push(shortcut) + return groups + }, + { + Global: [], + Navigation: [], + Dialogs: [], + }, + ) +}