@hyperframes/studio 0.8.51 → 0.8.52
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-Cn_PpHqP.js → hyperframes-player-I5YRzB02.js} +1 -1
- package/dist/assets/{index-Clrb11lj.js → index-BTAfgesM.js} +1 -1
- package/dist/assets/index-CxAu_Okg.css +1 -0
- package/dist/assets/index-DocJlW2v.js +376 -0
- package/dist/assets/{index-CPS3Cvj8.js → index-Du5x7KUR.js} +1 -1
- package/dist/index.d.ts +401 -7
- package/dist/index.html +2 -2
- package/dist/index.js +6545 -5869
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
- package/src/App.tsx +3 -2
- package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
- package/src/captions/components/CaptionOverlay.tsx +8 -4
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/RightPanelTabs.test.tsx +151 -0
- package/src/components/RightPanelTabs.tsx +59 -0
- package/src/components/StudioHeader.dom.test.tsx +181 -0
- package/src/components/StudioHeader.tsx +109 -149
- package/src/components/StudioRightPanel.tsx +47 -35
- package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
- package/src/components/TimelineHistoryButtons.tsx +54 -0
- package/src/components/TimelineToolbar.test.tsx +8 -0
- package/src/components/TimelineToolbar.tsx +3 -7
- package/src/components/dock/Dock.test.tsx +84 -16
- package/src/components/dock/Dock.tsx +19 -2
- package/src/components/dock/dock.css +5 -0
- package/src/components/dock/dockAccessibility.test.ts +166 -0
- package/src/components/dock/dockAccessibility.ts +98 -0
- package/src/components/dock/dockLayout.test.ts +134 -0
- package/src/components/dock/dockLayout.ts +53 -6
- package/src/components/dock/dockTestHarness.ts +11 -0
- package/src/components/editor/LayersPanel.test.ts +97 -1
- package/src/components/editor/LayersPanel.tsx +4 -2
- package/src/components/editor/PreviewGuides.tsx +1 -1
- package/src/components/editor/TopologyLens.test.tsx +28 -0
- package/src/components/editor/TopologyLens.tsx +3 -1
- package/src/components/editor/useColorGradingController.test.ts +38 -0
- package/src/components/editor/useColorGradingController.ts +9 -1
- package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
- package/src/components/editor/useColorGradingPreviews.ts +11 -1
- package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
- package/src/components/editor/usePreviewCompositionRect.ts +21 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewPane.tsx +1 -1
- package/src/components/pointerTargetSize.test.tsx +8 -0
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/timelineToolbarStyles.ts +5 -0
- package/src/components/ui/Button.test.tsx +222 -30
- package/src/components/ui/Button.tsx +88 -125
- package/src/components/ui/IconButton.tsx +38 -0
- package/src/components/ui/Input.tsx +115 -0
- package/src/components/ui/Menu.test.tsx +374 -0
- package/src/components/ui/Menu.tsx +213 -0
- package/src/components/ui/NumberField.tsx +122 -0
- package/src/components/ui/Popover.tsx +74 -0
- package/src/components/ui/Select.tsx +100 -0
- package/src/components/ui/Slider.tsx +153 -0
- package/src/components/ui/Tabs.test.tsx +98 -0
- package/src/components/ui/Tabs.tsx +71 -0
- package/src/components/ui/Toggle.tsx +63 -0
- package/src/components/ui/Tooltip.test.tsx +110 -0
- package/src/components/ui/Tooltip.tsx +38 -112
- package/src/components/ui/cn.test.ts +30 -0
- package/src/components/ui/cn.ts +25 -0
- package/src/components/ui/index.ts +29 -2
- package/src/components/ui/mountHost.testHelpers.ts +24 -0
- package/src/components/ui/valueControls.test.tsx +524 -0
- package/src/hooks/useAppHotkeys.ts +20 -79
- package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useEditHistoryActions.test.tsx +90 -0
- package/src/hooks/useEditHistoryActions.ts +106 -0
- package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
- package/src/hooks/useServerConnection.ts +43 -10
- package/src/index.ts +51 -0
- package/src/player/components/AutomationSelectionMenu.tsx +1 -1
- package/src/player/components/TimelineCanvas.tsx +4 -4
- package/src/player/components/TimelineClip.tsx +0 -1
- package/src/player/lib/playbackShortcuts.ts +2 -0
- package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
- package/src/player/store/previewIframeStore.test.tsx +29 -0
- package/src/player/store/previewIframeStore.ts +25 -0
- package/src/renderQueueExports.test.tsx +58 -0
- package/src/styles/classCandidates.test.ts +105 -0
- package/src/styles/classCandidates.ts +281 -0
- package/src/styles/hex-baseline.json +84 -0
- package/src/styles/hexRatchet.test.ts +201 -0
- package/src/styles/styleSources.test.ts +20 -0
- package/src/styles/styleSources.ts +47 -0
- package/src/styles/theme.css +30 -0
- package/src/styles/theme.test.ts +2 -15
- package/src/styles/tokenGate.test.ts +109 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +11 -1
- package/src/utils/typingTarget.ts +4 -0
- package/dist/assets/index-C05SNrvY.js +0 -360
- package/dist/assets/index-DUR9qzQM.css +0 -1
- package/src/components/PanelTabButton.tsx +0 -31
|
@@ -1,57 +1,249 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Button/IconButton: renders every variant and size, then compiles Studio's
|
|
5
|
+
* real stylesheet against the emitted classes so an unstyled class fails.
|
|
6
|
+
*/
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import React, { act } from "react";
|
|
11
|
+
import { compile } from "tailwindcss";
|
|
12
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
13
|
+
import { Button, type ButtonSize, type ButtonVariant } from "./Button";
|
|
14
|
+
import { IconButton } from "./IconButton";
|
|
15
|
+
import { Tab, Tabs, TabsList } from "./Tabs";
|
|
16
|
+
import { cleanupMounted, mountHost } from "./mountHost.testHelpers";
|
|
17
|
+
import { isTypingTarget } from "../../utils/typingTarget";
|
|
18
|
+
import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
|
|
6
19
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
10
|
-
import { Button } from "./Button";
|
|
20
|
+
const VARIANTS: ButtonVariant[] = ["primary", "secondary", "danger", "ghost"];
|
|
21
|
+
const SIZES: ButtonSize[] = ["sm", "md", "lg"];
|
|
11
22
|
|
|
12
|
-
|
|
23
|
+
afterEach(cleanupMounted);
|
|
13
24
|
|
|
14
|
-
|
|
25
|
+
function classesOf(host: HTMLElement, selector = "button"): string[] {
|
|
26
|
+
const el = host.querySelector(selector);
|
|
27
|
+
if (!el) throw new Error(`nothing matched ${selector}`);
|
|
28
|
+
return [...el.classList];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// -- the compiled stylesheet, built once for the whole file --
|
|
15
32
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
33
|
+
const STYLES_DIR = path.resolve(__dirname, "../../styles");
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const TAILWIND_DIR = path.dirname(require.resolve("tailwindcss/package.json"));
|
|
36
|
+
|
|
37
|
+
async function loadStylesheet(id: string, base: string) {
|
|
38
|
+
const file = id === "tailwindcss" ? path.join(TAILWIND_DIR, "index.css") : path.resolve(base, id);
|
|
39
|
+
return { path: file, base: path.dirname(file), content: readFileSync(file, "utf8") };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let compileStudioCss: (candidates: string[]) => string;
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
const compiled = await compile(readFileSync(path.join(STYLES_DIR, "studio.css"), "utf8"), {
|
|
46
|
+
base: STYLES_DIR,
|
|
47
|
+
loadStylesheet,
|
|
48
|
+
});
|
|
49
|
+
compileStudioCss = (candidates) => compiled.build(candidates);
|
|
20
50
|
});
|
|
21
51
|
|
|
22
|
-
function
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
root = createRoot(host);
|
|
26
|
-
act(() => root?.render(element));
|
|
27
|
-
const button = host.querySelector("button");
|
|
28
|
-
if (!button) throw new Error("button did not render");
|
|
29
|
-
return button;
|
|
52
|
+
function unresolved(candidates: string[]): string[] {
|
|
53
|
+
const css = compileStudioCss(candidates);
|
|
54
|
+
return candidates.filter((candidate) => !css.includes(CSS.escape(candidate)));
|
|
30
55
|
}
|
|
31
56
|
|
|
32
|
-
describe("Button
|
|
33
|
-
it("
|
|
34
|
-
const
|
|
57
|
+
describe("Button classes", () => {
|
|
58
|
+
it("emits only classes Studio's stylesheet defines", () => {
|
|
59
|
+
const emitted = new Set<string>();
|
|
60
|
+
for (const variant of VARIANTS) {
|
|
61
|
+
for (const size of SIZES) {
|
|
62
|
+
const host = mountHost(
|
|
63
|
+
<>
|
|
64
|
+
<Button variant={variant} size={size}>
|
|
65
|
+
Export
|
|
66
|
+
</Button>
|
|
67
|
+
<IconButton variant={variant} size={size} icon={null} aria-label="Zoom out" />
|
|
68
|
+
</>,
|
|
69
|
+
);
|
|
70
|
+
for (const el of host.querySelectorAll("button")) {
|
|
71
|
+
for (const cls of el.classList) emitted.add(cls);
|
|
72
|
+
}
|
|
73
|
+
cleanupMounted();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
expect(emitted.size).toBeGreaterThan(20);
|
|
78
|
+
expect(unresolved([...emitted])).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("fails an undefined class, so the check above is not vacuous", () => {
|
|
82
|
+
// Names in a real namespace with no token behind them; the theme file now defines the
|
|
83
|
+
// `rounded-button` pair this used to name.
|
|
84
|
+
expect(unresolved(["bg-not-a-token", "text-step-999"])).toEqual([
|
|
85
|
+
"bg-not-a-token",
|
|
86
|
+
"text-step-999",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("lets a caller's type size beat the size's own", () => {
|
|
91
|
+
// The Renders Export case: `size="md"` brings `text-step-12`, the caller
|
|
92
|
+
// wants 11px, and with plain concatenation both survived into the class
|
|
93
|
+
// list and the stylesheet's order decided the winner.
|
|
94
|
+
const classes = classesOf(mountHost(<Button size="md" className="text-step-11" />));
|
|
95
|
+
|
|
96
|
+
expect(classes).toContain("text-step-11");
|
|
97
|
+
expect(classes).not.toContain("text-step-12");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("keeps every interactive look reachable without a pointer", () => {
|
|
101
|
+
// `data-preview-state` exists so a gallery can show the hover, active and
|
|
102
|
+
// focus looks. It is worth nothing if it drifts from the real state, so
|
|
103
|
+
// each pair is matched here in both directions.
|
|
104
|
+
const realPrefix = {
|
|
105
|
+
hover: ["enabled:hover:", "hover:"],
|
|
106
|
+
active: ["enabled:active:", "active:"],
|
|
107
|
+
focus: ["focus-visible:"],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
for (const variant of VARIANTS) {
|
|
111
|
+
const classes = classesOf(mountHost(<Button variant={variant}>Export</Button>));
|
|
112
|
+
cleanupMounted();
|
|
113
|
+
|
|
114
|
+
for (const [state, prefixes] of Object.entries(realPrefix)) {
|
|
115
|
+
const previewPrefix = `data-[preview-state=${state}]:`;
|
|
116
|
+
const preview = classes
|
|
117
|
+
.filter((cls) => cls.startsWith(previewPrefix))
|
|
118
|
+
.map((cls) => cls.slice(previewPrefix.length));
|
|
119
|
+
const real = classes
|
|
120
|
+
.filter((cls) => prefixes.some((prefix) => cls.startsWith(prefix)))
|
|
121
|
+
.map((cls) => cls.slice(cls.lastIndexOf(":") + 1));
|
|
122
|
+
|
|
123
|
+
expect([...preview].sort(), `${variant} ${state}`).toEqual([...real].sort());
|
|
124
|
+
expect(preview.length, `${variant} ${state}`).toBeGreaterThan(0);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("uses a motion token that zeroes itself under reduced motion", () => {
|
|
130
|
+
// AE4. The zero-duration case lives in the `duration-press` utility itself
|
|
131
|
+
// (theme.css), not in a `motion-reduce:` class beside it, so a caller
|
|
132
|
+
// cannot use the token and forget the reduced-motion half. The button's
|
|
133
|
+
// job is to name the token; the stylesheet's job is the media query.
|
|
134
|
+
expect(classesOf(mountHost(<Button>Export</Button>))).toContain("duration-press");
|
|
135
|
+
|
|
136
|
+
expect(compileStudioCss(["duration-press"])).toMatch(
|
|
137
|
+
/\.duration-press \{[\s\S]*?prefers-reduced-motion: reduce[\s\S]*?transition-duration: 0ms/,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("Button behaviour", () => {
|
|
143
|
+
it("does not fire a disabled click, and says it is disabled", () => {
|
|
144
|
+
let clicks = 0;
|
|
145
|
+
const host = mountHost(
|
|
146
|
+
<Button disabled onClick={() => (clicks += 1)}>
|
|
147
|
+
Export
|
|
148
|
+
</Button>,
|
|
149
|
+
);
|
|
150
|
+
const button = host.querySelector("button")!;
|
|
151
|
+
|
|
152
|
+
act(() => button.click());
|
|
153
|
+
|
|
154
|
+
expect(clicks).toBe(0);
|
|
155
|
+
expect(button.getAttribute("aria-disabled")).toBe("true");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("still fires a click while a preview state is forced", () => {
|
|
159
|
+
let clicks = 0;
|
|
160
|
+
const host = mountHost(
|
|
161
|
+
<Button data-preview-state="hover" onClick={() => (clicks += 1)}>
|
|
162
|
+
Export
|
|
163
|
+
</Button>,
|
|
164
|
+
);
|
|
165
|
+
const button = host.querySelector("button")!;
|
|
166
|
+
|
|
167
|
+
act(() => button.click());
|
|
168
|
+
|
|
169
|
+
expect(clicks).toBe(1);
|
|
170
|
+
expect(button.getAttribute("data-preview-state")).toBe("hover");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("is classified by the hotkey selectors the way a plain button is", () => {
|
|
174
|
+
// KTD13. Both selector lists gate on roles and element names. A primitive
|
|
175
|
+
// that landed on a different role would leak or swallow global hotkeys with
|
|
176
|
+
// nothing to notice it.
|
|
177
|
+
const host = mountHost(
|
|
178
|
+
<>
|
|
179
|
+
<button type="button" data-testid="plain">
|
|
180
|
+
Export
|
|
181
|
+
</button>
|
|
182
|
+
<Button data-testid="primitive">Export</Button>
|
|
183
|
+
<Tabs defaultValue="code">
|
|
184
|
+
<TabsList aria-label="Sidebar panels">
|
|
185
|
+
<Tab value="code">Code</Tab>
|
|
186
|
+
</TabsList>
|
|
187
|
+
</Tabs>
|
|
188
|
+
</>,
|
|
189
|
+
);
|
|
190
|
+
const plain = host.querySelector('[data-testid="plain"]')!;
|
|
191
|
+
const button = host.querySelector('[data-testid="primitive"]')!;
|
|
192
|
+
const tab = host.querySelector('[role="tab"]')!;
|
|
193
|
+
|
|
194
|
+
for (const el of [plain, button, tab]) {
|
|
195
|
+
expect(isTypingTarget(el)).toBe(false);
|
|
196
|
+
expect(shouldIgnorePlaybackShortcutTarget(el)).toBe(true);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("disabled states", () => {
|
|
202
|
+
it("disables a loading Button", () => {
|
|
203
|
+
const host = mountHost(<Button loading>Save</Button>);
|
|
204
|
+
const button = host.querySelector("button");
|
|
205
|
+
|
|
206
|
+
expect(button?.disabled).toBe(true);
|
|
207
|
+
expect(button?.getAttribute("aria-disabled")).toBe("true");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("marks a disabled IconButton for assistive tech", () => {
|
|
211
|
+
const host = mountHost(<IconButton aria-label="Undo" icon={<svg />} disabled />);
|
|
212
|
+
const button = host.querySelector("button");
|
|
213
|
+
|
|
214
|
+
expect(button?.disabled).toBe(true);
|
|
215
|
+
expect(button?.getAttribute("aria-disabled")).toBe("true");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("children layout", () => {
|
|
220
|
+
it("keeps label and shortcut direct flex items of the gap-bearing button without an icon", () => {
|
|
221
|
+
const host = mountHost(
|
|
35
222
|
<Button size="md">
|
|
36
223
|
<span>Export</span>
|
|
37
224
|
<kbd>⌘E</kbd>
|
|
38
225
|
</Button>,
|
|
39
226
|
);
|
|
227
|
+
const button = host.querySelector("button");
|
|
40
228
|
|
|
41
|
-
expect(button
|
|
42
|
-
expect(button
|
|
43
|
-
expect(Array.from(button
|
|
229
|
+
expect(button?.classList.contains("inline-flex")).toBe(true);
|
|
230
|
+
expect(button?.classList.contains("gap-1.5")).toBe(true);
|
|
231
|
+
expect(Array.from(button?.children ?? []).map((child) => child.tagName)).toEqual([
|
|
232
|
+
"SPAN",
|
|
233
|
+
"KBD",
|
|
234
|
+
]);
|
|
44
235
|
});
|
|
45
236
|
|
|
46
|
-
it("
|
|
47
|
-
const
|
|
237
|
+
it("makes label and shortcut direct flex items so the button gap applies between them", () => {
|
|
238
|
+
const host = mountHost(
|
|
48
239
|
<Button icon={<svg />}>
|
|
49
240
|
<span>Export</span>
|
|
50
241
|
<kbd>⌘E</kbd>
|
|
51
242
|
</Button>,
|
|
52
243
|
);
|
|
244
|
+
const button = host.querySelector("button");
|
|
53
245
|
|
|
54
|
-
expect(Array.from(button
|
|
246
|
+
expect(Array.from(button?.children ?? []).map((child) => child.tagName)).toEqual([
|
|
55
247
|
"SPAN",
|
|
56
248
|
"SPAN",
|
|
57
249
|
"KBD",
|
|
@@ -1,159 +1,122 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Button
|
|
2
|
+
* Button — a native `<button>` wearing Studio's tokens.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Everything visual comes from `theme.css`: the three control heights, the
|
|
5
|
+
* radius scale, the semantic colours, the motion durations. No value is decided
|
|
6
|
+
* here, so a button cannot drift from the rest of the system.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - duration-press-hover: 120ms press, 150ms hover
|
|
8
|
+
* Two conventions: classes merge through `cn`, so a caller's `className`
|
|
9
|
+
* always wins within its group; and every interactive look is written twice,
|
|
10
|
+
* once as the real state and once as `data-[preview-state=…]` (CSS-only, for
|
|
11
|
+
* screenshots). `Button.test.tsx` asserts the two stay in sync.
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
14
|
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
|
15
|
+
import { cn } from "./cn";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
|
18
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
type
|
|
20
|
+
/** Forces one interactive look for a gallery shot. CSS-only; see the header. */
|
|
21
|
+
export type PreviewState = "hover" | "active" | "focus";
|
|
21
22
|
|
|
22
|
-
interface
|
|
23
|
+
export interface ButtonBaseProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
23
24
|
variant?: ButtonVariant;
|
|
24
25
|
size?: ButtonSize;
|
|
26
|
+
"data-preview-state"?: PreviewState;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ButtonProps extends ButtonBaseProps {
|
|
25
30
|
loading?: boolean;
|
|
26
31
|
icon?: ReactNode;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"bg-
|
|
49
|
-
"enabled:hover:
|
|
50
|
-
"enabled:active:scale-[0.98]",
|
|
51
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Shared by Button and IconButton. `disabled:` keeps pointer events alive so a
|
|
36
|
+
* wrapping Tooltip can still explain why the control is disabled.
|
|
37
|
+
*/
|
|
38
|
+
export const buttonBase = cn(
|
|
39
|
+
"inline-flex items-center justify-center select-none cursor-pointer whitespace-nowrap",
|
|
40
|
+
"transition-[background-color,border-color,color,filter,transform] ease-out-quint duration-press",
|
|
41
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
42
|
+
"outline-hidden",
|
|
43
|
+
"focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent",
|
|
44
|
+
"data-[preview-state=focus]:outline-solid data-[preview-state=focus]:outline-2 data-[preview-state=focus]:outline-offset-1 data-[preview-state=focus]:outline-accent",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One entry per variant. The `data-[preview-state=…]` half of each string
|
|
49
|
+
* repeats the `hover:` and `active:` half exactly; the test pairs them.
|
|
50
|
+
*/
|
|
51
|
+
export const buttonVariants: Record<ButtonVariant, string> = {
|
|
52
|
+
primary: cn(
|
|
53
|
+
"bg-accent text-bg-0 font-semibold",
|
|
54
|
+
"enabled:hover:brightness-110 data-[preview-state=hover]:brightness-110",
|
|
55
|
+
"enabled:active:scale-[0.98] data-[preview-state=active]:scale-[0.98]",
|
|
56
|
+
),
|
|
57
|
+
secondary: cn(
|
|
58
|
+
"bg-transparent text-text-1 font-medium border border-border-strong",
|
|
59
|
+
"enabled:hover:bg-hover enabled:hover:text-text-0 data-[preview-state=hover]:bg-hover data-[preview-state=hover]:text-text-0",
|
|
60
|
+
"enabled:active:scale-[0.98] data-[preview-state=active]:scale-[0.98]",
|
|
61
|
+
),
|
|
62
|
+
danger: cn(
|
|
63
|
+
"bg-danger text-text-0 font-medium",
|
|
64
|
+
"enabled:hover:brightness-110 data-[preview-state=hover]:brightness-110",
|
|
65
|
+
"enabled:active:scale-[0.98] data-[preview-state=active]:scale-[0.98]",
|
|
66
|
+
),
|
|
67
|
+
ghost: cn(
|
|
68
|
+
"bg-transparent text-text-2 font-medium",
|
|
69
|
+
"enabled:hover:bg-hover enabled:hover:text-text-0 data-[preview-state=hover]:bg-hover data-[preview-state=hover]:text-text-0",
|
|
70
|
+
"enabled:active:scale-[0.98] data-[preview-state=active]:scale-[0.98]",
|
|
71
|
+
),
|
|
52
72
|
};
|
|
53
73
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
/**
|
|
75
|
+
* The three control heights, 24 / 28 / 32 px, from `--spacing-ctl-*`.
|
|
76
|
+
* Exported so the Capture download `<a href>` can wear the same recipe as a Button.
|
|
77
|
+
*/
|
|
78
|
+
export const buttonSizes: Record<ButtonSize, string> = {
|
|
79
|
+
sm: "h-ctl-sm px-2 gap-1 rounded-sm text-step-11",
|
|
80
|
+
md: "h-ctl px-3 gap-1.5 rounded-md text-step-12",
|
|
81
|
+
lg: "h-ctl-lg px-4 gap-2 rounded-md text-step-13",
|
|
58
82
|
};
|
|
59
83
|
|
|
60
|
-
|
|
61
|
-
|
|
84
|
+
function Spinner() {
|
|
85
|
+
return (
|
|
86
|
+
<svg className="animate-spin size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
87
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
88
|
+
<path
|
|
89
|
+
className="opacity-75"
|
|
90
|
+
fill="currentColor"
|
|
91
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
92
|
+
/>
|
|
93
|
+
</svg>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
62
97
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
63
98
|
(
|
|
64
|
-
{
|
|
65
|
-
variant = "secondary",
|
|
66
|
-
size = "md",
|
|
67
|
-
loading,
|
|
68
|
-
icon,
|
|
69
|
-
children,
|
|
70
|
-
className = "",
|
|
71
|
-
disabled,
|
|
72
|
-
...props
|
|
73
|
-
},
|
|
99
|
+
{ variant = "secondary", size = "md", loading, icon, children, className, disabled, ...props },
|
|
74
100
|
ref,
|
|
75
101
|
) => {
|
|
102
|
+
const isDisabled = disabled || loading;
|
|
103
|
+
let leading: ReactNode = null;
|
|
104
|
+
if (loading) leading = <Spinner />;
|
|
105
|
+
else if (icon) leading = <span className="shrink-0">{icon}</span>;
|
|
76
106
|
return (
|
|
77
107
|
<button
|
|
78
108
|
ref={ref}
|
|
79
|
-
disabled={
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// so a wrapping Tooltip can explain WHY they're disabled (A5).
|
|
85
|
-
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
86
|
-
"select-none cursor-pointer",
|
|
87
|
-
"outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
|
|
88
|
-
variantStyles[variant],
|
|
89
|
-
sizeStyles[size],
|
|
90
|
-
className,
|
|
91
|
-
].join(" ")}
|
|
109
|
+
disabled={isDisabled}
|
|
110
|
+
// `aria-disabled` as well as `disabled`: assistive tech announces the
|
|
111
|
+
// state even where the native attribute is filtered out of the tree.
|
|
112
|
+
aria-disabled={isDisabled || undefined}
|
|
113
|
+
className={cn(buttonBase, buttonVariants[variant], buttonSizes[size], className)}
|
|
92
114
|
{...props}
|
|
93
115
|
>
|
|
94
|
-
{
|
|
95
|
-
<svg className="animate-spin h-3.5 w-3.5" viewBox="0 0 24 24" fill="none">
|
|
96
|
-
<circle
|
|
97
|
-
className="opacity-25"
|
|
98
|
-
cx="12"
|
|
99
|
-
cy="12"
|
|
100
|
-
r="10"
|
|
101
|
-
stroke="currentColor"
|
|
102
|
-
strokeWidth="4"
|
|
103
|
-
/>
|
|
104
|
-
<path
|
|
105
|
-
className="opacity-75"
|
|
106
|
-
fill="currentColor"
|
|
107
|
-
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
108
|
-
/>
|
|
109
|
-
</svg>
|
|
110
|
-
) : icon ? (
|
|
111
|
-
<span className="shrink-0">{icon}</span>
|
|
112
|
-
) : null}
|
|
116
|
+
{leading}
|
|
113
117
|
{children}
|
|
114
118
|
</button>
|
|
115
119
|
);
|
|
116
120
|
},
|
|
117
121
|
);
|
|
118
122
|
Button.displayName = "Button";
|
|
119
|
-
|
|
120
|
-
// -- IconButton --
|
|
121
|
-
// For icon-only buttons. Enforces min 32px hit target.
|
|
122
|
-
|
|
123
|
-
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
124
|
-
icon: ReactNode;
|
|
125
|
-
size?: ButtonSize;
|
|
126
|
-
variant?: ButtonVariant;
|
|
127
|
-
"aria-label": string; // REQUIRED for accessibility
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const iconSizeStyles: Record<ButtonSize, string> = {
|
|
131
|
-
sm: "min-w-7 min-h-7 rounded-button", // 28px
|
|
132
|
-
md: "min-w-8 min-h-8 rounded-button", // 32px — minimum recommended
|
|
133
|
-
lg: "min-w-9 min-h-9 rounded-button", // 36px
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
// fallow-ignore-next-line unused-export
|
|
137
|
-
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
138
|
-
({ icon, size = "md", variant = "ghost", className = "", ...props }, ref) => {
|
|
139
|
-
return (
|
|
140
|
-
<button
|
|
141
|
-
ref={ref}
|
|
142
|
-
className={[
|
|
143
|
-
"inline-flex items-center justify-center",
|
|
144
|
-
"transition-all duration-press ease-standard",
|
|
145
|
-
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
146
|
-
"select-none cursor-pointer",
|
|
147
|
-
"outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
|
|
148
|
-
variantStyles[variant],
|
|
149
|
-
iconSizeStyles[size],
|
|
150
|
-
className,
|
|
151
|
-
].join(" ")}
|
|
152
|
-
{...props}
|
|
153
|
-
>
|
|
154
|
-
{icon}
|
|
155
|
-
</button>
|
|
156
|
-
);
|
|
157
|
-
},
|
|
158
|
-
);
|
|
159
|
-
IconButton.displayName = "IconButton";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IconButton — Button's square sibling; shares its base/variant classes so
|
|
3
|
+
* the two never drift. `aria-label` is required for screen readers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { forwardRef } from "react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { buttonBase, buttonVariants, type ButtonBaseProps, type ButtonSize } from "./Button";
|
|
9
|
+
import { cn } from "./cn";
|
|
10
|
+
|
|
11
|
+
interface IconButtonProps extends ButtonBaseProps {
|
|
12
|
+
icon: ReactNode;
|
|
13
|
+
"aria-label": string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Square boxes on the same 24 / 28 / 32 px control heights as Button. */
|
|
17
|
+
const iconSizeStyles: Record<ButtonSize, string> = {
|
|
18
|
+
sm: "size-ctl-sm rounded-sm",
|
|
19
|
+
md: "size-ctl rounded-md",
|
|
20
|
+
lg: "size-ctl-lg rounded-md",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
24
|
+
({ icon, size = "md", variant = "ghost", className, disabled, ...props }, ref) => {
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
ref={ref}
|
|
28
|
+
disabled={disabled}
|
|
29
|
+
aria-disabled={disabled || undefined}
|
|
30
|
+
className={cn(buttonBase, buttonVariants[variant], iconSizeStyles[size], className)}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
{icon}
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
IconButton.displayName = "IconButton";
|