@hyperframes/studio 0.7.78 → 0.7.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
  4. package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
  5. package/dist/assets/index-D379YOKT.css +1 -0
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1891 -1522
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioLeftSidebar.tsx +10 -5
  12. package/src/components/StudioRightPanel.tsx +7 -5
  13. package/src/components/TimelineToolbar.tsx +3 -1
  14. package/src/components/editor/AnimationCard.test.tsx +225 -18
  15. package/src/components/editor/AnimationCard.tsx +24 -3
  16. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  17. package/src/components/editor/EaseCurveSection.tsx +77 -15
  18. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  19. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  21. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +58 -20
  23. package/src/components/editor/PropertyPanel.tsx +7 -7
  24. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  26. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  27. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  28. package/src/components/editor/keyframeRetime.test.ts +42 -0
  29. package/src/components/editor/keyframeRetime.ts +4 -1
  30. package/src/components/editor/motionPathSelection.test.ts +65 -0
  31. package/src/components/editor/motionPathSelection.ts +13 -3
  32. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  33. package/src/components/editor/propertyPanelColor.tsx +32 -26
  34. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  35. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  36. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  37. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  40. package/src/components/pointerTargetSize.test.tsx +65 -0
  41. package/src/components/sidebar/CompositionsTab.tsx +4 -1
  42. package/src/contexts/DomEditContext.tsx +4 -0
  43. package/src/hooks/gsapDragCommit.ts +43 -10
  44. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  45. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
  46. package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
  47. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  48. package/src/hooks/gsapResizeIntercept.ts +2 -2
  49. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  50. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  51. package/src/hooks/gsapShared.test.ts +144 -0
  52. package/src/hooks/gsapShared.ts +264 -0
  53. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  54. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  55. package/src/hooks/gsapTweenSynth.ts +50 -23
  56. package/src/hooks/keyframeCacheAstLoad.ts +9 -83
  57. package/src/hooks/newTweenTarget.test.ts +364 -0
  58. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  59. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  60. package/src/hooks/timelineTrackVisibility.ts +11 -1
  61. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  62. package/src/hooks/useDomEditSession.test.tsx +152 -3
  63. package/src/hooks/useDomEditSession.ts +4 -53
  64. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  65. package/src/hooks/useEnableKeyframes.ts +18 -5
  66. package/src/hooks/useGestureCommit.ts +21 -4
  67. package/src/hooks/useGsapScriptCommits.ts +3 -0
  68. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  69. package/src/hooks/useGsapTweenCache.ts +44 -45
  70. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  71. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  72. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  73. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  74. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  75. package/src/player/components/ShortcutsPanel.tsx +11 -15
  76. package/src/player/components/Timeline.test.ts +3 -1
  77. package/src/player/components/TimelineCanvas.tsx +2 -1
  78. package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
  79. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  80. package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
  81. package/src/player/components/TimelineLanes.test.tsx +281 -0
  82. package/src/player/components/TimelineLanes.tsx +43 -97
  83. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  84. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  85. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  86. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  87. package/src/player/components/timelineDiamondTypes.ts +4 -3
  88. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  89. package/src/player/components/timelineLaneProps.ts +86 -0
  90. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  91. package/src/player/components/timelineTrackDisplay.ts +38 -0
  92. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  93. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  94. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  95. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  96. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  98. package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
  99. package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
  100. package/src/player/store/keyframeSlice.ts +15 -5
  101. package/src/telemetry/client.test.ts +12 -3
  102. package/src/telemetry/client.ts +3 -2
  103. package/dist/assets/index-DpkO2Hvw.css +0 -1
