From 4fc5aeabcfef937b6f55c27052bb4365a30be302 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 31 Aug 2026 15:58:32 +0200 Subject: [PATCH] fix(timeline): let the keyboard activate a lane pill, not just the pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every lane pill renders with `role="button"` and `tabIndex={0}`, so it is reachable by Tab and announced as activatable. Nothing answered: the only selection path was `onPointerDown`, and the file had no `onKeyDown` at all. The consequence is not that a keystroke was missing — it is that focus led nowhere. Delete, copy/paste and the inspector all act on `tl.selection`, so a keyboard user could reach a zoom, speed, trim, annotation or full-camera pill and then do nothing with any of them. Enter and Space now select, and Shift+Enter adds to the selection, matching shift-click. Every other key is left alone: the editor binds single letters (Z, T, D…) on window, and swallowing them here would disable the shortcuts while a pill has focus. `e.nativeEvent.stopPropagation()`, not just the synthetic one. The shell's shortcut handler listens on WINDOW, above React's root container, and Space is bound to play/pause there — stopping only the synthetic event would select the pill and toggle playback in the same keystroke. A test pins that, and pins that an ignored key still reaches the window listener. Selection is now one named callback (`selectPill`) that both doors call, rather than `tl.selectRegion` inlined in the pointer path. That is what stops the two drifting again, and it is the single line a new pill kind has to touch. --- .../v4/V4Timeline.geometry.test.tsx | 54 +++++++++++++++++++ src/components/ai-edition/v4/V4Timeline.tsx | 29 +++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 8e515568c..70e20b4d5 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -194,6 +194,60 @@ describe("V4Timeline lane pills", () => { }); }); +describe("V4Timeline lane pill keyboard", () => { + // A pill carries `role="button"` and `tabIndex={0}`, so it is reachable by Tab and + // announced as activatable. Selection was pointer-only, which meant a keyboard user + // could focus a region and then reach nothing that acts on a selection — Delete, + // copy/paste and the inspector all key off `tl.selection`. + it("selects the focused pill on Enter", () => { + const { pill, tl } = renderTimeline(); + fireEvent.keyDown(pill, { key: "Enter" }); + expect(tl.selectRegion).toHaveBeenCalledWith("annotation", "ann1", { additive: false }); + }); + + it("selects it on Space too, the other key a button answers to", () => { + const { pill, tl } = renderTimeline(); + fireEvent.keyDown(pill, { key: " " }); + expect(tl.selectRegion).toHaveBeenCalledWith("annotation", "ann1", { additive: false }); + }); + + it("adds to the selection when Shift is held, matching shift-click", () => { + const { pill, tl } = renderTimeline(); + fireEvent.keyDown(pill, { key: "Enter", shiftKey: true }); + expect(tl.selectRegion).toHaveBeenCalledWith("annotation", "ann1", { additive: true }); + }); + + it("leaves every other key to the shell's shortcut handler", () => { + // The editor binds single letters (Z adds a zoom, T a trim, D deletes). Swallowing + // them here would silently disable every shortcut while a pill has focus. + const { pill, tl } = renderTimeline(); + for (const key of ["z", "t", "d", "Escape", "ArrowRight"]) { + fireEvent.keyDown(pill, { key }); + } + expect(tl.selectRegion).not.toHaveBeenCalled(); + }); + + it("stops Enter and Space reaching the window listener", () => { + // Space is bound to play/pause on WINDOW, above React's root container. Without + // stopping the NATIVE event the same keystroke would select the pill and toggle + // playback; the synthetic `stopPropagation` alone does not reach that far. + const onWindowKey = vi.fn(); + window.addEventListener("keydown", onWindowKey); + try { + const { pill } = renderTimeline(); + fireEvent.keyDown(pill, { key: " " }); + fireEvent.keyDown(pill, { key: "Enter" }); + expect(onWindowKey).not.toHaveBeenCalled(); + + // A key the pill ignores still gets there, or the shortcuts would be dead. + fireEvent.keyDown(pill, { key: "z" }); + expect(onWindowKey).toHaveBeenCalledTimes(1); + } finally { + window.removeEventListener("keydown", onWindowKey); + } + }); +}); + describe("V4Timeline create-from-toolbar", () => { // The button asks for a DURATION worth a fixed number of pixels at the current // zoom, so the pill you get is always the same size on screen — which is what diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index f6c94e2ca..8ee1f6a01 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -660,11 +660,18 @@ export function V4Timeline({ // Drag a lane pill to move it (mode "move", keeps duration) or resize one // edge (mode "l"/"r"). Zoom/speed/annotation are timeline-ms; trims map // back to source-seconds through their carrying clip. + const selectPill = useCallback( + (pill: LanePill, additive: boolean) => { + tl.selectRegion(pill.kind, pill.id, { additive }); + }, + [tl], + ); + const startPillDrag = useCallback( (e: ReactPointerEvent, pill: LanePill, dragMode: "move" | "l" | "r") => { e.preventDefault(); e.stopPropagation(); - tl.selectRegion(pill.kind, pill.id, { additive: e.shiftKey }); + selectPill(pill, e.shiftKey); // Scale drag deltas against the canvas (full zoomed timeline) width, so a // drag tracks the cursor exactly regardless of padding, scrollbar or zoom. const el = canvasRef.current; @@ -776,7 +783,7 @@ export function V4Timeline({ window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }, - [tl, total, clips, pxPerSec], + [tl, selectPill, total, clips, pxPerSec], ); const startNavDrag = useCallback( @@ -1158,6 +1165,24 @@ export function V4Timeline({ : {}), }} onPointerDown={seg.interactive ? (e) => startPillDrag(e, p, "move") : undefined} + // A pill is focusable and announced as a button, so Enter and Space have to + // activate it — without this a keyboard user could tab to a region and then + // reach nothing that acts on a selection: Delete, copy/paste, the inspector. + // + // `nativeEvent.stopPropagation()`, not just the synthetic one: the editor + // shell listens on WINDOW, above React's root container, and Space is bound + // to play/pause there. Stopping only the synthetic event would select the + // pill and toggle playback in the same keystroke. + onKeyDown={ + seg.interactive + ? (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + e.nativeEvent.stopPropagation(); + selectPill(p, e.shiftKey); + } + : undefined + } title={p.label} > {seg.interactive ? (