docs(studio): document timeline keyboard navigation (#3031)

* feat(studio): expose timeline treegrid semantics

* feat(studio): coordinate logical timeline focus

* feat(studio): add timeline keyboard controls

* docs(studio): document timeline keyboard navigation
This commit is contained in:
Miguel Ángel
2026-08-04 17:09:07 -07:00
committed by GitHub
parent 0f1333c80d
commit 7bf425b7a9
41 changed files with 1911 additions and 653 deletions
@@ -9,6 +9,7 @@ import { getTrackStyle } from "./timelineIcons";
import { defaultTimelineTheme } from "./timelineTheme";
import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -26,6 +27,17 @@ afterEach(() => {
const TRACK_A = 1 / 6;
const TRACK_B = 0.5;
/** Every string a screen reader or a sighted user actually reads. */
function visibleText(host: HTMLElement): string {
return host.textContent ?? "";
}
function ariaLabels(host: HTMLElement): string {
return Array.from(host.querySelectorAll("[aria-label]"))
.map((el) => el.getAttribute("aria-label") ?? "")
.join(" ");
}
function element(id: string, track: number): TimelineElement {
return { id, label: id, tag: "div", start: 0, duration: 2, track };
}
@@ -94,6 +106,16 @@ function renderLanes(options: RenderLanesOptions = {}): {
rowGeometry={getTimelineRowGeometry(rowHeights)}
virtualRows={displayTrackOrder.map((_, index) => ({ index, rowKey: index }))}
rowsVirtualized={false}
focusedTargetId={null}
logicalRows={buildTimelineLogicalRows({
tracks,
displayTrackOrder,
laneCounts,
selectedElementId: null,
selectedElementIds: next.selectedElementIds ?? new Set(),
expandedClipIds: new Set(next.expandedClipIds ?? []),
gsapAnimations,
})}
clipIndex={createTimelineClipIndex(tracks)}
renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
pinnedClipIdentities={new Set()}
@@ -152,7 +174,11 @@ describe("TimelineLanes track numbering", () => {
expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
expect(view.host.querySelectorAll("[data-timeline-row]")).toHaveLength(2);
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
// Only what a user reads. The fractional key still identifies the row in
// `id` / `data-` attributes, which is exactly where an opaque sort key
// belongs.
expect(visibleText(view.host)).not.toContain("0.16666666666666666");
expect(ariaLabels(view.host)).not.toContain("0.16666666666666666");
act(() => view.root.unmount());
});
@@ -179,11 +205,12 @@ describe("TimelineLanes track numbering", () => {
onContextMenuLane,
});
// Row children: [sticky header column, time-mapped track content]. The rows
// sit inside the lanes list, which is what carries the virtualization
// positioning context.
const rows = Array.from(view.host.querySelectorAll('[role="listitem"]'));
const secondTrackContent = rows[1]?.children.item(1);
// The lane's own content cell: the track row's second child, after the
// sticky header column.
const secondTrackContent = view.host
.querySelectorAll("[data-timeline-row]")[1]
?.querySelector('[role="row"]')
?.children.item(1);
act(() => {
secondTrackContent?.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100 }),
@@ -238,9 +265,40 @@ describe("TimelineLanes disclosure target", () => {
);
const firstIds = idsFor(first.host);
const secondIds = idsFor(second.host);
const cellIdsFor = (host: HTMLElement) =>
new Set(
Array.from(host.querySelectorAll<HTMLElement>("[data-property-group][id]"), (cell) =>
cell.getAttribute("id"),
).filter((id): id is string => id !== null),
);
const ownedIdsFor = (host: HTMLElement) =>
Array.from(host.querySelectorAll("[aria-owns]"), (owner) =>
owner.getAttribute("aria-owns"),
).filter((id): id is string => id !== null);
const firstCellIds = cellIdsFor(first.host);
const secondCellIds = cellIdsFor(second.host);
for (const { host } of [first, second]) {
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
expect(treegrid?.getAttribute("aria-colcount")).toBe("2");
expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
expect(
[...host.querySelectorAll('[role="rowheader"]')].every(
(cell) => cell.getAttribute("aria-colindex") === "1",
),
).toBe(true);
expect(
[...host.querySelectorAll('[role="gridcell"]')].every(
(cell) => cell.getAttribute("aria-colindex") === "2",
),
).toBe(true);
}
expect(firstIds.length).toBeGreaterThan(0);
expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
expect(firstCellIds.size).toBeGreaterThan(0);
expect([...firstCellIds].some((id) => secondCellIds.has(id))).toBe(false);
expect(ownedIdsFor(first.host).every((id) => firstCellIds.has(id))).toBe(true);
expect(ownedIdsFor(second.host).every((id) => secondCellIds.has(id))).toBe(true);
// Still a legal CSS id selector: the aria-controls lookups above use `#id`.
for (const id of [...firstIds, ...secondIds]) {
expect(id).toMatch(/^[A-Za-z][\w-]*$/);