@@ -37,13 +37,6 @@ export function TimelineDiamondConnectors({
37
37
  keyframeTarget: (keyframe: TimelineDiamondKeyframe) => TimelineKeyframeTarget;
38
38
  onSelectSegment?: (target: TimelineKeyframeTarget) => void;
39
39
  }) {
40
- // The ease button sits dead centre of its segment, which on a two-keyframe clip
41
- // is the centre of the clip bar — the natural place to grab a clip and drag it.
42
- // Swallowing pointerdown there made that grab a no-op. Instead the press falls
43
- // through to the clip (so the drag starts normally) and the button keeps only
44
- // the click, which we drop if the pointer actually travelled.
45
- const pressXRef = useRef<number | null>(null);
46
-
47
40
  return (
48
41
  <>
49
42
  {markers.map((marker, i) => {
@@ -55,13 +48,6 @@ export function TimelineDiamondConnectors({
55
48
  if (x2 - x1 < 1) return null;
56
49
  const connectorLeft = x1 + previous.visualSize / 2;
57
50
  const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2;
58
- // The ease button targets one segment, so it needs the keyframe's own
59
- // animationId/tweenPercentage. On a merged inline row the button is
60
- // hidden where the segment is ambiguous (two source animations collide
61
- // at this % with different eases; see easeAmbiguous) or the keyframe has
62
- // no source animation id (runtime-scanned) so there is no tween to target.
63
- const target = keyframeTarget(kf);
64
- const ease = kf.ease ?? globalEase;
65
51
  return (
66
52
  <Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
67
53
  <div
@@ -78,64 +64,22 @@ export function TimelineDiamondConnectors({
78
64
  borderRadius: 1,
79
65
  }}
80
66
  />
81
- {onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && (
82
- <div
83
- className="group absolute"
84
- data-keyframe-ease-segment=""
85
- style={{
86
- left: x1,
87
- top: centerY,
88
- width: x2 - x1,
89
- height: 18,
90
- transform: "translateY(-50%)",
91
- // Own a stacking context above the diamond buttons. At fit
92
- // zoom the 16px ease control can overlap its neighbouring
93
- // diamond; without a z-index here the later diamond wins the
94
- // hit test even though the child button has z-index 3.
95
- zIndex: 3,
96
- // Only the centered control is interactive. The transparent
97
- // segment wrapper must not swallow connector/clip gestures.
98
- pointerEvents: "none",
99
- }}
100
- >
101
- <button
102
- type="button"
103
- data-keyframe-ease-button=""
104
- aria-label={`Edit ${ease} easing`}
105
- title={`Edit ${ease} easing`}
106
- className="absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
107
- style={{
108
- left: "50%",
109
- top: "50%",
110
- width: 16,
111
- height: 16,
112
- transform: "translate(-50%, -50%)",
113
- zIndex: 3,
114
- pointerEvents: "auto",
115
- padding: 0,
116
- border: "1px solid rgba(255, 255, 255, 0.14)",
117
- background: "#171717",
118
- cursor: "pointer",
119
- }}
120
- onPointerDown={(e) => {
121
- pressXRef.current = e.clientX;
122
- }}
123
- onClick={(e) => {
124
- e.stopPropagation();
125
- const pressX = pressXRef.current;
126
- pressXRef.current = null;
127
- if (
128
- pressX !== null &&
129
- Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX
130
- ) {
131
- return;
132
- }
133
- onSelectSegment(target);
134
- }}
135
- >
136
- <MiniCurveSvg ease={ease} active size={12} />
137
- </button>
138
- </div>
67
+ {onSelectSegment && showsEaseControl(kf) && (
68
+ <SegmentEaseControl
69
+ left={x1}
70
+ width={x2 - x1}
71
+ centerY={centerY}
72
+ ease={kf.ease ?? globalEase}
73
+ target={keyframeTarget(kf)}
74
+ // connectorWidth is the clear span between the two diamonds'
75
+ // edges, so a 24x24 target centred in it overhangs a diamond as
76
+ // soon as the span is narrower than 24. The segment wrapper sits
77
+ // at z-index 3, above the diamonds, so that overhang would win
78
+ // the hit test and steal their clicks at fit zoom. Grow the
79
+ // target only where the room exists.
80
+ roomForFullTarget={connectorWidth >= 24}
81
+ onSelectSegment={onSelectSegment}
82
+ />
139
83
  )}
140
84
  </Fragment>
141
85
  );
@@ -143,3 +87,105 @@ export function TimelineDiamondConnectors({
143
87
  </>
144
88
  );
145
89
  }
90
+
91
+ /**
92
+ * The ease control targets one segment, so it needs the keyframe's own
93
+ * animationId. A merged keyframe now shows one too: the editor edits every
94
+ * colliding tween together, so one button standing for several curves is
95
+ * honest. Only a keyframe with no source animation id (runtime-scanned) is left
96
+ * out, because there is no tween to target.
97
+ */
98
+ function showsEaseControl(kf: TimelineDiamondKeyframe): boolean {
99
+ return kf.animationId !== undefined;
100
+ }
101
+
102
+ /**
103
+ * The ease button centred on one connector segment, plus the transparent
104
+ * wrapper that positions it. Split out of the connector map so that map stays a
105
+ * geometry loop and this keeps the press guard, hit-target sizing and click
106
+ * filtering together.
107
+ */
108
+ function SegmentEaseControl({
109
+ left,
110
+ width,
111
+ centerY,
112
+ ease,
113
+ target,
114
+ roomForFullTarget,
115
+ onSelectSegment,
116
+ }: {
117
+ left: number;
118
+ width: number;
119
+ centerY: number;
120
+ ease: string;
121
+ target: TimelineKeyframeTarget;
122
+ roomForFullTarget: boolean;
123
+ onSelectSegment: (target: TimelineKeyframeTarget) => void;
124
+ }) {
125
+ // The ease button sits dead centre of its segment, which on a two-keyframe clip
126
+ // is the centre of the clip bar, the natural place to grab a clip and drag it.
127
+ // Swallowing pointerdown there made that grab a no-op. Instead the press falls
128
+ // through to the clip (so the drag starts normally) and the button keeps only
129
+ // the click, which we drop if the pointer actually travelled.
130
+ const pressXRef = useRef<number | null>(null);
131
+ return (
132
+ <div
133
+ className="group absolute"
134
+ data-keyframe-ease-segment=""
135
+ style={{
136
+ left,
137
+ top: centerY,
138
+ width,
139
+ height: 18,
140
+ transform: "translateY(-50%)",
141
+ // Own a stacking context above the diamond buttons. At fit zoom the 16px
142
+ // ease control can overlap its neighbouring diamond; without a z-index
143
+ // here the later diamond wins the hit test even though the child button
144
+ // has z-index 3.
145
+ zIndex: 3,
146
+ // Only the centered control is interactive. The transparent segment
147
+ // wrapper must not swallow connector/clip gestures.
148
+ pointerEvents: "none",
149
+ }}
150
+ >
151
+ <button
152
+ type="button"
153
+ data-keyframe-ease-button=""
154
+ aria-label={`Edit ${ease} easing`}
155
+ title={`Edit ${ease} easing`}
156
+ // A visible 24x24 badge would collide with the diamonds either side, so
157
+ // the WCAG 2.2 (2.5.8) target is met with a centered transparent
158
+ // ::before overlay; the box stays 16x16. Where the segment is too narrow
159
+ // for that overlay the button keeps its 16x16 hit area, which is WCAG's
160
+ // target-spacing exception: the neighbouring diamonds are themselves the
161
+ // reason it cannot grow, and stealing their clicks is the worse failure.
162
+ className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
163
+ style={{
164
+ left: "50%",
165
+ top: "50%",
166
+ width: 16,
167
+ height: 16,
168
+ transform: "translate(-50%, -50%)",
169
+ zIndex: 3,
170
+ pointerEvents: "auto",
171
+ padding: 0,
172
+ border: "1px solid rgba(255, 255, 255, 0.14)",
173
+ background: "#171717",
174
+ cursor: "pointer",
175
+ }}
176
+ onPointerDown={(e) => {
177
+ pressXRef.current = e.clientX;
178
+ }}
179
+ onClick={(e) => {
180
+ e.stopPropagation();
181
+ const pressX = pressXRef.current;
182
+ pressXRef.current = null;
183
+ if (pressX !== null && Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX) return;
184
+ onSelectSegment(target);
185
+ }}
186
+ >
187
+ <MiniCurveSvg ease={ease} active size={12} />
188
+ </button>
189
+ </div>
190
+ );
191
+ }
@@ -0,0 +1,281 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, createRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { TimelineLanes } from "./TimelineLanes";
8
+ import { getTrackStyle } from "./timelineIcons";
9
+ import { defaultTimelineTheme } from "./timelineTheme";
10
+ import { TRACK_H } from "./timelineLayout";
11
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
12
+ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
13
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
14
+ import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
15
+
16
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
17
+
18
+ afterEach(() => {
19
+ document.body.innerHTML = "";
20
+ usePlayerStore.getState().reset();
21
+ });
22
+
23
+ /** The z-order sort keys really are fractional: a clip nudged between two lanes
24
+ * lands on the midpoint. These are the values that used to reach aria-label. */
25
+ const TRACK_A = 1 / 6;
26
+ const TRACK_B = 0.5;
27
+
28
+ function element(id: string, track: number): TimelineElement {
29
+ return { id, label: id, tag: "div", start: 0, duration: 2, track };
30
+ }
31
+
32
+ function positionTween(id: string): GsapAnimation {
33
+ return {
34
+ id: `${id}-tween`,
35
+ targetSelector: `#${id}`,
36
+ method: "to",
37
+ position: 0,
38
+ duration: 2,
39
+ properties: {},
40
+ propertyGroup: "position",
41
+ keyframes: {
42
+ format: "percentage",
43
+ keyframes: [
44
+ { percentage: 0, properties: { x: 0 } },
45
+ { percentage: 100, properties: { x: 100 } },
46
+ ],
47
+ },
48
+ };
49
+ }
50
+
51
+ interface RenderLanesOptions {
52
+ elements?: TimelineElement[];
53
+ animations?: Map<string, GsapAnimation[]>;
54
+ expandedClipIds?: string[];
55
+ selectedElementIds?: Set<string>;
56
+ multiDragPreview?: MultiDragPreviewInput | null;
57
+ draggedClip?: DraggedClipState | null;
58
+ onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
59
+ onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
60
+ }
61
+
62
+ function renderLanes(options: RenderLanesOptions = {}): {
63
+ host: HTMLDivElement;
64
+ root: Root;
65
+ rerender: (next: RenderLanesOptions) => void;
66
+ } {
67
+ const host = document.createElement("div");
68
+ document.body.append(host);
69
+ const root = createRoot(host);
70
+ const render = (next: RenderLanesOptions) => {
71
+ const elements = next.elements ?? [element("clip-a", TRACK_A)];
72
+ const gsapAnimations = next.animations ?? new Map<string, GsapAnimation[]>();
73
+ const displayTrackOrder = [...new Set(elements.map((el) => el.track))].sort((a, b) => a - b);
74
+ const tracks: [number, TimelineElement[]][] = displayTrackOrder.map((track) => [
75
+ track,
76
+ elements.filter((el) => el.track === track),
77
+ ]);
78
+ const laneCounts = new Map(
79
+ elements.map((el) => [el.id, (gsapAnimations.get(el.id) ?? []).length]),
80
+ );
81
+ act(() => {
82
+ usePlayerStore.setState({ expandedClipIds: new Set(next.expandedClipIds ?? []) });
83
+ root.render(
84
+ <TimelineLanes
85
+ pps={100}
86
+ contentOrigin={232}
87
+ contentGutter={32}
88
+ trackContentWidth={800}
89
+ theme={defaultTimelineTheme}
90
+ displayTrackOrder={displayTrackOrder}
91
+ rowHeights={displayTrackOrder.map(() => TRACK_H)}
92
+ trackOrder={displayTrackOrder}
93
+ tracks={tracks}
94
+ trackStyles={new Map()}
95
+ laneCounts={laneCounts}
96
+ selectedElementId={null}
97
+ selectedElementIds={next.selectedElementIds ?? new Set()}
98
+ hoveredClip={null}
99
+ draggedClip={next.draggedClip ?? null}
100
+ draggedElement={null}
101
+ multiDragPreview={next.multiDragPreview ?? null}
102
+ blockedClipRef={createRef<BlockedClipState | null>()}
103
+ suppressClickRef={{ current: false }}
104
+ scrollRef={createRef<HTMLDivElement>()}
105
+ setHoveredClip={vi.fn()}
106
+ setShowPopover={vi.fn()}
107
+ setRangeSelection={vi.fn()}
108
+ setResizingClip={vi.fn()}
109
+ setDraggedClip={vi.fn()}
110
+ setSelectedElementId={vi.fn()}
111
+ syncClipDragAutoScroll={vi.fn()}
112
+ shiftClickClipRef={createRef()}
113
+ getPreviewElement={(el) => el}
114
+ getTrackStyle={getTrackStyle}
115
+ gsapAnimations={gsapAnimations}
116
+ selectedKeyframes={new Set()}
117
+ currentTime={0}
118
+ onContextMenuLane={next.onContextMenuLane}
119
+ onToggleTrackHidden={next.onToggleTrackHidden}
120
+ onTogglePropertyGroupKeyframe={vi.fn()}
121
+ onResizeElement={vi.fn()}
122
+ onMoveElement={vi.fn()}
123
+ onRazorSplit={vi.fn()}
124
+ onRazorSplitAll={vi.fn()}
125
+ />,
126
+ );
127
+ });
128
+ };
129
+ render(options);
130
+ return { host, root, rerender: render };
131
+ }
132
+
133
+ function visibilityLabels(host: HTMLElement): (string | null)[] {
134
+ return Array.from(host.querySelectorAll("button[aria-label^='Hide track ']")).map((button) =>
135
+ button.getAttribute("aria-label"),
136
+ );
137
+ }
138
+
139
+ describe("TimelineLanes track numbering", () => {
140
+ // Screen readers literally announced "Hide track 0.16666666666666666".
141
+ it("numbers tracks contiguously from 1 regardless of the fractional sort keys", () => {
142
+ const view = renderLanes({
143
+ elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
144
+ });
145
+
146
+ expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
147
+ expect(view.host.innerHTML).not.toContain("0.16666666666666666");
148
+ act(() => view.root.unmount());
149
+ });
150
+
151
+ it("hands the visibility toggle the real track key, not the display index", () => {
152
+ const onToggleTrackHidden = vi.fn();
153
+ const view = renderLanes({
154
+ elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
155
+ onToggleTrackHidden,
156
+ });
157
+
158
+ const second = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 2"]');
159
+ act(() => second?.click());
160
+
161
+ expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true);
162
+ act(() => view.root.unmount());
163
+ });
164
+
165
+ // The gap menu inserts at the track it is given, so a display index here would
166
+ // drop the new clip on the wrong lane.
167
+ it("hands the lane context menu the real track key, not the display index", () => {
168
+ const onContextMenuLane = vi.fn();
169
+ const view = renderLanes({
170
+ elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
171
+ onContextMenuLane,
172
+ });
173
+
174
+ // Row children: [sticky header column, time-mapped track content].
175
+ const rows = Array.from(view.host.children);
176
+ const secondTrackContent = rows[1]?.children.item(1);
177
+ act(() => {
178
+ secondTrackContent?.dispatchEvent(
179
+ new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100 }),
180
+ );
181
+ });
182
+
183
+ expect(onContextMenuLane).toHaveBeenCalledOnce();
184
+ expect(onContextMenuLane.mock.calls[0]?.[1]).toBe(TRACK_B);
185
+ act(() => view.root.unmount());
186
+ });
187
+ });
188
+
189
+ describe("TimelineLanes disclosure target", () => {
190
+ const ANIMATIONS = new Map([["clip-a", [positionTween("clip-a")]]]);
191
+
192
+ function ariaControlsTarget(host: HTMLElement): HTMLElement | null {
193
+ const caret = host.querySelector("button[aria-controls]");
194
+ const id = caret?.getAttribute("aria-controls");
195
+ return id ? host.querySelector<HTMLElement>(`#${id}`) : null;
196
+ }
197
+
198
+ // aria-controls used to name a div in the sticky label column: it computed to
199
+ // 0x0 and held no diamonds at all.
200
+ it("resolves the caret's aria-controls to an element holding the property lanes", () => {
201
+ const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
202
+ const target = ariaControlsTarget(view.host);
203
+
204
+ expect(target).not.toBeNull();
205
+ expect(target?.querySelectorAll("[data-timeline-property-lane]").length).toBeGreaterThan(0);
206
+ act(() => view.root.unmount());
207
+ });
208
+
209
+ it("still resolves the caret's aria-controls while the layer is collapsed", () => {
210
+ const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: [] });
211
+ const target = ariaControlsTarget(view.host);
212
+
213
+ expect(target).not.toBeNull();
214
+ expect(target?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(0);
215
+ act(() => view.root.unmount());
216
+ });
217
+
218
+ // Two timelines on one page (a mini-timeline in a modal beside the main one)
219
+ // both minted `timeline-lanes-track-0`, so every caret's aria-controls
220
+ // resolved to whichever instance mounted first.
221
+ it("mints lane ids that do not collide with a second TimelineLanes on the page", () => {
222
+ const first = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
223
+ const second = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
224
+
225
+ const idsFor = (host: HTMLElement) =>
226
+ Array.from(host.querySelectorAll("button[aria-controls]")).map((caret) =>
227
+ caret.getAttribute("aria-controls"),
228
+ );
229
+ const firstIds = idsFor(first.host);
230
+ const secondIds = idsFor(second.host);
231
+
232
+ expect(firstIds.length).toBeGreaterThan(0);
233
+ expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
234
+ // Still a legal CSS id selector: the aria-controls lookups above use `#id`.
235
+ for (const id of [...firstIds, ...secondIds]) {
236
+ expect(id).toMatch(/^[A-Za-z][\w-]*$/);
237
+ }
238
+ act(() => first.root.unmount());
239
+ act(() => second.root.unmount());
240
+ });
241
+
242
+ // The passenger branch wraps [clip, lanes] in a transformed div that re-renders
243
+ // on every pointer move. An unstable key there remounts the lanes and drops the
244
+ // in-flight drag.
245
+ it("does not remount the lanes while a multi-clip drag slides the formation", () => {
246
+ const elements = [element("clip-a", TRACK_A), element("clip-b", TRACK_A)];
247
+ const selectedElementIds = new Set(["clip-a", "clip-b"]);
248
+ const preview = (draggedPreviewStart: number): MultiDragPreviewInput => ({
249
+ dragStarted: true,
250
+ draggedKey: "clip-b",
251
+ draggedOriginStart: 0,
252
+ draggedPreviewStart,
253
+ selectedKeys: selectedElementIds,
254
+ });
255
+ const view = renderLanes({
256
+ elements,
257
+ animations: ANIMATIONS,
258
+ expandedClipIds: ["clip-a"],
259
+ selectedElementIds,
260
+ multiDragPreview: preview(0.25),
261
+ });
262
+
263
+ const before = ariaControlsTarget(view.host);
264
+ const beforeLane = before?.querySelector("[data-timeline-property-lane]");
265
+ expect(before).not.toBeNull();
266
+ expect(beforeLane).not.toBeNull();
267
+
268
+ view.rerender({
269
+ elements,
270
+ animations: ANIMATIONS,
271
+ expandedClipIds: ["clip-a"],
272
+ selectedElementIds,
273
+ multiDragPreview: preview(0.75),
274
+ });
275
+
276
+ // Node identity, not just presence: a remount replaces these nodes.
277
+ expect(ariaControlsTarget(view.host)).toBe(before);
278
+ expect(before?.querySelector("[data-timeline-property-lane]")).toBe(beforeLane);
279
+ act(() => view.root.unmount());
280
+ });
281
+ });