feat(timeline): add keyboard navigation with shortcut overlay
- Arrow left/right scrolls the timeline by 1 day (Shift: 1 week) - Delete/Backspace deletes selected allocations - ? toggles a keyboard shortcut overlay - Floating ? button in bottom-right corner provides persistent access - (Del) hint added to the FloatingActionBar delete button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface UseTimelineKeyboardOptions {
|
||||
scrollContainerRef: RefObject<HTMLElement | null>;
|
||||
cellWidth: number;
|
||||
selectedAllocationIds: string[];
|
||||
onDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
export interface UseTimelineKeyboardResult {
|
||||
showShortcuts: boolean;
|
||||
setShowShortcuts: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
function isTypingTarget(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return true;
|
||||
if ((el as HTMLElement).isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useTimelineKeyboard({
|
||||
scrollContainerRef,
|
||||
cellWidth,
|
||||
selectedAllocationIds,
|
||||
onDeleteSelected,
|
||||
}: UseTimelineKeyboardOptions): UseTimelineKeyboardResult {
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
|
||||
// Keep stable refs so the handler closure doesn't need to be re-registered on every render
|
||||
const onDeleteRef = useRef(onDeleteSelected);
|
||||
onDeleteRef.current = onDeleteSelected;
|
||||
const selectedCountRef = useRef(selectedAllocationIds.length);
|
||||
selectedCountRef.current = selectedAllocationIds.length;
|
||||
const cellWidthRef = useRef(cellWidth);
|
||||
cellWidthRef.current = cellWidth;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (isTypingTarget(document.activeElement)) return;
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().includes("MAC");
|
||||
const modKey = isMac ? e.metaKey : e.ctrlKey;
|
||||
const dayPx = cellWidthRef.current;
|
||||
const el = scrollContainerRef.current;
|
||||
|
||||
switch (e.key) {
|
||||
case "?":
|
||||
e.preventDefault();
|
||||
setShowShortcuts((prev) => !prev);
|
||||
break;
|
||||
|
||||
case "ArrowLeft":
|
||||
if (el) {
|
||||
e.preventDefault();
|
||||
el.scrollLeft -= (e.shiftKey ? 7 : 1) * dayPx;
|
||||
}
|
||||
break;
|
||||
|
||||
case "ArrowRight":
|
||||
if (el) {
|
||||
e.preventDefault();
|
||||
el.scrollLeft += (e.shiftKey ? 7 : 1) * dayPx;
|
||||
}
|
||||
break;
|
||||
|
||||
case "Delete":
|
||||
case "Backspace":
|
||||
if (modKey) break; // let browser handle Cmd+Backspace etc.
|
||||
if (selectedCountRef.current > 0) {
|
||||
e.preventDefault();
|
||||
onDeleteRef.current();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [scrollContainerRef]); // stable — uses refs for everything that changes
|
||||
|
||||
return { showShortcuts, setShowShortcuts };
|
||||
}
|
||||
Reference in New Issue
Block a user