From f517fbd8d4b25cf77e1960e1fff62ac24917bb89 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 21:38:08 +0200 Subject: [PATCH] feat(studio): expose timeline treegrid semantics --- .../src/player/components/Timeline.test.ts | 29 +++++++++-- .../Timeline.virtualization.test.tsx | 12 ++--- .../src/player/components/TimelineLanes.tsx | 39 ++++++++++++++- .../components/TimelinePropertyLanes.tsx | 2 + .../player/components/TimelineTrackHeader.tsx | 6 +++ .../player/components/TimelineTrackRow.tsx | 49 ++++++++++++++++--- .../timelineKeyboardNavigation.test.ts | 28 ++++++++--- .../components/timelineKeyboardNavigation.ts | 34 ++++--------- .../components/timelineNavigationIdentity.ts | 33 +++++++++++++ 9 files changed, 180 insertions(+), 52 deletions(-) create mode 100644 packages/studio/src/player/components/timelineNavigationIdentity.ts diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 814295d025..d5ae8abcb6 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -240,6 +240,25 @@ describe("Timeline provider boundary", () => { expect(propertyLane.style.background).toBe(""); expect(propertyLane.style.border).toBe(""); expect(propertyLane.style.borderRadius).toBe(""); + const treegrid = host.querySelector('[role="treegrid"]'); + const semanticRows = treegrid?.querySelectorAll('[role="row"]') ?? []; + expect(treegrid?.getAttribute("aria-rowcount")).toBe("3"); + expect([...semanticRows].map((row) => row.getAttribute("aria-rowindex"))).toEqual([ + "1", + "2", + "3", + ]); + expect(semanticRows[0]?.getAttribute("aria-level")).toBe("1"); + expect(semanticRows[0]?.getAttribute("aria-expanded")).toBe("true"); + expect(semanticRows[1]?.getAttribute("aria-level")).toBe("2"); + expect(semanticRows[1]?.textContent).toContain("position"); + expect(semanticRows[1]?.querySelector('[role="rowheader"]')?.getAttribute("aria-owns")).toBe( + headerLane.id, + ); + expect(semanticRows[1]?.querySelector('[role="gridcell"]')?.getAttribute("aria-owns")).toBe( + propertyLane.id, + ); + expect(semanticRows[2]?.hasAttribute("aria-expanded")).toBe(false); expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`); expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`); expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`); @@ -291,12 +310,12 @@ describe("Timeline provider boundary", () => { const root = createRoot(host); act(() => root.render(React.createElement(Timeline))); - const list = host.querySelector('[role="list"]'); - const rows = list?.querySelectorAll('[role="listitem"]') ?? []; + const treegrid = host.querySelector('[role="treegrid"]'); + const rows = treegrid?.querySelectorAll('[role="row"]') ?? []; expect(rows).toHaveLength(12); - expect(rows[0]?.getAttribute("aria-posinset")).toBe("1"); - expect(rows[0]?.getAttribute("aria-setsize")).toBe("12"); - expect(rows[11]?.getAttribute("aria-posinset")).toBe("12"); + expect(treegrid?.getAttribute("aria-rowcount")).toBe("12"); + expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1"); + expect(rows[11]?.getAttribute("aria-rowindex")).toBe("12"); act(() => root.unmount()); }); diff --git a/packages/studio/src/player/components/Timeline.virtualization.test.tsx b/packages/studio/src/player/components/Timeline.virtualization.test.tsx index 25d2455012..0d6a7fcadc 100644 --- a/packages/studio/src/player/components/Timeline.virtualization.test.tsx +++ b/packages/studio/src/player/components/Timeline.virtualization.test.tsx @@ -130,13 +130,13 @@ describe("Timeline row virtualization", () => { await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 3 }))); await act(async () => {}); - const list = host.querySelector('[role="list"]'); - const rows = list?.querySelectorAll('[role="listitem"]') ?? []; + const treegrid = host.querySelector('[role="treegrid"]'); + const rows = treegrid?.querySelectorAll('[role="row"]') ?? []; expect(rows.length).toBeGreaterThan(0); expect(rows.length).toBeLessThanOrEqual(16); - expect(rows[0]?.getAttribute("aria-posinset")).toBe("1"); - expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000"); - expect(list?.parentElement?.style.height).toBe(`${getTimelineCanvasHeight(1_000)}px`); + expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1"); + expect(treegrid?.getAttribute("aria-rowcount")).toBe("1000"); + expect(treegrid?.parentElement?.style.height).toBe(`${getTimelineCanvasHeight(1_000)}px`); const firstRow = rows[0] as HTMLElement; const focusedControl = firstRow.querySelector("button"); @@ -150,7 +150,7 @@ describe("Timeline row virtualization", () => { scroller.dispatchEvent(new Event("scroll")); }); } - expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull(); + expect(treegrid?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull(); expect(document.activeElement).toBe(focusedControl); act(() => root.unmount()); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index a9c95a2de9..ad7e4b7bc8 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; @@ -22,6 +23,7 @@ import type { TimelineLaneBaseProps } from "./TimelineLaneTypes"; import { TimelineTrackRow } from "./TimelineTrackRow"; import { isTimelineClipActive } from "./useTimelineActiveClips"; import { queryTimelineClipIndex } from "../lib/timelineClipIndex"; +import { buildTimelineLogicalRows, type TimelineLogicalRow } from "./timelineKeyboardNavigation"; interface TimelineLanesProps extends TimelineLaneBaseProps { /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ @@ -96,6 +98,32 @@ export function TimelineLanes({ }: TimelineLanesProps) { const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); + const { logicalRows, logicalRowsByTrack } = useMemo(() => { + const rows = buildTimelineLogicalRows({ + tracks, + displayTrackOrder, + laneCounts, + selectedElementId, + selectedElementIds, + expandedClipIds: STUDIO_KEYFRAMES_ENABLED ? expandedClipIds : new Set(), + gsapAnimations, + }); + const byTrack = new Map(); + for (const logicalRow of rows) { + const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? []; + trackRows.push(logicalRow); + byTrack.set(logicalRow.physicalTrackKey, trackRows); + } + return { logicalRows: rows, logicalRowsByTrack: byTrack }; + }, [ + displayTrackOrder, + expandedClipIds, + gsapAnimations, + laneCounts, + selectedElementId, + selectedElementIds, + tracks, + ]); const toggleClipExpandedTracked = (key: string) => { const willExpand = !expandedClipIds.has(key); trackStudioKeyframeLaneExpand({ expanded: willExpand }); @@ -103,8 +131,10 @@ export function TimelineLanes({ }; return (
{ @@ -112,6 +142,9 @@ export function TimelineLanes({ virtualRows.map(({ index: row, rowKey }) => { const trackNum = displayTrackOrder[row]; if (trackNum === undefined) return null; + const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? []; + const logicalRow = trackLogicalRows[0]; + if (!logicalRow) return null; const rowHeight = rowGeometry.getRowHeight(row); const els = tracks.find(([t]) => t === trackNum)?.[1] ?? []; const indexedRenderElements = rowsVirtualized @@ -166,7 +199,8 @@ export function TimelineLanes({ key={rowKey} index={row} rowKey={rowKey} - rowCount={displayTrackOrder.length} + logicalRow={logicalRow} + propertyRows={trackLogicalRows.slice(1)} top={rowGeometry.getRowTop(row)} height={rowHeight} virtualized={rowsVirtualized} @@ -199,6 +233,7 @@ export function TimelineLanes({ onSeek={onSeek} />
(
- {children} +
+ {children} +
+ {propertyRows.map((row) => { + const group = row.propertyGroup; + const keyframeCount = row.items.filter((item) => item.kind === "keyframe").length; + const easeCount = row.items.filter((item) => item.kind === "ease").length; + return ( +
+
+ {group} +
+
+ {keyframeCount} keyframes, {easeCount} ease controls +
+
+ ); + })}
); } diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts index 099735544a..d567ccebe4 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts @@ -5,9 +5,8 @@ import { buildTimelineLogicalRows, resolveTimelineFocusFallback, resolveTimelineNavigationTarget, - timelineClipFocusId, - timelineTrackRowId, } from "./timelineKeyboardNavigation"; +import { timelineClipFocusId, timelineTrackRowId } from "./timelineNavigationIdentity"; function clip(id: string, track: number, start: number, duration = 2): TimelineElement { return { id, track, start, duration, tag: "div" }; @@ -66,18 +65,31 @@ describe("buildTimelineLogicalRows", () => { const rows = model(); expect( - rows.map(({ physicalTrackKey, logicalIndex, level, parentId }) => ({ + rows.map(({ physicalTrackKey, logicalIndex, level, parentId, expandable }) => ({ physicalTrackKey, logicalIndex, level, parentId, + expandable, })), ).toEqual([ - { physicalTrackKey: 1, logicalIndex: 0, level: 1, parentId: null }, - { physicalTrackKey: 1, logicalIndex: 1, level: 2, parentId: timelineTrackRowId(1) }, - { physicalTrackKey: 1, logicalIndex: 2, level: 2, parentId: timelineTrackRowId(1) }, - { physicalTrackKey: 2, logicalIndex: 3, level: 1, parentId: null }, - { physicalTrackKey: 3, logicalIndex: 4, level: 1, parentId: null }, + { physicalTrackKey: 1, logicalIndex: 0, level: 1, parentId: null, expandable: true }, + { + physicalTrackKey: 1, + logicalIndex: 1, + level: 2, + parentId: timelineTrackRowId(1), + expandable: false, + }, + { + physicalTrackKey: 1, + logicalIndex: 2, + level: 2, + parentId: timelineTrackRowId(1), + expandable: false, + }, + { physicalTrackKey: 2, logicalIndex: 3, level: 1, parentId: null, expandable: false }, + { physicalTrackKey: 3, logicalIndex: 4, level: 1, parentId: null, expandable: false }, ]); expect(rows[0]?.expanded).toBe(true); expect(rows[3]?.items).toEqual([]); diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index 60588eabd5..c86e98ee96 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -5,6 +5,13 @@ import { timelineKeyframeSelectionKey, type TimelineKeyframeTarget, } from "./timelineKeyframeIdentity"; +import { + timelineClipFocusId, + timelineEaseFocusId, + timelineKeyframeFocusId, + timelinePropertyRowId, + timelineTrackRowId, +} from "./timelineNavigationIdentity"; import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; export type TimelineNavigationKey = @@ -49,6 +56,7 @@ export interface TimelineLogicalRow { logicalIndex: number; level: 1 | 2; parentId: string | null; + expandable: boolean; expanded: boolean; propertyGroup?: PropertyGroupName; items: readonly TimelineLogicalItem[]; @@ -73,30 +81,6 @@ export interface TimelineNavigationOptions { timelineBoundary?: boolean; } -function stableId(kind: string, ...parts: Array): string { - return JSON.stringify(["timeline", kind, ...parts]); -} - -export function timelineTrackRowId(track: number): string { - return stableId("track", track); -} - -export function timelinePropertyRowId(elementId: string, group: PropertyGroupName): string { - return stableId("property", elementId, group); -} - -export function timelineClipFocusId(elementId: string): string { - return stableId("clip", elementId); -} - -export function timelineKeyframeFocusId(elementId: string, target: TimelineKeyframeTarget): string { - return stableId("keyframe", timelineKeyframeSelectionKey(elementId, target)); -} - -export function timelineEaseFocusId(elementId: string, target: TimelineKeyframeTarget): string { - return stableId("ease", timelineKeyframeSelectionKey(elementId, target)); -} - function elementId(element: TimelineElement): string { return element.key ?? element.id; } @@ -217,6 +201,7 @@ export function buildTimelineLogicalRows({ logicalIndex: rows.length, level: 1, parentId: null, + expandable: lanes.length > 0, expanded, items: clipItems(trackId, elements), }); @@ -230,6 +215,7 @@ export function buildTimelineLogicalRows({ logicalIndex: rows.length, level: 2, parentId: trackId, + expandable: false, expanded: false, propertyGroup: lane.group, items: propertyItems(rowId, activeClip, lane.keyframes), diff --git a/packages/studio/src/player/components/timelineNavigationIdentity.ts b/packages/studio/src/player/components/timelineNavigationIdentity.ts new file mode 100644 index 0000000000..4624ea9578 --- /dev/null +++ b/packages/studio/src/player/components/timelineNavigationIdentity.ts @@ -0,0 +1,33 @@ +import type { PropertyGroupName } from "@hyperframes/core/gsap-parser"; +import { + timelineKeyframeSelectionKey, + type TimelineKeyframeTarget, +} from "./timelineKeyframeIdentity"; + +function stableId(kind: string, ...parts: Array): string { + return JSON.stringify(["timeline", kind, ...parts]); +} + +export function timelineTrackRowId(track: number): string { + return stableId("track", track); +} + +export function timelinePropertyRowId(elementId: string, group: PropertyGroupName): string { + return stableId("property", elementId, group); +} + +export function timelineLogicalRowCellId(rowId: string, cell: "header" | "content"): string { + return `${rowId}:${cell}`; +} + +export function timelineClipFocusId(elementId: string): string { + return stableId("clip", elementId); +} + +export function timelineKeyframeFocusId(elementId: string, target: TimelineKeyframeTarget): string { + return stableId("keyframe", timelineKeyframeSelectionKey(elementId, target)); +} + +export function timelineEaseFocusId(elementId: string, target: TimelineKeyframeTarget): string { + return stableId("ease", timelineKeyframeSelectionKey(elementId, target)); +}