import { useEffect, useId, useMemo, useRef } from 'react'; import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; import { DEFAULT_VIEWER_SHORTCUTS, formatShortcutChord, groupViewerShortcuts, } from '../../utils/viewerKeyboardShortcuts'; import type { ShortcutPlatform, ViewerShortcutDefinition, } from '../../utils/viewerKeyboardShortcuts'; export interface ViewerKeyboardHelpDialogProps { readonly open: boolean; readonly onClose: () => void; readonly shortcuts?: readonly ViewerShortcutDefinition[]; readonly title?: string; readonly description?: string; readonly platform?: ShortcutPlatform; } const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); export function ViewerKeyboardHelpDialog({ open, onClose, shortcuts = DEFAULT_VIEWER_SHORTCUTS, title = 'Keyboard shortcuts', description = 'Use these commands when the 3D viewer has focus. Form fields and toolbar buttons keep their native keyboard behavior.', platform, }: ViewerKeyboardHelpDialogProps) { const titleId = useId(); const descriptionId = useId(); const dialogRef = useRef(null); const closeButtonRef = useRef(null); const groups = useMemo(() => groupViewerShortcuts(shortcuts), [shortcuts]); useEffect(() => { if (!open || typeof window === 'undefined') { return undefined; } const previouslyFocusedElement = document.activeElement; const focusTimer = window.setTimeout(() => { closeButtonRef.current?.focus(); }, 0); return () => { window.clearTimeout(focusTimer); if ( previouslyFocusedElement instanceof HTMLElement && document.contains(previouslyFocusedElement) ) { previouslyFocusedElement.focus(); } }; }, [open]); if (!open) { return null; } const handleDialogKeyDown = (event: ReactKeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose(); return; } if (event.key !== 'Tab') { return; } const dialog = dialogRef.current; if (!dialog) { return; } const focusableElements = getFocusableElements(dialog); const firstFocusableElement = focusableElements[0]; const lastFocusableElement = focusableElements[focusableElements.length - 1]; if (!firstFocusableElement || !lastFocusableElement) { event.preventDefault(); return; } if (event.shiftKey && document.activeElement === firstFocusableElement) { event.preventDefault(); lastFocusableElement.focus(); return; } if (!event.shiftKey && document.activeElement === lastFocusableElement) { event.preventDefault(); firstFocusableElement.focus(); } }; return (
{ if (event.target === event.currentTarget) { onClose(); } }} role="presentation" >

{title}

{description}

{groups.length === 0 ? (

No keyboard shortcuts are registered for this viewer state.

) : (
{groups.map((group) => (

{group.label}

{group.shortcuts.map((shortcut) => (
{shortcut.label}
{shortcut.description ? (
{shortcut.description}
) : null}
{shortcut.chords.map((chord, chordIndex) => ( {formatShortcutChord(chord, platform)} ))}
))}
))}
)}
); } function getFocusableElements(container: HTMLElement): HTMLElement[] { return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( (element) => { if (element.getAttribute('aria-hidden') === 'true') { return false; } if (typeof window === 'undefined') { return true; } const style = window.getComputedStyle(element); return style.visibility !== 'hidden' && style.display !== 'none'; }, ); }