Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function LayerDisclosureRow({
>
<button
type="button"
tabIndex={-1}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
Expand Down
4 changes: 2 additions & 2 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export const Timeline = memo(function Timeline({
lastScrollLeftRef,
contentOrigin,
});
const { logicalRows, pinnedElementId, rowVirtualizationActive, virtualRows } =
const { logicalRows, focusedTargetId, pinnedElementId, rowVirtualizationActive, virtualRows } =
useTimelineLogicalFocus({
scrollRef,
tracks,
Expand Down Expand Up @@ -476,7 +476,6 @@ export const Timeline = memo(function Timeline({
onDragLeave={assetDrop.handleAssetDragLeave}
onDrop={assetDrop.handleAssetDrop}
onPointerDown={(e) => {
// Interactive controls own their clicks; scrubbing would preventDefault and eat them.
if (e.target instanceof Element && e.target.closest("button, input, select, a")) return;
if (splitAllAtPointer(e)) return;
handlePointerDown(e);
Expand Down Expand Up @@ -505,6 +504,7 @@ export const Timeline = memo(function Timeline({
rowGeometry={displayLayout.rowGeometry}
virtualRows={virtualRows}
logicalRows={logicalRows}
focusedTargetId={focusedTargetId}
rowsVirtualized={rowVirtualizationActive}
clipIndex={clipIndex}
renderTimeRange={renderTimeRange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ describe("Timeline row virtualization", () => {
expect(rows.length).toBeLessThanOrEqual(16);
expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1");
expect(treegrid?.getAttribute("aria-rowcount")).toBe("1000");
expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
expect(treegrid?.querySelectorAll('[data-timeline-focus-id][tabindex="0"]')).toHaveLength(1);
expect(treegrid?.parentElement?.style.height).toBe(`${getTimelineCanvasHeight(1_000)}px`);

const firstRow = rows[0] as HTMLElement;
Expand Down
19 changes: 17 additions & 2 deletions packages/studio/src/player/components/TimelineClip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function renderClip({
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const onClick = vi.fn();

act(() => {
root.render(
Expand All @@ -50,15 +51,15 @@ function renderClip({
isComposition={false}
onHoverStart={vi.fn()}
onHoverEnd={vi.fn()}
onClick={vi.fn()}
onClick={onClick}
onDoubleClick={vi.fn()}
>
<div data-custom-content="true" />
</TimelineClip>,
);
});

return { host, root };
return { host, onClick, root };
}

describe("TimelineClip", () => {
Expand Down Expand Up @@ -113,4 +114,18 @@ describe("TimelineClip", () => {

act(() => root.unmount());
});

it("is a roving native button with explicit selection semantics", () => {
const { host, onClick, root } = renderClip({
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 2, track: 0 },
isSelected: true,
});
const clip = host.querySelector<HTMLButtonElement>(".timeline-clip")!;
expect(clip.type).toBe("button");
expect(clip.tabIndex).toBe(-1);
expect(clip.getAttribute("aria-pressed")).toBe("true");
act(() => clip.click());
expect(onClick).toHaveBeenCalledOnce();
act(() => root.unmount());
});
});
16 changes: 13 additions & 3 deletions packages/studio/src/player/components/TimelineClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface TimelineClipProps {
capabilities: TimelineEditCapabilities;
theme?: TimelineTheme;
isComposition: boolean;
tabIndex?: 0 | -1;
onHoverStart: () => void;
onHoverEnd: () => void;
onPointerDown?: (e: React.PointerEvent) => void;
Expand All @@ -44,6 +45,7 @@ export const TimelineClip = memo(function TimelineClip({
capabilities,
theme = defaultTimelineTheme,
isComposition,
tabIndex = -1,
onHoverStart,
onHoverEnd,
onPointerDown,
Expand Down Expand Up @@ -83,11 +85,17 @@ export const TimelineClip = memo(function TimelineClip({
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
cursor: "default",
appearance: "none",
color: "inherit",
font: "inherit",
padding: 0,
textAlign: "left",
transform: isDragging ? "translateY(-1px)" : undefined,
};

return (
<div
<button
type="button"
data-clip={isGestureActor ? undefined : "true"}
data-el-id={isGestureActor ? undefined : (el.key ?? el.id)}
data-timeline-focus-id={isGestureActor ? undefined : timelineClipFocusId(el.key ?? el.id)}
Expand All @@ -96,7 +104,9 @@ export const TimelineClip = memo(function TimelineClip({
data-clip-hidden={el.hidden ? "true" : undefined}
data-active={isActive ? "" : undefined}
aria-hidden={isGestureActor ? "true" : undefined}
tabIndex={isGestureActor ? undefined : -1}
tabIndex={isGestureActor ? undefined : tabIndex}
aria-label={`${displayLabel}, ${startLabel} to ${endLabel} seconds`}
aria-pressed={isGestureActor ? undefined : isSelected}
className={clipClassName}
style={style}
title={
Expand Down Expand Up @@ -178,6 +188,6 @@ export const TimelineClip = memo(function TimelineClip({
</span>
)}
{children}
</div>
</button>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function createTimelineHost() {
return host;
}

function renderDiamonds(onClickKeyframe = vi.fn()) {
function renderDiamonds(onClickKeyframe = vi.fn(), onShiftClickKeyframe = vi.fn()) {
const host = createTimelineHost();
const root = createRoot(host);
act(() => {
Expand All @@ -58,14 +58,56 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
isSelected
currentPercentage={0}
elementId="clip-1"
clipStart={10}
clipDuration={10}
selectedKeyframes={new Set()}
onClickKeyframe={onClickKeyframe}
onShiftClickKeyframe={onShiftClickKeyframe}
/>,
);
});
return { host, root, onClickKeyframe };
return { host, root, onClickKeyframe, onShiftClickKeyframe };
}

it("pins authored keyframes outside the clip to an inspectable boundary marker", () => {
const host = createTimelineHost();
const root = createRoot(host);
act(() => {
root.render(
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes: [
{ percentage: -40, properties: { x: 0 }, propertyGroup: "position" },
{ percentage: -20, properties: { x: 25 }, propertyGroup: "position" },
{ percentage: 5, properties: { x: 50 }, propertyGroup: "position" },
{ percentage: 120, properties: { x: 100 }, propertyGroup: "position" },
],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={5}
elementId="clip-1"
clipStart={10}
clipDuration={10}
selectedKeyframes={new Set()}
/>,
);
});

const before = host.querySelectorAll<HTMLButtonElement>('[data-keyframe-outside-clip="before"]');
const after = host.querySelector<HTMLButtonElement>('[data-keyframe-outside-clip="after"]');
expect(Array.from(before, (marker) => marker.style.left)).toEqual(["-35px", "-23px"]);
expect(before[0]?.getAttribute("aria-label")).toBe("position keyframe at 6s (before clip)");
expect(after?.style.left).toBe("201px");
expect(after?.getAttribute("aria-label")).toBe("position keyframe at 22s (after clip)");
expect(host.querySelectorAll('button[aria-label*="keyframe at"]')).toHaveLength(4);

act(() => root.unmount());
});

function renderRetimeLane(onMoveKeyframe = vi.fn().mockResolvedValue(true), strict = false) {
usePlayerStore.setState({ elements: [RETIME_ELEMENT] });
const host = createTimelineHost();
Expand Down Expand Up @@ -156,6 +198,27 @@ describe("TimelineClipDiamonds", () => {
act(() => root.unmount());
});

it("gives keyframes time-based names and native keyboard selection semantics", () => {
const { host, root, onClickKeyframe } = renderDiamonds();
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
expect(diamond.getAttribute("aria-label")).toBe("Motion keyframe at 15s");
expect(diamond.getAttribute("aria-pressed")).toBe("false");
act(() => diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 })));
expect(onClickKeyframe).toHaveBeenCalledWith(50);
act(() => root.unmount());
});

it("uses Shift+Space's native click for additive keyframe selection", () => {
const { host, root, onClickKeyframe, onShiftClickKeyframe } = renderDiamonds();
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
act(() =>
diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0, shiftKey: true })),
);
expect(onShiftClickKeyframe).toHaveBeenCalledWith("clip-1", 50);
expect(onClickKeyframe).not.toHaveBeenCalled();
act(() => root.unmount());
});

it("publishes retime previews after StrictMode effect replay", () => {
const { diamond, host, root } = renderRetimeLane(undefined, true);
const initialLeft = diamond.style.left;
Expand Down Expand Up @@ -654,10 +717,9 @@ describe("TimelineClipDiamonds", () => {
// Regression: onClickKeyframe's state updates can re-render the diamond
// button out from under the gesture before the browser auto-synthesizes the
// "click" event that follows a button's pointerdown+pointerup. That orphaned
// click then bubbles to the ancestor clip's onClick, which toggles selection
// off whenever the clip is already selected — the state a diamond click
// always happens in — so every keyframe click immediately deselected its
// own clip. suppressClickRef lets that ancestor ignore the stray click.
// click then bubbles to the ancestor clip's onClick. That stray click can
// replace keyframe focus or collapse a marquee selection, so suppressClickRef
// lets the ancestor ignore it.
it("arms suppressClickRef synchronously on a keyframe click", () => {
const suppressClickRef = { current: false };
const host = createTimelineHost();
Expand Down Expand Up @@ -695,6 +757,7 @@ describe("TimelineClipDiamonds", () => {
const renderSegmentLane = (lastAmbiguous: boolean) => {
const host = createTimelineHost();
const root = createRoot(host);
const onSelectSegment = vi.fn();
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
percentage,
tweenPercentage: percentage,
Expand Down Expand Up @@ -727,13 +790,15 @@ describe("TimelineClipDiamonds", () => {
isSelected
currentPercentage={0}
elementId="clip-1"
clipStart={10}
clipDuration={10}
selectedKeyframes={new Set()}
onSelectSegment={vi.fn()}
onSelectSegment={onSelectSegment}
groupAware
/>,
);
});
return { host, root };
return { host, onSelectSegment, root };
};

it("shows the inline ease button on a colliding merged segment (bulk edit)", () => {
Expand All @@ -746,8 +811,23 @@ describe("TimelineClipDiamonds", () => {
});

it("shows the inline ease button on single-animation merged segments", () => {
const { host, root } = renderSegmentLane(false);
const { host, onSelectSegment, root } = renderSegmentLane(false);
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]")!;
expect(ease.getAttribute("aria-label")).toBe("Edit none easing after 10s");
expect(ease.classList.contains("opacity-0")).toBe(true);
act(() => ease.click());
expect(onSelectSegment).toHaveBeenCalledOnce();
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
act(() => root.unmount());
});

it("ends connectors at the diamond boundaries", () => {
const { host, root } = renderSegmentLane(false);
const connectors = host.querySelectorAll<HTMLElement>("[data-keyframe-connector]");

expect(Array.from(connectors, (connector) => connector.style.left)).toEqual(["11px", "111px"]);
expect(Array.from(connectors, (connector) => connector.style.width)).toEqual(["78px", "78px"]);
act(() => root.unmount());
});

Expand Down
Loading
Loading