142 lines
4.3 KiB
TypeScript
142 lines
4.3 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useRef } from 'react';
|
||
import { X } from 'lucide-react';
|
||
|
||
interface KeyboardShortcutsModalProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
const shortcuts = [
|
||
{ key: 'R', description: 'Rectangle tool' },
|
||
{ key: 'S', description: 'Span tool' },
|
||
{ key: 'L', description: 'Line tool' },
|
||
{ key: 'D', description: 'Delete tool' },
|
||
{ key: 'T', description: 'Toggle theme (light/dark)' },
|
||
{ key: 'Esc', description: 'Deselect tool / cancel action' },
|
||
{ key: 'Del / ⌫', description: 'Delete selected annotation' },
|
||
{ key: 'Enter', description: 'Edit selected span' },
|
||
{ key: '1–6', description: 'Quick-assign span label (during span creation)' },
|
||
{ 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) {
|
||
const dialogRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Escape / ? key handler
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const handleKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape' || e.key === '?') {
|
||
onClose();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', handleKey);
|
||
return () => window.removeEventListener('keydown', handleKey);
|
||
}, [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;
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||
onClick={onClose}
|
||
>
|
||
<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"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2
|
||
id="keyboard-shortcuts-title"
|
||
className="text-sm font-semibold text-foreground"
|
||
>
|
||
Keyboard Shortcuts
|
||
</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<table className="w-full text-xs">
|
||
<tbody>
|
||
{shortcuts.map(({ key, description }) => (
|
||
<tr key={key} className="border-b border-border last:border-0">
|
||
<td className="py-1.5 pr-4 font-mono">
|
||
<kbd className="px-1.5 py-0.5 bg-secondary rounded text-foreground font-mono text-[11px]">
|
||
{key}
|
||
</kbd>
|
||
</td>
|
||
<td className="py-1.5 text-muted-foreground">{description}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
|
||
<p className="mt-4 text-[10px] text-muted-foreground text-center">
|
||
Press <kbd className="px-1 py-0.5 bg-secondary rounded font-mono">?</kbd> or <kbd className="px-1 py-0.5 bg-secondary rounded font-mono">Esc</kbd> to close
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|