Files
hyperframes/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
T
Miguel Angel Simon Sierra c264cf266f fix(studio): stop lane-header clicks from reaching the track row
The prev/next keyframe chevrons and the group toggle diamond let their
click bubble to the ancestor track row, so seeking to a keyframe also
reselected the track. The disclosure caret and the eye already stop it;
these now match.

Truncated labels (layer name, track label, group label, value readout)
also carry a title so the full text is reachable on hover.
2026-07-27 19:52:06 +02:00

294 lines
9.7 KiB
TypeScript

// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { defaultTimelineTheme } from "./timelineTheme";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { LABEL_COL_W } from "./timelineLayout";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
const ELEMENT: TimelineElement = {
id: "clip-1",
label: "Hero card",
tag: "div",
start: 0,
duration: 2,
track: 0,
};
function animation(
id: string,
propertyGroup: PropertyGroupName,
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
}>,
): GsapAnimation {
return {
id,
targetSelector: "#clip-1",
method: "to",
position: 0,
duration: 2,
properties: {},
propertyGroup,
keyframes: { format: "percentage", keyframes },
};
}
const POSITION = animation("position-tween", "position", [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 50, properties: { x: 100, y: 50 } },
{ percentage: 100, properties: { x: 200, y: 100 } },
]);
const OPACITY = animation("opacity-tween", "visual", [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 50, properties: { opacity: 0.5 } },
{ percentage: 100, properties: { opacity: 1 } },
]);
interface RenderHeaderOptions {
animations?: GsapAnimation[];
currentTime?: number;
expanded?: boolean;
onSeek?: (time: number) => void;
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
}
function renderHeader(options: RenderHeaderOptions = {}): {
host: HTMLDivElement;
root: Root;
rerender: (next: RenderHeaderOptions) => void;
} {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (next: RenderHeaderOptions) => {
act(() => {
root.render(
<TimelineTrackHeader
trackNumber={0}
trackLabel="Hero card"
contentOrigin={LABEL_COL_W}
keyframeClip={ELEMENT}
isExpanded={next.expanded !== false}
animations={next.animations ?? [POSITION, OPACITY]}
currentTime={next.currentTime ?? 0}
isTrackHidden={false}
isAudioTrack={false}
isActive
isHovered={false}
theme={defaultTimelineTheme}
onToggleClipExpanded={vi.fn()}
onToggleTrackHidden={vi.fn()}
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
onSeek={next.onSeek}
/>,
);
});
};
render(options);
return { host, root, rerender: render };
}
function click(host: HTMLElement, label: string) {
const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
expect(button).not.toBeNull();
act(() => button?.click());
}
describe("TimelineTrackHeader", () => {
it("adds and removes a keyframe on the explicitly targeted property-group tween", () => {
const onTogglePropertyGroupKeyframe = vi.fn();
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
click(view.host, "Toggle Opacity keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
ELEMENT,
expect.objectContaining({
animationId: "opacity-tween",
propertyGroup: "visual",
tweenPercentage: 25,
properties: { opacity: 0.25 },
remove: false,
}),
);
view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
click(view.host, "Toggle Opacity keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
ELEMENT,
expect.objectContaining({
animationId: "opacity-tween",
propertyGroup: "visual",
tweenPercentage: 50,
properties: { opacity: 0.5 },
remove: true,
}),
);
expect(onTogglePropertyGroupKeyframe).not.toHaveBeenCalledWith(
ELEMENT,
expect.objectContaining({ animationId: "position-tween" }),
);
act(() => view.root.unmount());
});
it("seeks only to the selected group's adjacent keyframes", () => {
const onSeek = vi.fn();
const view = renderHeader({
currentTime: 1,
animations: [
POSITION,
animation("opacity-tween", "visual", [
{ percentage: 25, properties: { opacity: 0.25 } },
{ percentage: 50, properties: { opacity: 0.5 } },
{ percentage: 75, properties: { opacity: 0.75 } },
]),
],
onSeek,
});
click(view.host, "Next Position keyframe");
expect(onSeek).toHaveBeenLastCalledWith(2);
click(view.host, "Previous Position keyframe");
expect(onSeek).toHaveBeenLastCalledWith(0);
expect(onSeek).not.toHaveBeenCalledWith(1.5);
act(() => view.root.unmount());
});
// The lane header sits inside the track row, whose own click handler selects
// the track. Every control in the label column has to own its click, or
// seeking to a keyframe also reselects whatever is behind the header.
it("keeps lane-header control clicks off the ancestor track row", () => {
const onAncestorClick = vi.fn();
const view = renderHeader({
currentTime: 1,
onSeek: vi.fn(),
onTogglePropertyGroupKeyframe: vi.fn(),
});
// React 18 delegates from the root container, so an ancestor of it is where
// a leaked click actually shows up.
document.body.addEventListener("click", onAncestorClick);
// Every control in the lane's label column, found by row rather than by
// label, so a wording change to one button can't silently drop it here.
const controls = view.host.querySelectorAll<HTMLButtonElement>(
'[data-property-group="position"] button',
);
expect(controls.length).toBeGreaterThanOrEqual(3);
for (const button of controls) {
act(() => {
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
document.body.removeEventListener("click", onAncestorClick);
expect(onAncestorClick).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
it("fills the toggle diamond exactly at that group's keyframe", () => {
const view = renderHeader({ currentTime: 0.5 });
const positionToggle = view.host.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle Position keyframe"]',
);
expect(positionToggle?.textContent).toBe("◇");
view.rerender({ currentTime: 1 });
expect(
view.host.querySelector<HTMLButtonElement>('button[aria-label="Toggle Position keyframe"]')
?.textContent,
).toBe("◆");
act(() => view.root.unmount());
});
it("updates formatted group values when the playhead moves", () => {
const view = renderHeader({ currentTime: 0.5 });
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
"50, 25",
);
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("25%");
view.rerender({ currentTime: 1.5 });
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
"150, 75",
);
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("75%");
act(() => view.root.unmount());
});
it("disables the previous chevron at or before the group's first keyframe", () => {
const view = renderHeader({ currentTime: 0 });
const prevAt0 = view.host.querySelector<HTMLButtonElement>(
'button[aria-label="Previous Position keyframe"]',
);
expect(prevAt0).not.toBeNull();
expect(prevAt0?.disabled).toBe(true);
view.rerender({ currentTime: 1 });
const prevAt1 = view.host.querySelector<HTMLButtonElement>(
'button[aria-label="Previous Position keyframe"]',
);
expect(prevAt1?.disabled).toBe(false);
act(() => view.root.unmount());
});
it("uses the same lane row offsets when collapsed, expanded once, and expanded multiple times", () => {
const view = renderHeader({ expanded: false });
expect(view.host.querySelectorAll("[data-timeline-lane-top]")).toHaveLength(0);
const assertAligned = (animations: GsapAnimation[]) => {
view.rerender({ animations });
const lanesHost = document.createElement("div");
document.body.append(lanesHost);
const lanesRoot = createRoot(lanesHost);
act(() => {
lanesRoot.render(
<TimelinePropertyLanes
animations={animations}
clipStart={0}
clipDuration={2}
clipLeftPx={120}
clipWidthPx={200}
accentColor="#3CE6AC"
isSelected
currentPercentage={0}
elementId="clip-1"
selectedKeyframes={new Set()}
/>,
);
});
expect(
Array.from(view.host.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
(row) => row.style.top,
),
).toEqual(
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
(row) => row.style.top,
),
);
expect(
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-property-lane]")).map(
(row) => row.style.left,
),
).toEqual(animations.map(() => "120px"));
act(() => lanesRoot.unmount());
};
assertAligned([POSITION]);
assertAligned([POSITION, OPACITY]);
act(() => view.root.unmount());
});
});