@hyperframes/studio 0.7.58 → 0.7.59

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 (156) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13514 -8528
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +29 -51
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  96. package/src/contexts/VariablePromoteContext.tsx +10 -5
  97. package/src/hooks/domEditCommitRunner.ts +11 -0
  98. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  99. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  100. package/src/hooks/timelineTrackVisibility.ts +27 -10
  101. package/src/hooks/useAppHotkeys.ts +9 -0
  102. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  103. package/src/hooks/useDomEditCommits.ts +29 -175
  104. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  105. package/src/hooks/useDomEditSession.ts +17 -5
  106. package/src/hooks/useDomEditTextCommits.ts +14 -9
  107. package/src/hooks/useDomEditWiring.ts +10 -9
  108. package/src/hooks/useElementLifecycleOps.ts +8 -3
  109. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  110. package/src/hooks/useFileManager.ts +82 -60
  111. package/src/hooks/useGsapAnimationOps.ts +7 -6
  112. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  113. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  114. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  115. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  116. package/src/hooks/useGsapScriptCommits.ts +150 -50
  117. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  118. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  119. package/src/hooks/useInspectorSplitResize.ts +51 -0
  120. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  121. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  122. package/src/hooks/usePersistentEditHistory.ts +71 -31
  123. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  124. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  125. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  126. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  127. package/src/hooks/useSdkSession.ts +155 -14
  128. package/src/hooks/useSlideshowPersist.ts +7 -1
  129. package/src/hooks/useStoryboard.ts +23 -4
  130. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  131. package/src/hooks/useTimelineEditing.ts +16 -10
  132. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  133. package/src/hooks/useTimelineGroupEditing.ts +16 -2
  134. package/src/hooks/useVariablesPersist.ts +9 -7
  135. package/src/icons/SystemIcons.tsx +2 -0
  136. package/src/styles/studio.css +22 -0
  137. package/src/styles/tailwind-preset.shared.js +7 -0
  138. package/src/utils/designInputTracking.test.ts +97 -0
  139. package/src/utils/designInputTracking.ts +80 -0
  140. package/src/utils/sdkCutover.gate.test.ts +7 -7
  141. package/src/utils/sdkCutover.test.ts +490 -57
  142. package/src/utils/sdkCutover.ts +181 -219
  143. package/src/utils/sdkEditTransaction.ts +292 -0
  144. package/src/utils/sdkResolverAttempts.ts +94 -0
  145. package/src/utils/sdkResolverShadow.test.ts +148 -0
  146. package/src/utils/sdkResolverShadow.ts +139 -106
  147. package/src/utils/setSlideshowManifest.test.ts +0 -9
  148. package/src/utils/setSlideshowManifest.ts +20 -27
  149. package/src/utils/studioFileHistory.test.ts +41 -0
  150. package/src/utils/studioFileHistory.ts +30 -27
  151. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  152. package/src/utils/studioHelpers.test.ts +45 -0
  153. package/src/utils/studioHelpers.ts +45 -14
  154. package/src/utils/studioSaveDiagnostics.ts +17 -0
  155. package/dist/assets/index-B_UvTX3E.js +0 -423
  156. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -0,0 +1,1174 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import {
7
+ FlatGroupHeader,
8
+ FlatRow,
9
+ FlatSegmentedRow,
10
+ FlatSelectRow,
11
+ FlatSlider,
12
+ } from "./propertyPanelFlatPrimitives";
13
+
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ afterEach(() => {
17
+ document.body.innerHTML = "";
18
+ });
19
+
20
+ function renderInto(node: React.ReactElement) {
21
+ const host = document.createElement("div");
22
+ document.body.append(host);
23
+ const root = createRoot(host);
24
+ act(() => {
25
+ root.render(node);
26
+ });
27
+ return { host, root };
28
+ }
29
+
30
+ describe("FlatRow", () => {
31
+ it("renders the default tier with no reset button", () => {
32
+ const { host, root } = renderInto(
33
+ <FlatRow label="Weight" value="400 · Regular" tier="default" onCommit={vi.fn()} />,
34
+ );
35
+ const value = host.querySelector('[data-flat-row-value="true"]');
36
+ expect(value?.className).toContain("text-panel-text-3");
37
+ expect(host.querySelector('[data-flat-row-reset="true"]')).toBeNull();
38
+ act(() => root.unmount());
39
+ });
40
+
41
+ it("renders the explicitCustom tier with a mint value and a reset button", () => {
42
+ const onReset = vi.fn();
43
+ const { host, root } = renderInto(
44
+ <FlatRow
45
+ label="Letter spacing"
46
+ value="3.96px"
47
+ tier="explicitCustom"
48
+ onCommit={vi.fn()}
49
+ onReset={onReset}
50
+ />,
51
+ );
52
+ const value = host.querySelector('[data-flat-row-value="true"]');
53
+ expect(value?.className).toContain("text-panel-accent");
54
+ const reset = host.querySelector<HTMLButtonElement>('[data-flat-row-reset="true"]');
55
+ expect(reset).not.toBeNull();
56
+ act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
57
+ expect(onReset).toHaveBeenCalledTimes(1);
58
+ act(() => root.unmount());
59
+ });
60
+
61
+ it("commits edits through the underlying CommitField input", () => {
62
+ const onCommit = vi.fn();
63
+ const { host, root } = renderInto(
64
+ <FlatRow label="Size" value="22px" tier="explicitDefault" onCommit={onCommit} />,
65
+ );
66
+ const input = host.querySelector<HTMLInputElement>("input");
67
+ if (!input) throw new Error("expected an input");
68
+ act(() => {
69
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
70
+ window.HTMLInputElement.prototype,
71
+ "value",
72
+ )?.set;
73
+ nativeInputValueSetter?.call(input, "24px");
74
+ input.dispatchEvent(new Event("input", { bubbles: true }));
75
+ });
76
+ act(() => {
77
+ input.dispatchEvent(new Event("focusout", { bubbles: true }));
78
+ });
79
+ expect(onCommit).toHaveBeenCalledWith("24px");
80
+ act(() => root.unmount());
81
+ });
82
+ });
83
+
84
+ describe("FlatSegmentedRow", () => {
85
+ it("underlines the active option in mint and leaves others muted", () => {
86
+ const onChange = vi.fn();
87
+ const { host, root } = renderInto(
88
+ <FlatSegmentedRow
89
+ label="Align"
90
+ options={[
91
+ { key: "left", node: "L", label: "left", active: false },
92
+ { key: "right", node: "R", label: "right", active: true },
93
+ ]}
94
+ onChange={onChange}
95
+ />,
96
+ );
97
+ const options = host.querySelectorAll('[data-flat-segment="true"]');
98
+ expect(options).toHaveLength(2);
99
+ expect((options[0] as HTMLElement).className).toContain("text-panel-text-4");
100
+ expect((options[1] as HTMLElement).className).toContain("border-panel-accent");
101
+ act(() =>
102
+ (options[0] as HTMLElement).dispatchEvent(new MouseEvent("click", { bubbles: true })),
103
+ );
104
+ expect(onChange).toHaveBeenCalledWith("left");
105
+ act(() => root.unmount());
106
+ });
107
+
108
+ it("gives each option an accessible name and pressed state — glyphs alone (e.g. two 'A' buttons) aren't a valid accessible name", () => {
109
+ const { host, root } = renderInto(
110
+ <FlatSegmentedRow
111
+ label="Case · Style"
112
+ options={[
113
+ { key: "normal", node: "A", label: "upright", active: true },
114
+ { key: "italic", node: "A", label: "italic", active: false },
115
+ ]}
116
+ onChange={vi.fn()}
117
+ />,
118
+ );
119
+ const options = host.querySelectorAll<HTMLButtonElement>('[data-flat-segment="true"]');
120
+ expect(options[0]?.getAttribute("aria-label")).toBe("upright");
121
+ expect(options[0]?.getAttribute("aria-pressed")).toBe("true");
122
+ expect(options[1]?.getAttribute("aria-label")).toBe("italic");
123
+ expect(options[1]?.getAttribute("aria-pressed")).toBe("false");
124
+ act(() => root.unmount());
125
+ });
126
+ });
127
+
128
+ describe("FlatGroupHeader", () => {
129
+ it("renders the open header (name + caret), with no sticky-related props required", () => {
130
+ const onToggleOpen = vi.fn();
131
+ const { host, root } = renderInto(
132
+ <FlatGroupHeader title="Text" isOpen onToggleOpen={onToggleOpen} />,
133
+ );
134
+ expect(host.textContent).toContain("Text");
135
+ const collapse = host.querySelector<HTMLButtonElement>('button[title="Collapse"]');
136
+ act(() => collapse?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
137
+ expect(onToggleOpen).toHaveBeenCalledTimes(1);
138
+ act(() => root.unmount());
139
+ });
140
+
141
+ it("renders the collapsed row (name + summary + caret-right) with no sticky positioning", () => {
142
+ const onToggleOpen = vi.fn();
143
+ const { host, root } = renderInto(
144
+ <FlatGroupHeader
145
+ title="Style"
146
+ isOpen={false}
147
+ onToggleOpen={onToggleOpen}
148
+ summary="fill none · 100%"
149
+ />,
150
+ );
151
+ expect(host.textContent).toContain("fill none · 100%");
152
+ const row = host.querySelector<HTMLButtonElement>('[data-flat-group-collapsed="true"]');
153
+ expect(row?.style.position).toBe("");
154
+ act(() => row?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
155
+ expect(onToggleOpen).toHaveBeenCalledTimes(1);
156
+ act(() => root.unmount());
157
+ });
158
+
159
+ it("applies the entrance animation class to both states, only when animateEntrance is set", () => {
160
+ const { host: openHost, root: openRoot } = renderInto(
161
+ <FlatGroupHeader title="Text" isOpen onToggleOpen={vi.fn()} animateEntrance />,
162
+ );
163
+ expect(openHost.firstElementChild?.className).toContain("hf-flat-group-enter");
164
+ act(() => openRoot.unmount());
165
+
166
+ const { host: collapsedHost, root: collapsedRoot } = renderInto(
167
+ <FlatGroupHeader title="Style" isOpen={false} onToggleOpen={vi.fn()} animateEntrance />,
168
+ );
169
+ const row = collapsedHost.querySelector('[data-flat-group-collapsed="true"]');
170
+ expect(row?.className).toContain("hf-flat-group-enter");
171
+ act(() => collapsedRoot.unmount());
172
+ });
173
+
174
+ it("omits the entrance animation class in both states when animateEntrance is not set", () => {
175
+ const { host: openHost, root: openRoot } = renderInto(
176
+ <FlatGroupHeader title="Text" isOpen onToggleOpen={vi.fn()} />,
177
+ );
178
+ expect(openHost.firstElementChild?.className).not.toContain("hf-flat-group-enter");
179
+ act(() => openRoot.unmount());
180
+
181
+ const { host: collapsedHost, root: collapsedRoot } = renderInto(
182
+ <FlatGroupHeader title="Style" isOpen={false} onToggleOpen={vi.fn()} />,
183
+ );
184
+ const row = collapsedHost.querySelector('[data-flat-group-collapsed="true"]');
185
+ expect(row?.className).not.toContain("hf-flat-group-enter");
186
+ act(() => collapsedRoot.unmount());
187
+ });
188
+
189
+ it("renders no inline position styling in either state (collapsed headers never move)", () => {
190
+ const { host: collapsedHost, root: collapsedRoot } = renderInto(
191
+ <FlatGroupHeader title="Layout" isOpen={false} onToggleOpen={vi.fn()} />,
192
+ );
193
+ const row = collapsedHost.querySelector<HTMLButtonElement>(
194
+ '[data-flat-group-collapsed="true"]',
195
+ );
196
+ expect(row?.getAttribute("style")).toBeNull();
197
+ act(() => collapsedRoot.unmount());
198
+
199
+ const { host: openHost, root: openRoot } = renderInto(
200
+ <FlatGroupHeader title="Motion" isOpen onToggleOpen={vi.fn()} />,
201
+ );
202
+ expect(openHost.textContent).toContain("Motion");
203
+ expect(openHost.querySelector("[style]")).toBeNull();
204
+ act(() => openRoot.unmount());
205
+ });
206
+ });
207
+
208
+ describe("FlatSlider", () => {
209
+ it("renders the default tier with a dim knob at the correct position", () => {
210
+ const { host, root } = renderInto(
211
+ <FlatSlider
212
+ label="Layer blur"
213
+ value={0}
214
+ min={0}
215
+ max={40}
216
+ tier="default"
217
+ displayValue="0px"
218
+ onCommit={vi.fn()}
219
+ />,
220
+ );
221
+ const knob = host.querySelector<HTMLElement>('[data-flat-slider-knob="true"]');
222
+ expect(knob).not.toBeNull();
223
+ expect(knob?.className).toContain("bg-panel-text-4");
224
+ expect(knob?.style.left).toBe("0%");
225
+ const value = host.querySelector('[data-flat-slider-value="true"]');
226
+ expect(value?.className).toContain("text-panel-text-3");
227
+ expect(value?.textContent).toBe("0px");
228
+ act(() => root.unmount());
229
+ });
230
+
231
+ it("renders the explicitCustom tier with a filled track and bright knob", () => {
232
+ const { host, root } = renderInto(
233
+ <FlatSlider
234
+ label="Opacity"
235
+ value={100}
236
+ min={0}
237
+ max={100}
238
+ tier="explicitCustom"
239
+ displayValue="100%"
240
+ onCommit={vi.fn()}
241
+ />,
242
+ );
243
+ const fill = host.querySelector<HTMLElement>('[data-flat-slider-fill="true"]');
244
+ expect(fill?.style.width).toBe("100%");
245
+ const knob = host.querySelector<HTMLElement>('[data-flat-slider-knob="true"]');
246
+ expect(knob?.className).toContain("bg-white");
247
+ act(() => root.unmount());
248
+ });
249
+
250
+ it("commits a value on track click, proportional to click position", () => {
251
+ const onCommit = vi.fn();
252
+ const { host, root } = renderInto(
253
+ <FlatSlider
254
+ label="Opacity"
255
+ value={10}
256
+ min={0}
257
+ max={100}
258
+ tier="explicitCustom"
259
+ displayValue="10%"
260
+ onCommit={onCommit}
261
+ />,
262
+ );
263
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
264
+ if (!track) throw new Error("expected a track element");
265
+ Object.defineProperty(track, "getBoundingClientRect", {
266
+ value: () => ({ left: 0, width: 200, top: 0, height: 2, right: 200, bottom: 2 }),
267
+ });
268
+ act(() => {
269
+ track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
270
+ track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
271
+ });
272
+ expect(onCommit).toHaveBeenCalledWith(50);
273
+ act(() => root.unmount());
274
+ });
275
+
276
+ it("widens the click/drag hit area vertically beyond the thin visible line", () => {
277
+ const onCommit = vi.fn();
278
+ const { host, root } = renderInto(
279
+ <FlatSlider
280
+ label="Opacity"
281
+ value={50}
282
+ min={0}
283
+ max={100}
284
+ tier="explicitCustom"
285
+ displayValue="50%"
286
+ onCommit={onCommit}
287
+ />,
288
+ );
289
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
290
+ if (!track) throw new Error("expected a track element");
291
+ Object.defineProperty(track, "getBoundingClientRect", {
292
+ value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
293
+ });
294
+ act(() => {
295
+ track.dispatchEvent(
296
+ new MouseEvent("pointerdown", { bubbles: true, clientX: 20, clientY: 18 }),
297
+ );
298
+ track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 20 }));
299
+ });
300
+ expect(onCommit).toHaveBeenCalledWith(10);
301
+ act(() => root.unmount());
302
+ });
303
+
304
+ it("tracks the knob instantly on every pointermove during a drag (draft state)", () => {
305
+ const onCommit = vi.fn();
306
+ const { host, root } = renderInto(
307
+ <FlatSlider
308
+ label="Opacity"
309
+ value={50}
310
+ min={0}
311
+ max={100}
312
+ tier="explicitCustom"
313
+ displayValue="50%"
314
+ onCommit={onCommit}
315
+ />,
316
+ );
317
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
318
+ if (!track) throw new Error("expected a track element");
319
+ Object.defineProperty(track, "getBoundingClientRect", {
320
+ value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
321
+ });
322
+ act(() => {
323
+ track.dispatchEvent(
324
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
325
+ );
326
+ });
327
+ // Instant, un-throttled knob feedback via aria-valuenow (draft state) —
328
+ // this must update on every pointermove regardless of the commit throttle.
329
+ expect(track.getAttribute("aria-valuenow")).toBe("10");
330
+ act(() => {
331
+ track.dispatchEvent(
332
+ new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }),
333
+ );
334
+ });
335
+ expect(track.getAttribute("aria-valuenow")).toBe("80");
336
+ act(() => {
337
+ track.dispatchEvent(
338
+ new PointerEvent("pointermove", { bubbles: true, clientX: 100, pointerId: 1 }),
339
+ );
340
+ });
341
+ expect(track.getAttribute("aria-valuenow")).toBe("50");
342
+ act(() => {
343
+ track.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
344
+ });
345
+ act(() => root.unmount());
346
+ });
347
+
348
+ it("throttles rapid drag commits to leading edge + final value on release, not every step", () => {
349
+ const onCommit = vi.fn();
350
+ const { host, root } = renderInto(
351
+ <FlatSlider
352
+ label="Opacity"
353
+ value={5}
354
+ min={0}
355
+ max={100}
356
+ tier="explicitCustom"
357
+ displayValue="5%"
358
+ onCommit={onCommit}
359
+ />,
360
+ );
361
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
362
+ if (!track) throw new Error("expected a track element");
363
+ Object.defineProperty(track, "getBoundingClientRect", {
364
+ value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
365
+ });
366
+ act(() => {
367
+ // pointerdown fires the leading-edge commit immediately — a live
368
+ // preview needs to move the instant the drag starts, not wait 40ms.
369
+ track.dispatchEvent(
370
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
371
+ );
372
+ track.dispatchEvent(
373
+ new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }),
374
+ );
375
+ track.dispatchEvent(
376
+ new PointerEvent("pointermove", { bubbles: true, clientX: 100, pointerId: 1 }),
377
+ );
378
+ });
379
+ // The leading-edge commit (10) fired; the rapid intermediate position (80)
380
+ // from the first pointermove never committed — it's within the 40ms
381
+ // throttle window, so only the trailing flush or the pointerup release
382
+ // gets to send the next value.
383
+ expect(onCommit).toHaveBeenCalledTimes(1);
384
+ expect(onCommit).toHaveBeenCalledWith(10);
385
+ act(() => {
386
+ // Real pointerup events always carry the pointer's true release position
387
+ // (matches the last pointermove) — the handler recomputes from this
388
+ // rather than trusting a possibly-stale `draft` closure.
389
+ track.dispatchEvent(
390
+ new PointerEvent("pointerup", { bubbles: true, clientX: 100, pointerId: 1 }),
391
+ );
392
+ });
393
+ // Release flushes immediately with the LAST position only.
394
+ expect(onCommit).toHaveBeenCalledTimes(2);
395
+ expect(onCommit).toHaveBeenNthCalledWith(2, 50);
396
+ act(() => root.unmount());
397
+ });
398
+
399
+ it("ignores pointermove once a drag has ended (pointer capture released)", () => {
400
+ const onCommit = vi.fn();
401
+ const { host, root } = renderInto(
402
+ <FlatSlider
403
+ label="Opacity"
404
+ value={50}
405
+ min={0}
406
+ max={100}
407
+ tier="explicitCustom"
408
+ displayValue="50%"
409
+ onCommit={onCommit}
410
+ />,
411
+ );
412
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
413
+ if (!track) throw new Error("expected a track element");
414
+ Object.defineProperty(track, "getBoundingClientRect", {
415
+ value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
416
+ });
417
+ act(() => {
418
+ track.dispatchEvent(
419
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
420
+ );
421
+ track.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
422
+ });
423
+ onCommit.mockClear();
424
+ act(() => {
425
+ track.dispatchEvent(
426
+ new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }),
427
+ );
428
+ });
429
+ expect(onCommit).not.toHaveBeenCalled();
430
+ act(() => root.unmount());
431
+ });
432
+
433
+ it("still commits the release position when releasePointerCapture synchronously fires lostpointercapture (real-browser behavior happy-dom doesn't replicate)", () => {
434
+ const onCommit = vi.fn();
435
+ const { host, root } = renderInto(
436
+ <FlatSlider
437
+ label="Opacity"
438
+ value={10}
439
+ min={0}
440
+ max={100}
441
+ tier="explicitCustom"
442
+ displayValue="10%"
443
+ onCommit={onCommit}
444
+ />,
445
+ );
446
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
447
+ if (!track) throw new Error("expected a track element");
448
+ Object.defineProperty(track, "getBoundingClientRect", {
449
+ value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }),
450
+ });
451
+ // Real browsers fire lostpointercapture SYNCHRONOUSLY, mid-call, when
452
+ // releasePointerCapture() is invoked — happy-dom does not replicate this,
453
+ // so patch it in to reproduce the exact reentrancy hazard onPointerUp
454
+ // must guard against.
455
+ const originalRelease = track.releasePointerCapture.bind(track);
456
+ track.releasePointerCapture = (pointerId: number) => {
457
+ originalRelease(pointerId);
458
+ track.dispatchEvent(new Event("lostpointercapture", { bubbles: true }));
459
+ };
460
+ act(() => {
461
+ track.dispatchEvent(
462
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 }),
463
+ );
464
+ });
465
+ act(() => {
466
+ track.dispatchEvent(
467
+ new PointerEvent("pointerup", { bubbles: true, clientX: 80, pointerId: 1 }),
468
+ );
469
+ });
470
+ // The real release position (80), not a rollback to the pre-drag value (10)
471
+ // caused by onLostPointerCapture resyncing mid-handler.
472
+ expect(onCommit).toHaveBeenLastCalledWith(80);
473
+ expect(track.getAttribute("aria-valuenow")).toBe("80");
474
+ act(() => root.unmount());
475
+ });
476
+
477
+ it("Escape during a drag reverts to the pre-drag value and releases pointer capture, instead of leaving the last dragged-to position committed", () => {
478
+ const onCommit = vi.fn();
479
+ const { host, root } = renderInto(
480
+ <FlatSlider
481
+ label="Opacity"
482
+ value={10}
483
+ min={0}
484
+ max={100}
485
+ tier="explicitCustom"
486
+ displayValue="10%"
487
+ onCommit={onCommit}
488
+ />,
489
+ );
490
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
491
+ if (!track) throw new Error("expected a track element");
492
+ Object.defineProperty(track, "getBoundingClientRect", {
493
+ value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }),
494
+ });
495
+ act(() => {
496
+ track.dispatchEvent(
497
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 }),
498
+ );
499
+ });
500
+ // The leading-edge commit already applied the dragged-to value (30).
501
+ expect(onCommit).toHaveBeenLastCalledWith(30);
502
+ act(() => {
503
+ track.dispatchEvent(
504
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
505
+ );
506
+ });
507
+ expect(onCommit).toHaveBeenLastCalledWith(10);
508
+ expect(track.getAttribute("aria-valuenow")).toBe("10");
509
+ expect(track.hasPointerCapture(1)).toBe(false);
510
+ // A subsequent pointermove for the now-released pointer must not resume
511
+ // the cancelled drag.
512
+ onCommit.mockClear();
513
+ act(() => {
514
+ track.dispatchEvent(
515
+ new PointerEvent("pointermove", { bubbles: true, clientX: 80, pointerId: 1 }),
516
+ );
517
+ });
518
+ expect(onCommit).not.toHaveBeenCalled();
519
+ act(() => root.unmount());
520
+ });
521
+
522
+ it("right-click (contextmenu) during a drag cancels it and reverts to the pre-drag value, instead of committing the last dragged-to position", () => {
523
+ const onCommit = vi.fn();
524
+ const { host, root } = renderInto(
525
+ <FlatSlider
526
+ label="Opacity"
527
+ value={10}
528
+ min={0}
529
+ max={100}
530
+ tier="explicitCustom"
531
+ displayValue="10%"
532
+ onCommit={onCommit}
533
+ />,
534
+ );
535
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
536
+ if (!track) throw new Error("expected a track element");
537
+ Object.defineProperty(track, "getBoundingClientRect", {
538
+ value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }),
539
+ });
540
+ act(() => {
541
+ track.dispatchEvent(
542
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 65, pointerId: 1 }),
543
+ );
544
+ });
545
+ expect(onCommit).toHaveBeenLastCalledWith(65);
546
+ const contextMenuEvent = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
547
+ act(() => {
548
+ track.dispatchEvent(contextMenuEvent);
549
+ });
550
+ expect(contextMenuEvent.defaultPrevented).toBe(true);
551
+ expect(onCommit).toHaveBeenLastCalledWith(10);
552
+ expect(track.getAttribute("aria-valuenow")).toBe("10");
553
+ expect(track.hasPointerCapture(1)).toBe(false);
554
+ act(() => root.unmount());
555
+ });
556
+
557
+ it("a native pointercancel during a drag reverts to the pre-drag value, instead of leaving the last dragged-to position committed", () => {
558
+ const onCommit = vi.fn();
559
+ const { host, root } = renderInto(
560
+ <FlatSlider
561
+ label="Opacity"
562
+ value={10}
563
+ min={0}
564
+ max={100}
565
+ tier="explicitCustom"
566
+ displayValue="10%"
567
+ onCommit={onCommit}
568
+ />,
569
+ );
570
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
571
+ if (!track) throw new Error("expected a track element");
572
+ Object.defineProperty(track, "getBoundingClientRect", {
573
+ value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }),
574
+ });
575
+ act(() => {
576
+ track.dispatchEvent(
577
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 65, pointerId: 1 }),
578
+ );
579
+ });
580
+ expect(onCommit).toHaveBeenLastCalledWith(65);
581
+ act(() => {
582
+ track.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId: 1 }));
583
+ });
584
+ expect(onCommit).toHaveBeenLastCalledWith(10);
585
+ expect(track.getAttribute("aria-valuenow")).toBe("10");
586
+ expect(track.hasPointerCapture(1)).toBe(false);
587
+ act(() => root.unmount());
588
+ });
589
+ });
590
+
591
+ describe("FlatSlider — Grade extensions", () => {
592
+ it("renders a center tick when centerTick is true, and omits it by default", () => {
593
+ const { host: withTick, root: rootA } = renderInto(
594
+ <FlatSlider
595
+ label="Exposure"
596
+ value={0}
597
+ min={-100}
598
+ max={100}
599
+ tier="default"
600
+ displayValue="+0.00"
601
+ centerTick
602
+ onCommit={vi.fn()}
603
+ />,
604
+ );
605
+ expect(withTick.querySelector('[data-flat-slider-center-tick="true"]')).not.toBeNull();
606
+ act(() => rootA.unmount());
607
+
608
+ const { host: withoutTick, root: rootB } = renderInto(
609
+ <FlatSlider
610
+ label="Layer blur"
611
+ value={0}
612
+ min={0}
613
+ max={100}
614
+ tier="default"
615
+ displayValue="0px"
616
+ onCommit={vi.fn()}
617
+ />,
618
+ );
619
+ expect(withoutTick.querySelector('[data-flat-slider-center-tick="true"]')).toBeNull();
620
+ act(() => rootB.unmount());
621
+ });
622
+
623
+ it("always reserves a 14px reset slot, showing the icon only when set and onReset is provided", () => {
624
+ const onReset = vi.fn();
625
+ const { host, root } = renderInto(
626
+ <FlatSlider
627
+ label="Contrast"
628
+ value={12}
629
+ min={-100}
630
+ max={100}
631
+ tier="explicitCustom"
632
+ displayValue="+12%"
633
+ centerTick
634
+ onReset={onReset}
635
+ onCommit={vi.fn()}
636
+ />,
637
+ );
638
+ const slot = host.querySelector('[data-flat-slider-reset-slot="true"]');
639
+ expect(slot).not.toBeNull();
640
+ const resetButton = host.querySelector<HTMLButtonElement>('[data-flat-slider-reset="true"]');
641
+ expect(resetButton).not.toBeNull();
642
+ act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
643
+ expect(onReset).toHaveBeenCalledTimes(1);
644
+ act(() => root.unmount());
645
+
646
+ const { host: unsetHost, root: rootB } = renderInto(
647
+ <FlatSlider
648
+ label="Contrast"
649
+ value={0}
650
+ min={-100}
651
+ max={100}
652
+ tier="default"
653
+ displayValue="0%"
654
+ centerTick
655
+ onCommit={vi.fn()}
656
+ />,
657
+ );
658
+ expect(unsetHost.querySelector('[data-flat-slider-reset-slot="true"]')).not.toBeNull();
659
+ expect(unsetHost.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
660
+ act(() => rootB.unmount());
661
+ });
662
+
663
+ it("renders no reset slot at all when neither centerTick nor onReset is provided", () => {
664
+ const { host, root } = renderInto(
665
+ <FlatSlider
666
+ label="Opacity"
667
+ value={100}
668
+ min={0}
669
+ max={100}
670
+ tier="explicitCustom"
671
+ displayValue="100%"
672
+ onCommit={vi.fn()}
673
+ />,
674
+ );
675
+ expect(host.querySelector('[data-flat-slider-reset-slot="true"]')).toBeNull();
676
+ expect(host.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
677
+ act(() => root.unmount());
678
+ });
679
+
680
+ it("shows a reachable reset button on a non-centerTick slider that passes onReset (Grade Vignette/Effects)", () => {
681
+ const onReset = vi.fn();
682
+ const { host, root } = renderInto(
683
+ <FlatSlider
684
+ label="Vignette"
685
+ value={18}
686
+ min={0}
687
+ max={100}
688
+ tier="explicitCustom"
689
+ displayValue="18%"
690
+ onReset={onReset}
691
+ onCommit={vi.fn()}
692
+ />,
693
+ );
694
+ const resetButton = host.querySelector<HTMLButtonElement>('[data-flat-slider-reset="true"]');
695
+ expect(resetButton).not.toBeNull();
696
+ act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
697
+ expect(onReset).toHaveBeenCalledTimes(1);
698
+ act(() => root.unmount());
699
+ });
700
+
701
+ it("never commits from a click released on a disabled slider", () => {
702
+ const onCommit = vi.fn();
703
+ const { host, root } = renderInto(
704
+ <FlatSlider
705
+ label="Opacity"
706
+ value={100}
707
+ min={0}
708
+ max={100}
709
+ tier="default"
710
+ displayValue="100%"
711
+ disabled
712
+ onCommit={onCommit}
713
+ />,
714
+ );
715
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
716
+ if (!track) throw new Error("expected a track element");
717
+ Object.defineProperty(track, "getBoundingClientRect", {
718
+ value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
719
+ });
720
+ act(() => {
721
+ track.dispatchEvent(
722
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 50, pointerId: 1 }),
723
+ );
724
+ track.dispatchEvent(
725
+ new PointerEvent("pointerup", { bubbles: true, clientX: 50, pointerId: 1 }),
726
+ );
727
+ });
728
+ expect(onCommit).not.toHaveBeenCalled();
729
+ act(() => root.unmount());
730
+ });
731
+
732
+ it("disables the reset button when the slider itself is disabled", () => {
733
+ const onReset = vi.fn();
734
+ const { host, root } = renderInto(
735
+ <FlatSlider
736
+ label="Exposure"
737
+ value={20}
738
+ min={0}
739
+ max={100}
740
+ tier="explicitCustom"
741
+ displayValue="20"
742
+ disabled
743
+ onReset={onReset}
744
+ onCommit={vi.fn()}
745
+ />,
746
+ );
747
+ const resetButton = host.querySelector<HTMLButtonElement>('[data-flat-slider-reset="true"]');
748
+ expect(resetButton?.disabled).toBe(true);
749
+ act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
750
+ expect(onReset).not.toHaveBeenCalled();
751
+ act(() => root.unmount());
752
+ });
753
+
754
+ it("a trailing throttled commit uses the current render's onCommit, not the one captured when it was scheduled", () => {
755
+ vi.useFakeTimers();
756
+ const onCommitA = vi.fn();
757
+ const { host, root } = renderInto(
758
+ <FlatSlider
759
+ label="Exposure"
760
+ value={0}
761
+ min={-100}
762
+ max={100}
763
+ tier="explicitCustom"
764
+ displayValue="0"
765
+ onCommit={onCommitA}
766
+ />,
767
+ );
768
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
769
+ if (!track) throw new Error("expected a track element");
770
+ Object.defineProperty(track, "getBoundingClientRect", {
771
+ value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
772
+ });
773
+ act(() => {
774
+ // Leading-edge commit fires synchronously with onCommitA (clientX 150
775
+ // on a -100..100 track maps to 50, distinct from the initial value 0).
776
+ track.dispatchEvent(
777
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 150, pointerId: 1 }),
778
+ );
779
+ });
780
+ expect(onCommitA).toHaveBeenCalledTimes(1);
781
+ act(() => {
782
+ // Within the 40ms throttle window — queues a trailing commit (to 80,
783
+ // distinct from the just-committed 50) instead of firing immediately.
784
+ track.dispatchEvent(
785
+ new PointerEvent("pointermove", { bubbles: true, clientX: 180, pointerId: 1 }),
786
+ );
787
+ });
788
+ expect(onCommitA).toHaveBeenCalledTimes(1);
789
+ // Simulate the real-world race: something else causes this slider to
790
+ // re-render with a NEW onCommit closure before the queued timer fires
791
+ // (e.g. Grade's per-detail onCommit spreads the render-time whole
792
+ // grading object, so a different control committing in between produces
793
+ // a fresh closure). The stale closure must not win.
794
+ const onCommitB = vi.fn();
795
+ act(() => {
796
+ root.render(
797
+ <FlatSlider
798
+ label="Exposure"
799
+ value={0}
800
+ min={-100}
801
+ max={100}
802
+ tier="explicitCustom"
803
+ displayValue="0"
804
+ onCommit={onCommitB}
805
+ />,
806
+ );
807
+ });
808
+ act(() => {
809
+ vi.advanceTimersByTime(45);
810
+ });
811
+ expect(onCommitB).toHaveBeenCalledTimes(1);
812
+ expect(onCommitB).toHaveBeenCalledWith(80);
813
+ expect(onCommitA).toHaveBeenCalledTimes(1);
814
+ act(() => root.unmount());
815
+ vi.useRealTimers();
816
+ });
817
+
818
+ it("flushes a still-queued trailing commit on unmount instead of dropping it", () => {
819
+ vi.useFakeTimers();
820
+ const onCommit = vi.fn();
821
+ const { host, root } = renderInto(
822
+ <FlatSlider
823
+ label="Opacity"
824
+ value={5}
825
+ min={0}
826
+ max={100}
827
+ tier="explicitCustom"
828
+ displayValue="5%"
829
+ onCommit={onCommit}
830
+ />,
831
+ );
832
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
833
+ if (!track) throw new Error("expected a track element");
834
+ Object.defineProperty(track, "getBoundingClientRect", {
835
+ value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
836
+ });
837
+ act(() => {
838
+ track.dispatchEvent(
839
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
840
+ );
841
+ });
842
+ expect(onCommit).toHaveBeenCalledTimes(1);
843
+ act(() => {
844
+ // Queues a trailing commit that never gets to fire before unmount.
845
+ track.dispatchEvent(
846
+ new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }),
847
+ );
848
+ });
849
+ expect(onCommit).toHaveBeenCalledTimes(1);
850
+ act(() => root.unmount());
851
+ expect(onCommit).toHaveBeenCalledTimes(2);
852
+ expect(onCommit).toHaveBeenNthCalledWith(2, 80);
853
+ vi.useRealTimers();
854
+ });
855
+
856
+ it("supports keyboard operation: focusable, arrow keys step, Home/End clamp to range", () => {
857
+ const onCommit = vi.fn();
858
+ const { host, root } = renderInto(
859
+ <FlatSlider
860
+ label="Volume"
861
+ value={50}
862
+ min={0}
863
+ max={100}
864
+ tier="default"
865
+ displayValue="50%"
866
+ onCommit={onCommit}
867
+ />,
868
+ );
869
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
870
+ if (!track) throw new Error("expected a track element");
871
+ expect(track.getAttribute("tabindex")).toBe("0");
872
+ expect(track.getAttribute("aria-valuemin")).toBe("0");
873
+ expect(track.getAttribute("aria-valuemax")).toBe("100");
874
+ act(() => {
875
+ track.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
876
+ });
877
+ expect(onCommit).toHaveBeenLastCalledWith(51);
878
+ act(() => {
879
+ track.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
880
+ });
881
+ expect(onCommit).toHaveBeenLastCalledWith(0);
882
+ act(() => root.unmount());
883
+ });
884
+
885
+ it("ignores the committed prop echoing back mid-drag (no knob snap-back)", () => {
886
+ const onCommit = vi.fn();
887
+ function Harness() {
888
+ const [value, setValue] = React.useState(10);
889
+ return (
890
+ <FlatSlider
891
+ label="Opacity"
892
+ value={value}
893
+ min={0}
894
+ max={100}
895
+ tier="explicitCustom"
896
+ displayValue={`${value}%`}
897
+ onCommit={(next) => {
898
+ onCommit(next);
899
+ setValue(next);
900
+ }}
901
+ />
902
+ );
903
+ }
904
+ const { host, root } = renderInto(<Harness />);
905
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
906
+ if (!track) throw new Error("expected a track element");
907
+ Object.defineProperty(track, "getBoundingClientRect", {
908
+ value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }),
909
+ });
910
+ act(() => {
911
+ // Leading-edge commit fires at 30 and echoes back through the parent's
912
+ // state — mid-drag, that echo must NOT reset the draft.
913
+ track.dispatchEvent(
914
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 }),
915
+ );
916
+ });
917
+ act(() => {
918
+ track.dispatchEvent(
919
+ new PointerEvent("pointermove", { bubbles: true, clientX: 80, pointerId: 1 }),
920
+ );
921
+ });
922
+ // Draft tracks the pointer (80), not the stale committed echo (30).
923
+ expect(track.getAttribute("aria-valuenow")).toBe("80");
924
+ act(() => {
925
+ track.dispatchEvent(
926
+ new PointerEvent("pointerup", { bubbles: true, clientX: 80, pointerId: 1 }),
927
+ );
928
+ });
929
+ expect(onCommit).toHaveBeenLastCalledWith(80);
930
+ expect(track.getAttribute("aria-valuenow")).toBe("80");
931
+ act(() => root.unmount());
932
+ });
933
+
934
+ it("resets the dragging state on lostpointercapture even without a prior pointerup/pointercancel", () => {
935
+ const { host, root } = renderInto(
936
+ <FlatSlider
937
+ label="Opacity"
938
+ value={10}
939
+ min={0}
940
+ max={100}
941
+ tier="explicitCustom"
942
+ displayValue="10%"
943
+ onCommit={vi.fn()}
944
+ />,
945
+ );
946
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
947
+ if (!track) throw new Error("expected a track element");
948
+ Object.defineProperty(track, "getBoundingClientRect", {
949
+ value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }),
950
+ });
951
+ act(() => {
952
+ track.dispatchEvent(
953
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 }),
954
+ );
955
+ });
956
+ expect(track.getAttribute("aria-valuenow")).toBe("30");
957
+ act(() => {
958
+ // Capture lost WITHOUT a pointerup/pointercancel first — e.g. another
959
+ // element steals it, or the browser reclaims it for a scroll gesture.
960
+ track.dispatchEvent(new Event("lostpointercapture", { bubbles: true }));
961
+ });
962
+ act(() => {
963
+ root.render(
964
+ <FlatSlider
965
+ label="Opacity"
966
+ value={99}
967
+ min={0}
968
+ max={100}
969
+ tier="explicitCustom"
970
+ displayValue="99%"
971
+ onCommit={vi.fn()}
972
+ />,
973
+ );
974
+ });
975
+ // If lostpointercapture hadn't cleared the dragging flag, this external
976
+ // value change would be silently ignored (mid-drag echo suppression)
977
+ // forever — the knob would be stuck at 30.
978
+ expect(track.getAttribute("aria-valuenow")).toBe("99");
979
+ act(() => root.unmount());
980
+ });
981
+
982
+ it("resyncs immediately from the latest value on lostpointercapture, even when the value changed WHILE still dragging", () => {
983
+ const { host, root } = renderInto(
984
+ <FlatSlider
985
+ label="Opacity"
986
+ value={10}
987
+ min={0}
988
+ max={100}
989
+ tier="explicitCustom"
990
+ displayValue="10%"
991
+ onCommit={vi.fn()}
992
+ />,
993
+ );
994
+ const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
995
+ if (!track) throw new Error("expected a track element");
996
+ Object.defineProperty(track, "getBoundingClientRect", {
997
+ value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }),
998
+ });
999
+ act(() => {
1000
+ track.dispatchEvent(
1001
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 }),
1002
+ );
1003
+ });
1004
+ expect(track.getAttribute("aria-valuenow")).toBe("30");
1005
+ // Value changes to 99 WHILE still dragging — the [value] sync effect
1006
+ // must skip it (draggingRef is still true), so draft stays at 30.
1007
+ act(() => {
1008
+ root.render(
1009
+ <FlatSlider
1010
+ label="Opacity"
1011
+ value={99}
1012
+ min={0}
1013
+ max={100}
1014
+ tier="explicitCustom"
1015
+ displayValue="99%"
1016
+ onCommit={vi.fn()}
1017
+ />,
1018
+ );
1019
+ });
1020
+ expect(track.getAttribute("aria-valuenow")).toBe("30");
1021
+ act(() => {
1022
+ // Capture lost with NO further render afterward — if the resync
1023
+ // depended on a subsequent [value] effect run rather than reading
1024
+ // latestValueRef directly, this would leave the knob stuck at 30.
1025
+ track.dispatchEvent(new Event("lostpointercapture", { bubbles: true }));
1026
+ });
1027
+ expect(track.getAttribute("aria-valuenow")).toBe("99");
1028
+ act(() => root.unmount());
1029
+ });
1030
+ });
1031
+
1032
+ describe("FlatSelectRow", () => {
1033
+ it("renders the default tier with no reset button", () => {
1034
+ const { host, root } = renderInto(
1035
+ <FlatSelectRow
1036
+ label="Blend"
1037
+ value="normal"
1038
+ options={["normal", "multiply", "screen"]}
1039
+ tier="default"
1040
+ onChange={vi.fn()}
1041
+ />,
1042
+ );
1043
+ const select = host.querySelector("select");
1044
+ expect(select?.value).toBe("normal");
1045
+ expect(host.querySelector('[data-flat-select-reset="true"]')).toBeNull();
1046
+ act(() => root.unmount());
1047
+ });
1048
+
1049
+ it("renders the explicitCustom tier with a reset button and fires onReset", () => {
1050
+ const onReset = vi.fn();
1051
+ const { host, root } = renderInto(
1052
+ <FlatSelectRow
1053
+ label="Shadow"
1054
+ value="soft"
1055
+ options={["none", "soft", "lift", "glow"]}
1056
+ tier="explicitCustom"
1057
+ onChange={vi.fn()}
1058
+ onReset={onReset}
1059
+ />,
1060
+ );
1061
+ const select = host.querySelector<HTMLSelectElement>("select");
1062
+ expect(select?.className).toContain("text-panel-accent");
1063
+ const reset = host.querySelector<HTMLButtonElement>('[data-flat-select-reset="true"]');
1064
+ act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
1065
+ expect(onReset).toHaveBeenCalledTimes(1);
1066
+ act(() => root.unmount());
1067
+ });
1068
+
1069
+ it("disables the reset button (and gives the select an accessible name) when the row itself is disabled", () => {
1070
+ const onReset = vi.fn();
1071
+ const { host, root } = renderInto(
1072
+ <FlatSelectRow
1073
+ label="Shadow"
1074
+ value="soft"
1075
+ options={["none", "soft", "lift", "glow"]}
1076
+ tier="explicitCustom"
1077
+ disabled
1078
+ onChange={vi.fn()}
1079
+ onReset={onReset}
1080
+ />,
1081
+ );
1082
+ const select = host.querySelector<HTMLSelectElement>("select");
1083
+ expect(select?.getAttribute("aria-label")).toBe("Shadow");
1084
+ const reset = host.querySelector<HTMLButtonElement>('[data-flat-select-reset="true"]');
1085
+ expect(reset?.disabled).toBe(true);
1086
+ act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
1087
+ expect(onReset).not.toHaveBeenCalled();
1088
+ act(() => root.unmount());
1089
+ });
1090
+
1091
+ it("fires onChange when the select value changes", () => {
1092
+ const onChange = vi.fn();
1093
+ const { host, root } = renderInto(
1094
+ <FlatSelectRow
1095
+ label="Overflow"
1096
+ value="visible"
1097
+ options={["visible", "hidden", "clip", "auto", "scroll"]}
1098
+ tier="default"
1099
+ onChange={onChange}
1100
+ />,
1101
+ );
1102
+ const select = host.querySelector<HTMLSelectElement>("select");
1103
+ if (!select) throw new Error("expected a select");
1104
+ act(() => {
1105
+ select.value = "hidden";
1106
+ select.dispatchEvent(new Event("change", { bubbles: true }));
1107
+ });
1108
+ expect(onChange).toHaveBeenCalledWith("hidden");
1109
+ act(() => root.unmount());
1110
+ });
1111
+ });
1112
+
1113
+ describe("FlatSelectRow — label/value options", () => {
1114
+ it("renders distinct labels for entries with a different display label than value", () => {
1115
+ const { host, root } = renderInto(
1116
+ <FlatSelectRow
1117
+ label="Preset"
1118
+ value="natural-lift"
1119
+ options={[
1120
+ { value: "neutral", label: "Neutral" },
1121
+ { value: "natural-lift", label: "Natural Lift" },
1122
+ { value: "fresh-pop", label: "Fresh Pop" },
1123
+ ]}
1124
+ tier="explicitCustom"
1125
+ onChange={vi.fn()}
1126
+ />,
1127
+ );
1128
+ const select = host.querySelector("select");
1129
+ expect(select?.value).toBe("natural-lift");
1130
+ const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent);
1131
+ expect(options).toEqual(["Neutral", "Natural Lift", "Fresh Pop"]);
1132
+ act(() => root.unmount());
1133
+ });
1134
+
1135
+ it("still treats a bare string array as value===label (Plan 2 behavior unchanged)", () => {
1136
+ const { host, root } = renderInto(
1137
+ <FlatSelectRow
1138
+ label="Blend"
1139
+ value="multiply"
1140
+ options={["normal", "multiply", "screen"]}
1141
+ tier="explicitCustom"
1142
+ onChange={vi.fn()}
1143
+ />,
1144
+ );
1145
+ const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent);
1146
+ expect(options).toEqual(["normal", "multiply", "screen"]);
1147
+ act(() => root.unmount());
1148
+ });
1149
+
1150
+ it("preserves a valid authored value outside the preset list instead of misrepresenting it as the first option", () => {
1151
+ const onChange = vi.fn();
1152
+ const { host, root } = renderInto(
1153
+ <FlatSelectRow
1154
+ label="Blend"
1155
+ value="difference"
1156
+ options={["normal", "multiply", "screen", "overlay"]}
1157
+ tier="explicitCustom"
1158
+ onChange={onChange}
1159
+ />,
1160
+ );
1161
+ const select = host.querySelector<HTMLSelectElement>("select");
1162
+ // A native <select> whose `value` matches no <option> falls back to
1163
+ // selectedIndex 0 — silently showing "normal" as selected even though
1164
+ // the real persisted value is "difference". The row must add an option
1165
+ // for the current value so it's genuinely representable.
1166
+ expect(select?.value).toBe("difference");
1167
+ const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent);
1168
+ expect(options).toContain("difference");
1169
+ // And reselecting the (still-present) first preset must be an explicit
1170
+ // user choice, not something that already happened silently.
1171
+ expect(onChange).not.toHaveBeenCalled();
1172
+ act(() => root.unmount());
1173
+ });
1174
+ });