@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.
- package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
- package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
- package/dist/assets/index-CrkAdJkb.js +426 -0
- package/dist/assets/index-Dj5p8U_A.css +1 -0
- package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.html +2 -2
- package/dist/index.js +13514 -8528
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioRightPanel.tsx +29 -51
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineTrackVisibility.test.ts +52 -0
- package/src/hooks/timelineTrackVisibility.ts +27 -10
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.ts +29 -175
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.ts +17 -5
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useElementLifecycleOps.ts +8 -3
- package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
- package/src/hooks/useFileManager.ts +82 -60
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +4 -1
- package/src/hooks/useTimelineEditing.ts +16 -10
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTimelineGroupEditing.ts +16 -2
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +490 -57
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +292 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +30 -27
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.ts +17 -0
- package/dist/assets/index-B_UvTX3E.js +0 -423
- 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
|
+
});
|