candle-annotator/src/components/KeyboardShortcutsModal.tsx

142 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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: '16', 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>
);
}