Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/KEYBOARD_SHORTCUTS.md
Original file line number Diff line number Diff line change
@@ -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
<AppShellLayout>
<Dashboard />
</AppShellLayout>
```

## 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.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 21 additions & 1 deletion src/components/shell/AppShellLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand All @@ -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(
<AppShellLayout>
Expand Down
46 changes: 45 additions & 1 deletion src/components/shell/AppShellLayout.tsx
Original file line number Diff line number Diff line change
@@ -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<AppShellLayoutProps> = ({ 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<HTMLAnchorElement>) => {
const mainContent = document.getElementById('main-content')

Expand All @@ -33,6 +50,33 @@ export const AppShellLayout: React.FC<AppShellLayoutProps> = ({ children }) => {
>
{children}
</main>
<KeyboardShortcutsOverlay
isOpen={isShortcutsOpen}
onClose={() => setIsShortcutsOpen(false)}
/>
</div>
)
}

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
)
}
122 changes: 122 additions & 0 deletions src/components/shell/KeyboardShortcutsOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => (
<nav aria-label="Main navigation">
<a href="/marketplace">Marketplace</a>
</nav>
),
}))

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(
<AppShellLayout>
<h1>Dashboard</h1>
</AppShellLayout>,
)

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(
<AppShellLayout>
<label htmlFor="search">Search</label>
<input id="search" />
</AppShellLayout>,
)

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(
<AppShellLayout>
<button type="button">Trigger</button>
</AppShellLayout>,
)

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(
<AppShellLayout>
<h1>Dashboard</h1>
</AppShellLayout>,
)

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(
<AppShellLayout>
<h1>Dashboard</h1>
</AppShellLayout>,
)

fireEvent.keyDown(window, { key: '?' })

expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument()
expect(screen.getByTestId('dialog-backdrop')).not.toHaveClass('animate-in')
})
})
92 changes: 92 additions & 0 deletions src/components/shell/KeyboardShortcutsOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(null)
const groups = getShortcutGroups()

return (
<Dialog
isOpen={isOpen}
onClose={onClose}
labelledById={titleId}
describedById={descriptionId}
initialFocusRef={closeButtonRef}
backdropClassName="bg-black/75 p-4 backdrop-blur-md"
className="w-full max-w-2xl"
>
<section className="rounded-2xl border border-[rgba(0,212,255,0.2)] bg-[#0d1117] text-white shadow-[0_0_60px_rgba(0,212,255,0.15)]">
<header className="flex items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
<div className="flex min-w-0 items-center gap-3">
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-[rgba(0,212,255,0.25)] bg-[rgba(0,212,255,0.08)] text-[#00d4ff]"
aria-hidden="true"
>
<Keyboard size={18} />
</span>
<div className="min-w-0">
<h2 id={titleId} className="text-lg font-semibold leading-tight">
Keyboard shortcuts
</h2>
<p id={descriptionId} className="mt-1 text-sm text-white/50">
Global app-shell shortcuts and dialog controls.
</p>
</div>
</div>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
aria-label="Close keyboard shortcuts"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-white/60 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-[#00d4ff]"
>
<X size={16} aria-hidden="true" />
</button>
</header>

<div className="grid gap-5 px-5 py-5 md:grid-cols-3">
{Object.entries(groups).map(([area, shortcuts]) => (
<section key={area} aria-labelledby={`shortcuts-${area.toLowerCase()}`}>
<h3
id={`shortcuts-${area.toLowerCase()}`}
className="text-xs font-semibold uppercase tracking-widest text-white/40"
>
{area}
</h3>
<ul className="mt-3 space-y-3">
{shortcuts.map((shortcut) => (
<li key={shortcut.id} className="rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex flex-wrap items-center gap-1.5" aria-label={`${shortcut.label}: ${shortcut.keys.join(' plus ')}`}>
{shortcut.keys.map((key) => (
<kbd
key={`${shortcut.id}-${key}`}
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs font-semibold text-white/80"
>
{key}
</kbd>
))}
</div>
<p className="mt-2 text-sm font-medium text-white/85">{shortcut.label}</p>
<p className="mt-1 text-xs leading-5 text-white/45">{shortcut.description}</p>
</li>
))}
</ul>
</section>
))}
</div>
</section>
</Dialog>
)
}
4 changes: 3 additions & 1 deletion src/hooks/useCommandPalette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
}
Expand Down
7 changes: 7 additions & 0 deletions src/lib/shortcuts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
COMMAND_PALETTE_SHORTCUTS,
KEYBOARD_SHORTCUTS,
getShortcutGroups,
matchesKeyboardShortcut,
} from './shortcutRegistry'
export type { KeyboardShortcut, ShortcutArea } from './shortcutRegistry'
Loading