feat: add Shift+F10 context menu trigger and keyboard navigation
This commit is contained in:
65
src/App.tsx
65
src/App.tsx
@@ -280,6 +280,7 @@ function App() {
|
|||||||
const menuItemRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({});
|
const menuItemRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({});
|
||||||
const kineticDragActiveRef = useRef(false);
|
const kineticDragActiveRef = useRef(false);
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contextMenuTriggerRef = useRef<Element | null>(null);
|
||||||
const tabScrollRef = useRef<HTMLDivElement>(null);
|
const tabScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const sidebarItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
const sidebarItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
@@ -332,6 +333,7 @@ function App() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contextMenuTriggerRef.current = document.activeElement;
|
||||||
setContextMenu({ x, y, selectedText });
|
setContextMenu({ x, y, selectedText });
|
||||||
setSelectionRects(rects);
|
setSelectionRects(rects);
|
||||||
}
|
}
|
||||||
@@ -365,6 +367,19 @@ function App() {
|
|||||||
if (!contextMenu) setSelectionRects(prev => prev.length > 0 ? [] : prev);
|
if (!contextMenu) setSelectionRects(prev => prev.length > 0 ? [] : prev);
|
||||||
}, [contextMenu]);
|
}, [contextMenu]);
|
||||||
|
|
||||||
|
// Auto-focus first menuitem when context menu opens; restore focus on close
|
||||||
|
useEffect(() => {
|
||||||
|
if (contextMenu) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const firstItem = contextMenuRef.current?.querySelector<HTMLElement>('[role="menuitem"]');
|
||||||
|
firstItem?.focus();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
(contextMenuTriggerRef.current as HTMLElement)?.focus();
|
||||||
|
contextMenuTriggerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [contextMenu]);
|
||||||
|
|
||||||
// Clamp context menu within window bounds after render
|
// Clamp context menu within window bounds after render
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const menu = contextMenuRef.current;
|
const menu = contextMenuRef.current;
|
||||||
@@ -639,10 +654,27 @@ function App() {
|
|||||||
else if (e.shiftKey && e.key === 'S') { e.preventDefault(); setShowSidebar(s => !s); }
|
else if (e.shiftKey && e.key === 'S') { e.preventDefault(); setShowSidebar(s => !s); }
|
||||||
} else if (e.key === 'F11') { e.preventDefault(); setFocusMode(f => !f); }
|
} else if (e.key === 'F11') { e.preventDefault(); setFocusMode(f => !f); }
|
||||||
else if (e.key === 'Escape') { setShowSearch(false); setShowSidebar(false); }
|
else if (e.key === 'Escape') { setShowSearch(false); setShowSidebar(false); }
|
||||||
|
else if (e.key === 'F10' && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
contextMenuTriggerRef.current = document.activeElement;
|
||||||
|
const zf = uiZoom / 100;
|
||||||
|
const focused = document.activeElement as HTMLElement;
|
||||||
|
let x: number, y: number;
|
||||||
|
if (focused && focused !== document.body) {
|
||||||
|
const rect = focused.getBoundingClientRect();
|
||||||
|
x = (rect.left + rect.width / 2) / zf;
|
||||||
|
y = (rect.top + rect.height / 2) / zf;
|
||||||
|
} else {
|
||||||
|
x = window.innerWidth / 2 / zf;
|
||||||
|
y = window.innerHeight / 2 / zf;
|
||||||
|
}
|
||||||
|
const selectedText = window.getSelection()?.toString() || '';
|
||||||
|
setContextMenu({ x, y, selectedText });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [handleOpenDialog, closeTab, activeTabId, closeWindow]);
|
}, [handleOpenDialog, closeTab, activeTabId, closeWindow, uiZoom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
@@ -874,6 +906,35 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Context menu keyboard navigation (WAI-ARIA menu pattern)
|
||||||
|
const handleContextMenuKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
const menu = contextMenuRef.current;
|
||||||
|
if (!menu) return;
|
||||||
|
const items = menu.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
||||||
|
if (!items.length) return;
|
||||||
|
|
||||||
|
const currentIndex = Array.from(items).indexOf(document.activeElement as HTMLElement);
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
||||||
|
items[next]?.focus();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
const prev = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||||
|
items[prev]?.focus();
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault();
|
||||||
|
items[0]?.focus();
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault();
|
||||||
|
items[items.length - 1]?.focus();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-focus first menuitem when a dropdown opens
|
// Auto-focus first menuitem when a dropdown opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (menuOpen) {
|
if (menuOpen) {
|
||||||
@@ -1152,7 +1213,7 @@ function App() {
|
|||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
<motion.div ref={contextMenuRef} className="context-menu" role="menu" style={{ left: contextMenu.x, top: contextMenu.y }} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={smoothTransition}>
|
<motion.div ref={contextMenuRef} className="context-menu" role="menu" onKeyDown={handleContextMenuKeyDown} style={{ left: contextMenu.x, top: contextMenu.y }} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={smoothTransition}>
|
||||||
{contextMenu.selectedText && (
|
{contextMenu.selectedText && (
|
||||||
<>
|
<>
|
||||||
<button className="context-menu-item" role="menuitem" onClick={() => { navigator.clipboard.writeText(contextMenu.selectedText); setContextMenu(null); }}>Copy</button>
|
<button className="context-menu-item" role="menuitem" onClick={() => { navigator.clipboard.writeText(contextMenu.selectedText); setContextMenu(null); }}>Copy</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user