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:
2026-04-09 13:20:32 +02:00
parent 05f6eba5d8
commit fa54ef4cbd
4 changed files with 184 additions and 2 deletions
+88
View File
@@ -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 };
}