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 @@ -276,7 +276,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 @@ -456,7 +456,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 (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
const rect = scrollRef.current.getBoundingClientRect();
Expand Down Expand Up @@ -496,6 +495,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 @@ -81,6 +81,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,12 +58,15 @@ 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 };
}

function renderRetimeLane(onMoveKeyframe = vi.fn().mockResolvedValue(true), strict = false) {
Expand Down Expand Up @@ -120,6 +123,27 @@ function renderRetimeLane(onMoveKeyframe = vi.fn().mockResolvedValue(true), stri
}

describe("TimelineClipDiamonds", () => {
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 @@ -659,6 +683,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 @@ -691,13 +716,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 @@ -710,8 +737,14 @@ 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());
});

Expand Down
Loading
Loading