code-review-fix task 11.2: add role=dialog aria-modal and focus trapping to KeyboardShortcutsModal
This commit is contained in:
parent
6f7e3451a2
commit
9e4ad02f44
1 changed files with 66 additions and 2 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
interface KeyboardShortcutsModalProps {
|
interface KeyboardShortcutsModalProps {
|
||||||
|
|
@ -21,7 +21,19 @@ const shortcuts = [
|
||||||
{ key: '?', description: 'Show this help' },
|
{ key: '?', description: 'Show this help' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTORS = [
|
||||||
|
'a[href]',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
export default function KeyboardShortcutsModal({ open, onClose }: KeyboardShortcutsModalProps) {
|
export default function KeyboardShortcutsModal({ open, onClose }: KeyboardShortcutsModalProps) {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Escape / ? key handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
|
@ -33,6 +45,49 @@ export default function KeyboardShortcutsModal({ open, onClose }: KeyboardShortc
|
||||||
return () => window.removeEventListener('keydown', handleKey);
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// Focus trapping
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
|
||||||
|
// Move focus into the dialog on open
|
||||||
|
const firstFocusable = dialog.querySelector<HTMLElement>(FOCUSABLE_SELECTORS);
|
||||||
|
firstFocusable?.focus();
|
||||||
|
|
||||||
|
const handleTabKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
|
||||||
|
const focusableElements = Array.from(
|
||||||
|
dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
|
||||||
|
).filter((el) => !el.closest('[disabled]'));
|
||||||
|
|
||||||
|
if (focusableElements.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = focusableElements[0];
|
||||||
|
const last = focusableElements[focusableElements.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.addEventListener('keydown', handleTabKey);
|
||||||
|
return () => dialog.removeEventListener('keydown', handleTabKey);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -41,11 +96,20 @@ export default function KeyboardShortcutsModal({ open, onClose }: KeyboardShortc
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="keyboard-shortcuts-title"
|
||||||
className="bg-background border border-border rounded-lg shadow-xl w-96 p-5"
|
className="bg-background border border-border rounded-lg shadow-xl w-96 p-5"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-sm font-semibold text-foreground">Keyboard Shortcuts</h2>
|
<h2
|
||||||
|
id="keyboard-shortcuts-title"
|
||||||
|
className="text-sm font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue