fix(studio): scope lane ids per timeline and stop inventing a track row

Two latent defects in the announcement path this branch adds.

- lanesId was keyed by render row alone, so a second TimelineLanes on the
  page (a mini-timeline beside the main one) would mint the same
  timeline-lanes-track-0 and every caret's aria-controls would resolve to
  whichever instance mounted first. The prefix now comes from useId, with the
  colons stripped so the id stays a legal CSS selector.
- trackDisplayNumber returned trackOrder.length + 1 for a key it could not
  find, which is indistinguishable from a real row: the label announced a row
  the user can see is wrong and nothing upstream could tell it had guessed. It
  returns null now, and trackDisplaySuffix drops the number from the label
  rather than inventing one.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 21:21:27 +02:00
parent 477916cbe2
commit ba0d6406d2
6 changed files with 106 additions and 14 deletions
@@ -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.