fix(studio): announce real track numbers and point aria-controls at the lanes

The timeline's track key is a fractional z-order sort value, and the header
built its visibility label straight from it, so screen readers announced
"Hide track 0.16666666666666666". A track's 1-based display row is now passed
alongside the key: the row number goes in every label, the key keeps routing
every callback (visibility toggle, lane context menu). The same fix covers the
`Track N` fallback used when a track holds no labelled element.

The layer disclosure caret's aria-controls named a div in the sticky label
column. That subtree is not empty, it holds the per-lane keyframe controls, but
its children are all absolutely positioned so the div computes to 0x0, and the
diamonds the caret visibly reveals live on the canvas instead. The caret expands
two disjoint subtrees and was naming the less useful one. TimelinePropertyLanes
now renders one static wrapper (static, not relative, so it establishes no
containing block and the absolutely-positioned lanes keep resolving against the
track-content div with identical geometry) and takes the id. TimelineLanes mints
that id, since it is the only place that sees both ends of the disclosure, and
mounts the wrapper for the track's keyframe clip in both disclosure states so
the reference still resolves while collapsed.

TimelineLaneBaseProps moves to its own module: it is the contract shared by
TimelineCanvas and TimelineLanes, and lifting it out keeps TimelineLanes.tsx
well under the 600-line cap instead of pushing past it.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 20:37:52 +02:00
parent f04cdb79c5
commit 1faa0cbdad
10 changed files with 524 additions and 127 deletions
@@ -67,6 +67,7 @@ interface RenderHeaderOptions {
expanded?: boolean;
onSeek?: (time: number) => void;
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
}
function renderHeader(options: RenderHeaderOptions = {}): {
@@ -81,8 +82,12 @@ function renderHeader(options: RenderHeaderOptions = {}): {
act(() => {
root.render(
<TimelineTrackHeader
trackNumber={0}
// A real fractional z-order sort key, so a label built from it would
// read out "track 0.16666666666666666".
trackNumber={1 / 6}
trackDisplayNumber={1}
trackLabel="Hero card"
lanesId="timeline-lanes-track-0"
contentOrigin={LABEL_COL_W}
keyframeClip={next.keyframeClip ?? ELEMENT}
clipCount={next.clipCount ?? 1}
@@ -93,7 +98,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
isAudioTrack={false}
theme={defaultTimelineTheme}
onToggleClipExpanded={vi.fn()}
onToggleTrackHidden={vi.fn()}
onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
onSeek={next.onSeek}
/>,
@@ -173,10 +178,27 @@ describe("TimelineTrackHeader", () => {
// in every disclosure state — a hover-gated eye is unusable by keyboard.
it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => {
const view = renderHeader({ expanded: true });
expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
view.rerender({ expanded: false });
expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
act(() => view.root.unmount());
});
// trackNumber is a fractional z-order sort key, so building the label from it
// made screen readers announce "Hide track 0.16666666666666666". The display
// number is label-only; the toggle still routes by the real key.
it("announces the display track number but toggles with the real fractional key", () => {
const onToggleTrackHidden = vi.fn();
const view = renderHeader({ onToggleTrackHidden });
const eye = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 1"]');
expect(eye).not.toBeNull();
expect(eye?.title).toBe("Hide track 1");
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
act(() => eye?.click());
expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true);
act(() => view.root.unmount());
});