mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
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:
@@ -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-]*$/);
|
||||
|
||||
Reference in New Issue
Block a user