diff --git a/packages/studio/src/hooks/timelineTrackVisibility.ts b/packages/studio/src/hooks/timelineTrackVisibility.ts index 634ea6ee6..7860ad38b 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.ts @@ -1,7 +1,11 @@ import { useCallback } from "react"; import { usePlayerStore, type TimelineElement } from "../player"; import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements"; -import { timelineTrackOrder, trackDisplayNumber } from "../player/components/timelineTrackDisplay"; +import { + timelineTrackOrder, + trackDisplayNumber, + trackDisplaySuffix, +} from "../player/components/timelineTrackDisplay"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher"; import { @@ -211,13 +215,15 @@ export async function toggleTimelineTrackHidden({ }: ToggleTimelineTrackHiddenInput): Promise { // `track` is the fractional sort key the callback needs; the history entry is // read by a human, so it gets the display row instead. - const displayNumber = trackDisplayNumber(timelineTrackOrder(timelineElements), track); + const suffix = trackDisplaySuffix( + trackDisplayNumber(timelineTrackOrder(timelineElements), track), + ); return setElementsHidden({ projectId, activeCompPath, elements: timelineElements.filter((element) => element.track === track), hidden, - label: hidden ? `Hide track ${displayNumber}` : `Show track ${displayNumber}`, + label: hidden ? `Hide track${suffix}` : `Show track${suffix}`, previewIframe, writeProjectFile, recordEdit, diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index eecc98f89..248b1675a 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -215,6 +215,30 @@ describe("TimelineLanes disclosure target", () => { act(() => view.root.unmount()); }); + // Two timelines on one page (a mini-timeline in a modal beside the main one) + // both minted `timeline-lanes-track-0`, so every caret's aria-controls + // resolved to whichever instance mounted first. + it("mints lane ids that do not collide with a second TimelineLanes on the page", () => { + const first = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); + const second = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); + + const idsFor = (host: HTMLElement) => + Array.from(host.querySelectorAll("button[aria-controls]")).map((caret) => + caret.getAttribute("aria-controls"), + ); + const firstIds = idsFor(first.host); + const secondIds = idsFor(second.host); + + expect(firstIds.length).toBeGreaterThan(0); + expect(firstIds.some((id) => secondIds.includes(id))).toBe(false); + // 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-]*$/); + } + act(() => first.root.unmount()); + act(() => second.root.unmount()); + }); + // The passenger branch wraps [clip, lanes] in a transformed div that re-renders // on every pointer move. An unstable key there remounts the lanes and drops the // in-flight drag. diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 32e805631..58d857762 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -1,10 +1,11 @@ +import { useId } from "react"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; -import { trackDisplayNumber } from "./timelineTrackDisplay"; +import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; import { clipTimingStart } from "../../hooks/gsapShared"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout"; @@ -89,6 +90,12 @@ export function TimelineLanes({ onRazorSplit, onRazorSplitAll, }: TimelineLanesProps) { + // Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal + // beside the main one) cannot both mint `...-track-0` and have every caret's + // aria-controls resolve to whichever mounted first. React's useId embeds + // colons, which are legal in an id and in aria-controls but need escaping in + // a CSS `#id` selector, so they come out here and the prefix stays plain. + const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); const toggleClipExpandedTracked = (key: string) => { @@ -137,7 +144,7 @@ export function TimelineLanes({ // the disclosure: the caret in the sticky header and the diamond lanes // on the canvas. Keyed by display row, not by `trackNum`, which is a // fractional sort key and would mint ids like `...-0.16666666666666666`. - const lanesId = `timeline-lanes-track-${row}`; + const lanesId = `${lanesIdPrefix}-track-${row}`; return (