@nationaldesignstudio/react 0.2.0 → 0.5.0
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/component-registry.md +1310 -127
- package/dist/components/atoms/background/background.d.ts +13 -27
- package/dist/components/atoms/button/button.d.ts +64 -72
- package/dist/components/atoms/button/button.figma.d.ts +1 -0
- package/dist/components/atoms/button/icon-button.d.ts +62 -110
- package/dist/components/atoms/input/input-group.d.ts +278 -0
- package/dist/components/atoms/input/input.d.ts +121 -0
- package/dist/components/atoms/popover/popover.d.ts +195 -0
- package/dist/components/atoms/select/select.d.ts +131 -0
- package/dist/components/atoms/tooltip/tooltip.d.ts +161 -0
- package/dist/components/organisms/card/card.d.ts +3 -3
- package/dist/components/sections/hero/hero.d.ts +2 -2
- package/dist/components/sections/prose/prose.d.ts +3 -3
- package/dist/components/sections/river/river.d.ts +1 -1
- package/dist/components/sections/tout/tout.d.ts +4 -4
- package/dist/components/shared/floating-arrow.d.ts +34 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +13935 -7622
- package/dist/index.js.map +1 -1
- package/dist/lib/form-control.d.ts +106 -0
- package/dist/tokens.css +4725 -19065
- package/package.json +2 -1
- package/src/components/atoms/accordion/accordion.stories.tsx +1 -1
- package/src/components/atoms/accordion/accordion.tsx +2 -2
- package/src/components/atoms/background/background.tsx +71 -109
- package/src/components/atoms/button/button.figma.tsx +37 -0
- package/src/components/atoms/button/button.stories.tsx +253 -115
- package/src/components/atoms/button/button.test.tsx +289 -5
- package/src/components/atoms/button/button.tsx +40 -101
- package/src/components/atoms/button/button.visual.test.tsx +28 -32
- package/src/components/atoms/button/icon-button.stories.tsx +44 -101
- package/src/components/atoms/button/icon-button.test.tsx +26 -94
- package/src/components/atoms/button/icon-button.tsx +81 -224
- package/src/components/atoms/input/index.ts +17 -0
- package/src/components/atoms/input/input-group.stories.tsx +646 -0
- package/src/components/atoms/input/input-group.test.tsx +362 -0
- package/src/components/atoms/input/input-group.tsx +409 -0
- package/src/components/atoms/input/input.stories.tsx +228 -0
- package/src/components/atoms/input/input.test.tsx +167 -0
- package/src/components/atoms/input/input.tsx +104 -0
- package/src/components/atoms/pager-control/pager-control.stories.tsx +6 -8
- package/src/components/atoms/pager-control/pager-control.tsx +12 -12
- package/src/components/atoms/popover/index.ts +30 -0
- package/src/components/atoms/popover/popover.stories.tsx +531 -0
- package/src/components/atoms/popover/popover.test.tsx +486 -0
- package/src/components/atoms/popover/popover.tsx +488 -0
- package/src/components/atoms/select/index.ts +18 -0
- package/src/components/atoms/select/select.stories.tsx +455 -0
- package/src/components/atoms/select/select.tsx +324 -0
- package/src/components/atoms/tooltip/index.ts +24 -0
- package/src/components/atoms/tooltip/tooltip.stories.tsx +348 -0
- package/src/components/atoms/tooltip/tooltip.test.tsx +363 -0
- package/src/components/atoms/tooltip/tooltip.tsx +347 -0
- package/src/components/dev-tools/dev-toolbar/dev-toolbar.stories.tsx +8 -17
- package/src/components/dev-tools/dev-toolbar/dev-toolbar.tsx +3 -3
- package/src/components/foundation/typography/typography.stories.tsx +401 -0
- package/src/components/organisms/card/card.stories.tsx +19 -19
- package/src/components/organisms/card/card.test.tsx +1 -1
- package/src/components/organisms/card/card.tsx +3 -3
- package/src/components/organisms/card/card.visual.test.tsx +11 -11
- package/src/components/organisms/navbar/navbar.tsx +2 -2
- package/src/components/organisms/navbar/navbar.visual.test.tsx +2 -2
- package/src/components/organisms/us-gov-banner/us-gov-banner.tsx +2 -2
- package/src/components/sections/banner/banner.stories.tsx +1 -5
- package/src/components/sections/banner/banner.test.tsx +2 -2
- package/src/components/sections/banner/banner.tsx +6 -6
- package/src/components/sections/card-grid/card-grid.tsx +5 -5
- package/src/components/sections/faq-section/faq-section.tsx +2 -2
- package/src/components/sections/hero/hero.stories.tsx +7 -7
- package/src/components/sections/hero/hero.test.tsx +5 -5
- package/src/components/sections/hero/hero.tsx +10 -11
- package/src/components/sections/prose/prose.test.tsx +2 -2
- package/src/components/sections/prose/prose.tsx +6 -7
- package/src/components/sections/river/river.stories.tsx +8 -8
- package/src/components/sections/river/river.test.tsx +4 -4
- package/src/components/sections/river/river.tsx +8 -16
- package/src/components/sections/tout/tout.stories.tsx +7 -31
- package/src/components/sections/tout/tout.test.tsx +1 -1
- package/src/components/sections/tout/tout.tsx +11 -11
- package/src/components/sections/two-column-section/two-column-section.tsx +7 -9
- package/src/components/shared/floating-arrow.tsx +78 -0
- package/src/components/shared/index.ts +5 -0
- package/src/index.ts +98 -0
- package/src/lib/form-control.ts +71 -0
- package/src/stories/grid-system.stories.tsx +309 -0
- package/src/stories/{Introduction.mdx → introduction.mdx} +29 -15
- package/src/stories/{ThemeProvider.stories.tsx → theme-provider.stories.tsx} +8 -22
- package/src/stories/{TokenShowcase.stories.tsx → token-showcase.stories.tsx} +1 -20
- package/src/stories/token-showcase.tsx +777 -0
- package/src/styles.css +3 -0
- package/src/tests/token-resolution.test.tsx +298 -0
- package/src/theme/hooks.ts +1 -1
- package/src/theme/index.ts +1 -1
- package/src/theme/theme-provider.test.tsx +270 -0
- package/src/theme/{ThemeProvider.tsx → theme-provider.tsx} +18 -2
- package/src/stories/GridSystem.stories.tsx +0 -84
- package/src/stories/TokenShowcase.tsx +0 -1429
|
@@ -3,6 +3,28 @@ import { page, userEvent } from "vitest/browser";
|
|
|
3
3
|
import { render } from "vitest-browser-react";
|
|
4
4
|
import { Button } from "./button";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Helper to get computed styles of an element
|
|
8
|
+
*/
|
|
9
|
+
function getStyles(element: HTMLElement) {
|
|
10
|
+
return window.getComputedStyle(element);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Helper to convert rgb/rgba to hex for easier comparison
|
|
15
|
+
*/
|
|
16
|
+
function _rgbToHex(rgb: string): string {
|
|
17
|
+
// Handle rgba format
|
|
18
|
+
const match = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/);
|
|
19
|
+
if (!match) return rgb;
|
|
20
|
+
|
|
21
|
+
const r = parseInt(match[1], 10);
|
|
22
|
+
const g = parseInt(match[2], 10);
|
|
23
|
+
const b = parseInt(match[3], 10);
|
|
24
|
+
|
|
25
|
+
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
6
28
|
describe("Button", () => {
|
|
7
29
|
describe("Accessibility", () => {
|
|
8
30
|
test("has correct button role", async () => {
|
|
@@ -124,12 +146,274 @@ describe("Button", () => {
|
|
|
124
146
|
});
|
|
125
147
|
});
|
|
126
148
|
|
|
127
|
-
describe("
|
|
128
|
-
test("
|
|
129
|
-
render(<Button>Default</Button>);
|
|
149
|
+
describe("Variant Styles", () => {
|
|
150
|
+
test("default variant has dark background", async () => {
|
|
151
|
+
render(<Button variant="default">Default</Button>);
|
|
130
152
|
const button = page.getByRole("button", { name: "Default" });
|
|
131
|
-
|
|
132
|
-
|
|
153
|
+
await expect.element(button).toBeInTheDocument();
|
|
154
|
+
|
|
155
|
+
const element = button.element();
|
|
156
|
+
const styles = getStyles(element);
|
|
157
|
+
|
|
158
|
+
// Default variant should have a dark background (gray-1200)
|
|
159
|
+
// The actual color depends on CSS variables, but it should not be transparent
|
|
160
|
+
expect(styles.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
|
|
161
|
+
expect(styles.backgroundColor).not.toBe("transparent");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("primary variant has blue background", async () => {
|
|
165
|
+
render(<Button variant="primary">Primary</Button>);
|
|
166
|
+
const button = page.getByRole("button", { name: "Primary" });
|
|
167
|
+
await expect.element(button).toBeInTheDocument();
|
|
168
|
+
|
|
169
|
+
const element = button.element();
|
|
170
|
+
const styles = getStyles(element);
|
|
171
|
+
|
|
172
|
+
// Primary variant should have a visible background
|
|
173
|
+
expect(styles.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
|
|
174
|
+
expect(styles.backgroundColor).not.toBe("transparent");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("destructive variant has red-tinted background", async () => {
|
|
178
|
+
render(<Button variant="destructive">Delete</Button>);
|
|
179
|
+
const button = page.getByRole("button", { name: "Delete" });
|
|
180
|
+
await expect.element(button).toBeInTheDocument();
|
|
181
|
+
|
|
182
|
+
const element = button.element();
|
|
183
|
+
const styles = getStyles(element);
|
|
184
|
+
|
|
185
|
+
// Destructive variant should have a visible background
|
|
186
|
+
expect(styles.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
|
|
187
|
+
expect(styles.backgroundColor).not.toBe("transparent");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("ghost variant has transparent background", async () => {
|
|
191
|
+
render(<Button variant="ghost">Ghost</Button>);
|
|
192
|
+
const button = page.getByRole("button", { name: "Ghost" });
|
|
193
|
+
await expect.element(button).toBeInTheDocument();
|
|
194
|
+
|
|
195
|
+
const element = button.element();
|
|
196
|
+
const styles = getStyles(element);
|
|
197
|
+
|
|
198
|
+
// Ghost variant should have transparent background
|
|
199
|
+
expect(
|
|
200
|
+
styles.backgroundColor === "rgba(0, 0, 0, 0)" ||
|
|
201
|
+
styles.backgroundColor === "transparent",
|
|
202
|
+
).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("link variant has no background and underline on hover behavior", async () => {
|
|
206
|
+
render(<Button variant="link">Link</Button>);
|
|
207
|
+
const button = page.getByRole("button", { name: "Link" });
|
|
208
|
+
await expect.element(button).toBeInTheDocument();
|
|
209
|
+
|
|
210
|
+
const element = button.element();
|
|
211
|
+
const styles = getStyles(element);
|
|
212
|
+
|
|
213
|
+
// Link variant should have transparent background
|
|
214
|
+
expect(
|
|
215
|
+
styles.backgroundColor === "rgba(0, 0, 0, 0)" ||
|
|
216
|
+
styles.backgroundColor === "transparent",
|
|
217
|
+
).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("outline variant has visible border", async () => {
|
|
221
|
+
render(<Button variant="outline">Outline</Button>);
|
|
222
|
+
const button = page.getByRole("button", { name: "Outline" });
|
|
223
|
+
await expect.element(button).toBeInTheDocument();
|
|
224
|
+
|
|
225
|
+
const element = button.element();
|
|
226
|
+
const styles = getStyles(element);
|
|
227
|
+
|
|
228
|
+
// Outline variant should have a visible border
|
|
229
|
+
const borderWidth = parseFloat(styles.borderWidth) || 0;
|
|
230
|
+
expect(borderWidth).toBeGreaterThan(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("secondary variant has light gray background with border", async () => {
|
|
234
|
+
render(<Button variant="secondary">Secondary</Button>);
|
|
235
|
+
const button = page.getByRole("button", { name: "Secondary" });
|
|
236
|
+
await expect.element(button).toBeInTheDocument();
|
|
237
|
+
|
|
238
|
+
const element = button.element();
|
|
239
|
+
const styles = getStyles(element);
|
|
240
|
+
|
|
241
|
+
// Secondary variant should have a visible background
|
|
242
|
+
expect(styles.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
|
|
243
|
+
expect(styles.backgroundColor).not.toBe("transparent");
|
|
244
|
+
|
|
245
|
+
// Secondary should also have a border
|
|
246
|
+
const borderWidth = parseFloat(styles.borderWidth) || 0;
|
|
247
|
+
expect(borderWidth).toBeGreaterThan(0);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("Size Styles", () => {
|
|
252
|
+
test("small size has 32px height", async () => {
|
|
253
|
+
render(<Button size="sm">Small</Button>);
|
|
254
|
+
const button = page.getByRole("button", { name: "Small" });
|
|
255
|
+
await expect.element(button).toBeInTheDocument();
|
|
256
|
+
|
|
257
|
+
const element = button.element();
|
|
258
|
+
const styles = getStyles(element);
|
|
259
|
+
|
|
260
|
+
// Small button should be 32px height
|
|
261
|
+
expect(styles.height).toBe("32px");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("default size has 36px height", async () => {
|
|
265
|
+
render(<Button size="default">Default</Button>);
|
|
266
|
+
const button = page.getByRole("button", { name: "Default" });
|
|
267
|
+
await expect.element(button).toBeInTheDocument();
|
|
268
|
+
|
|
269
|
+
const element = button.element();
|
|
270
|
+
const styles = getStyles(element);
|
|
271
|
+
|
|
272
|
+
// Default button should be 36px height
|
|
273
|
+
expect(styles.height).toBe("36px");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("large size has 40px height", async () => {
|
|
277
|
+
render(<Button size="lg">Large</Button>);
|
|
278
|
+
const button = page.getByRole("button", { name: "Large" });
|
|
279
|
+
await expect.element(button).toBeInTheDocument();
|
|
280
|
+
|
|
281
|
+
const element = button.element();
|
|
282
|
+
const styles = getStyles(element);
|
|
283
|
+
|
|
284
|
+
// Large button should be 40px height
|
|
285
|
+
expect(styles.height).toBe("40px");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("size affects padding appropriately", async () => {
|
|
289
|
+
render(
|
|
290
|
+
<>
|
|
291
|
+
<Button size="sm">Small</Button>
|
|
292
|
+
<Button size="lg">Large</Button>
|
|
293
|
+
</>,
|
|
294
|
+
);
|
|
295
|
+
const smallButton = page.getByRole("button", { name: "Small" });
|
|
296
|
+
const largeButton = page.getByRole("button", { name: "Large" });
|
|
297
|
+
|
|
298
|
+
const smallStyles = getStyles(smallButton.element());
|
|
299
|
+
const largeStyles = getStyles(largeButton.element());
|
|
300
|
+
|
|
301
|
+
const smallPaddingLeft = parseFloat(smallStyles.paddingLeft);
|
|
302
|
+
const largePaddingLeft = parseFloat(largeStyles.paddingLeft);
|
|
303
|
+
|
|
304
|
+
// Large should have more padding than small
|
|
305
|
+
expect(largePaddingLeft).toBeGreaterThan(smallPaddingLeft);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("size affects border radius appropriately", async () => {
|
|
309
|
+
render(
|
|
310
|
+
<>
|
|
311
|
+
<Button size="sm">Small</Button>
|
|
312
|
+
<Button size="lg">Large</Button>
|
|
313
|
+
</>,
|
|
314
|
+
);
|
|
315
|
+
const smallButton = page.getByRole("button", { name: "Small" });
|
|
316
|
+
const largeButton = page.getByRole("button", { name: "Large" });
|
|
317
|
+
|
|
318
|
+
const smallStyles = getStyles(smallButton.element());
|
|
319
|
+
const largeStyles = getStyles(largeButton.element());
|
|
320
|
+
|
|
321
|
+
const smallRadius = parseFloat(smallStyles.borderRadius);
|
|
322
|
+
const largeRadius = parseFloat(largeStyles.borderRadius);
|
|
323
|
+
|
|
324
|
+
// Large should have more border radius than small
|
|
325
|
+
// Figma: sm=4px, default=6px, lg=10px
|
|
326
|
+
expect(largeRadius).toBeGreaterThan(smallRadius);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("Disabled State Styles", () => {
|
|
331
|
+
test("disabled button has reduced opacity", async () => {
|
|
332
|
+
render(<Button disabled>Disabled</Button>);
|
|
333
|
+
const button = page.getByRole("button", { name: "Disabled" });
|
|
334
|
+
await expect.element(button).toBeInTheDocument();
|
|
335
|
+
|
|
336
|
+
const element = button.element();
|
|
337
|
+
const styles = getStyles(element);
|
|
338
|
+
|
|
339
|
+
// Disabled button should have opacity of 0.5
|
|
340
|
+
expect(parseFloat(styles.opacity)).toBe(0.5);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("disabled button has pointer-events none", async () => {
|
|
344
|
+
render(<Button disabled>Disabled</Button>);
|
|
345
|
+
const button = page.getByRole("button", { name: "Disabled" });
|
|
346
|
+
await expect.element(button).toBeInTheDocument();
|
|
347
|
+
|
|
348
|
+
const element = button.element();
|
|
349
|
+
const styles = getStyles(element);
|
|
350
|
+
|
|
351
|
+
expect(styles.pointerEvents).toBe("none");
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe("Data Attributes", () => {
|
|
356
|
+
test("button has correct data-variant attribute", async () => {
|
|
357
|
+
render(<Button variant="destructive">Test</Button>);
|
|
358
|
+
const button = page.getByRole("button", { name: "Test" });
|
|
359
|
+
|
|
360
|
+
await expect
|
|
361
|
+
.element(button)
|
|
362
|
+
.toHaveAttribute("data-variant", "destructive");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("button has correct data-size attribute", async () => {
|
|
366
|
+
render(<Button size="lg">Test</Button>);
|
|
367
|
+
const button = page.getByRole("button", { name: "Test" });
|
|
368
|
+
|
|
369
|
+
await expect.element(button).toHaveAttribute("data-size", "lg");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("default variant and size are reflected in data attributes", async () => {
|
|
373
|
+
render(<Button>Test</Button>);
|
|
374
|
+
const button = page.getByRole("button", { name: "Test" });
|
|
375
|
+
|
|
376
|
+
await expect.element(button).toHaveAttribute("data-variant", "default");
|
|
377
|
+
await expect.element(button).toHaveAttribute("data-size", "default");
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("Layout Styles", () => {
|
|
382
|
+
test("button uses flexbox for content alignment", async () => {
|
|
383
|
+
render(<Button>Test</Button>);
|
|
384
|
+
const button = page.getByRole("button", { name: "Test" });
|
|
385
|
+
await expect.element(button).toBeInTheDocument();
|
|
386
|
+
|
|
387
|
+
const element = button.element();
|
|
388
|
+
const styles = getStyles(element);
|
|
389
|
+
|
|
390
|
+
expect(styles.display).toBe("inline-flex");
|
|
391
|
+
expect(styles.alignItems).toBe("center");
|
|
392
|
+
expect(styles.justifyContent).toBe("center");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("button has cursor pointer", async () => {
|
|
396
|
+
render(<Button>Test</Button>);
|
|
397
|
+
const button = page.getByRole("button", { name: "Test" });
|
|
398
|
+
await expect.element(button).toBeInTheDocument();
|
|
399
|
+
|
|
400
|
+
const element = button.element();
|
|
401
|
+
const styles = getStyles(element);
|
|
402
|
+
|
|
403
|
+
expect(styles.cursor).toBe("pointer");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("button has gap for icon-text spacing", async () => {
|
|
407
|
+
render(<Button>Test</Button>);
|
|
408
|
+
const button = page.getByRole("button", { name: "Test" });
|
|
409
|
+
await expect.element(button).toBeInTheDocument();
|
|
410
|
+
|
|
411
|
+
const element = button.element();
|
|
412
|
+
const styles = getStyles(element);
|
|
413
|
+
|
|
414
|
+
// Gap should be set (6px from Figma)
|
|
415
|
+
const gap = parseFloat(styles.gap);
|
|
416
|
+
expect(gap).toBeGreaterThan(0);
|
|
133
417
|
});
|
|
134
418
|
});
|
|
135
419
|
});
|
|
@@ -9,22 +9,21 @@ import { tv, type VariantProps } from "tailwind-variants";
|
|
|
9
9
|
import { type ButtonTheme, buttonThemeToStyleVars } from "../../../lib/theme";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Button component based on Figma
|
|
12
|
+
* Button component based on Figma Button component
|
|
13
13
|
*
|
|
14
|
-
* Variants:
|
|
15
|
-
* -
|
|
16
|
-
* -
|
|
17
|
-
* -
|
|
18
|
-
* -
|
|
14
|
+
* Variants (matches Figma):
|
|
15
|
+
* - primary: Blue filled button for primary actions
|
|
16
|
+
* - default: Dark filled button for secondary prominence
|
|
17
|
+
* - secondary: Light gray filled button with subtle border
|
|
18
|
+
* - destructive: Red filled button for destructive actions
|
|
19
|
+
* - outline: Bordered button with transparent background
|
|
20
|
+
* - ghost: Transparent button with subtle hover
|
|
21
|
+
* - link: Text-only button with underline on hover
|
|
19
22
|
*
|
|
20
|
-
*
|
|
21
|
-
* -
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
* Sizes:
|
|
25
|
-
* - lg: Large buttons
|
|
26
|
-
* - default: Medium buttons
|
|
27
|
-
* - sm: Small buttons
|
|
23
|
+
* Sizes (matches Figma):
|
|
24
|
+
* - sm: Small buttons (32px height)
|
|
25
|
+
* - default: Default buttons (36px height)
|
|
26
|
+
* - lg: Large buttons (40px height)
|
|
28
27
|
*
|
|
29
28
|
* For icon-only buttons, use the IconButton component instead.
|
|
30
29
|
*
|
|
@@ -32,89 +31,42 @@ import { type ButtonTheme, buttonThemeToStyleVars } from "../../../lib/theme";
|
|
|
32
31
|
* Pass a `theme` prop to override default colors via CSS custom properties.
|
|
33
32
|
*/
|
|
34
33
|
const buttonVariants = tv({
|
|
35
|
-
base: "inline-flex items-center justify-center gap-
|
|
34
|
+
base: "inline-flex items-center justify-center gap-spatial-ui-button-gap-icon-text whitespace-nowrap transition-colors duration-150 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50",
|
|
36
35
|
variants: {
|
|
37
36
|
variant: {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
// Primary - blue filled button
|
|
38
|
+
primary:
|
|
39
|
+
"bg-button-primary-bg text-button-primary-text hover:bg-button-primary-bg-hover active:bg-button-primary-bg-active border-transparent",
|
|
40
|
+
// Default - dark filled button
|
|
41
|
+
default:
|
|
42
|
+
"bg-button-default-bg text-button-default-text hover:bg-button-default-bg-hover active:bg-button-default-bg-active border-transparent",
|
|
43
|
+
// Secondary - light gray filled with subtle border
|
|
44
|
+
secondary:
|
|
45
|
+
"bg-button-secondary-bg text-button-secondary-text hover:bg-button-secondary-bg-hover active:bg-button-secondary-bg-active border border-button-secondary-border",
|
|
46
|
+
// Destructive - red filled button
|
|
47
|
+
destructive:
|
|
48
|
+
"bg-button-destructive-bg text-button-destructive-text hover:bg-button-destructive-bg-hover active:bg-button-destructive-bg-active border-transparent",
|
|
49
|
+
// Outline - bordered with transparent background
|
|
50
|
+
outline:
|
|
51
|
+
"bg-button-outline-bg text-button-outline-text hover:bg-button-outline-bg-hover active:bg-button-outline-bg-active border border-button-outline-border hover:border-button-outline-border-hover",
|
|
52
|
+
// Ghost - transparent with subtle hover
|
|
53
|
+
ghost:
|
|
54
|
+
"bg-button-ghost-bg text-button-ghost-text hover:bg-button-ghost-bg-hover active:bg-button-ghost-bg-active border-transparent",
|
|
55
|
+
// Link - text only with underline on hover
|
|
56
|
+
link: "bg-transparent text-button-link-text hover:text-button-link-text-hover hover:underline active:text-button-link-text-hover border-transparent underline-offset-4",
|
|
42
57
|
// Themed - uses CSS custom properties for styling
|
|
43
58
|
themed:
|
|
44
59
|
"[background:var(--btn-bg)] [color:var(--btn-text)] [border-color:var(--btn-border-color,transparent)] hover:[background:var(--btn-bg-hover,var(--btn-bg))] active:[background:var(--btn-bg-active,var(--btn-bg-hover,var(--btn-bg)))]",
|
|
45
60
|
},
|
|
46
|
-
colorScheme: {
|
|
47
|
-
dark: "",
|
|
48
|
-
light: "",
|
|
49
|
-
},
|
|
50
61
|
size: {
|
|
51
|
-
|
|
62
|
+
sm: "typography-ui-text-xs h-spatial-ui-button-height-small px-spatial-ui-button-padding-x-small py-spatial-ui-button-padding-y-small rounded-surface-button-small",
|
|
52
63
|
default:
|
|
53
|
-
"
|
|
54
|
-
|
|
64
|
+
"typography-ui-text-sm h-spatial-ui-button-height-medium px-spatial-ui-button-padding-x-medium py-spatial-ui-button-padding-y-medium rounded-surface-button-medium",
|
|
65
|
+
lg: "typography-ui-text-md h-spatial-ui-button-height-large px-spatial-ui-button-padding-x-large py-spatial-ui-button-padding-y-large rounded-surface-button-large",
|
|
55
66
|
},
|
|
56
67
|
},
|
|
57
|
-
compoundVariants: [
|
|
58
|
-
// Solid + Dark (for light backgrounds) - uses semantic button tokens
|
|
59
|
-
{
|
|
60
|
-
variant: "solid",
|
|
61
|
-
colorScheme: "dark",
|
|
62
|
-
class:
|
|
63
|
-
"bg-button-primary-bg text-text-inverted hover:bg-button-primary-bg-hover active:bg-button-primary-bg-hover border-transparent focus-visible:ring-button-primary-bg",
|
|
64
|
-
},
|
|
65
|
-
// Solid + Light (for dark backgrounds)
|
|
66
|
-
{
|
|
67
|
-
variant: "solid",
|
|
68
|
-
colorScheme: "light",
|
|
69
|
-
class:
|
|
70
|
-
"bg-button-secondary-bg text-text-primary hover:bg-button-secondary-bg-hover active:bg-gray-200 border-transparent focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
|
|
71
|
-
},
|
|
72
|
-
// Outline + Dark (for light backgrounds)
|
|
73
|
-
{
|
|
74
|
-
variant: "outline",
|
|
75
|
-
colorScheme: "dark",
|
|
76
|
-
class:
|
|
77
|
-
"border-border-subtle text-gray-1000 hover:bg-alpha-black-5 active:bg-alpha-black-10 focus-visible:ring-gray-1000",
|
|
78
|
-
},
|
|
79
|
-
// Outline + Light (for dark backgrounds)
|
|
80
|
-
{
|
|
81
|
-
variant: "outline",
|
|
82
|
-
colorScheme: "light",
|
|
83
|
-
class:
|
|
84
|
-
"border-gray-50 text-gray-50 hover:bg-alpha-white-10 active:bg-alpha-white-20 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
|
|
85
|
-
},
|
|
86
|
-
// Ghost + Dark (for light backgrounds)
|
|
87
|
-
{
|
|
88
|
-
variant: "ghost",
|
|
89
|
-
colorScheme: "dark",
|
|
90
|
-
class:
|
|
91
|
-
"text-gray-700 hover:text-gray-900 hover:bg-alpha-black-5 active:bg-alpha-black-10 focus-visible:ring-gray-1000",
|
|
92
|
-
},
|
|
93
|
-
// Ghost + Light (for dark backgrounds)
|
|
94
|
-
{
|
|
95
|
-
variant: "ghost",
|
|
96
|
-
colorScheme: "light",
|
|
97
|
-
class:
|
|
98
|
-
"text-gray-300 hover:text-gray-100 hover:bg-alpha-white-10 active:bg-alpha-white-20 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
|
|
99
|
-
},
|
|
100
|
-
// Subtle + Dark (for light backgrounds)
|
|
101
|
-
{
|
|
102
|
-
variant: "subtle",
|
|
103
|
-
colorScheme: "dark",
|
|
104
|
-
class:
|
|
105
|
-
"border-border-subtle text-alpha-black-60 hover:border-border-strong hover:text-alpha-black-80 active:bg-alpha-black-5 focus-visible:ring-gray-1000",
|
|
106
|
-
},
|
|
107
|
-
// Subtle + Light (for dark backgrounds)
|
|
108
|
-
{
|
|
109
|
-
variant: "subtle",
|
|
110
|
-
colorScheme: "light",
|
|
111
|
-
class:
|
|
112
|
-
"border-alpha-white-20 text-alpha-white-60 hover:border-alpha-white-30 hover:text-alpha-white-80 active:bg-alpha-white-5 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
|
|
113
|
-
},
|
|
114
|
-
],
|
|
115
68
|
defaultVariants: {
|
|
116
|
-
variant: "
|
|
117
|
-
colorScheme: "dark",
|
|
69
|
+
variant: "default",
|
|
118
70
|
size: "default",
|
|
119
71
|
},
|
|
120
72
|
});
|
|
@@ -139,17 +91,7 @@ function hasThemeValues(theme: ButtonTheme | undefined): boolean {
|
|
|
139
91
|
|
|
140
92
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
141
93
|
(
|
|
142
|
-
{
|
|
143
|
-
className,
|
|
144
|
-
variant,
|
|
145
|
-
colorScheme,
|
|
146
|
-
size,
|
|
147
|
-
render,
|
|
148
|
-
nativeButton,
|
|
149
|
-
theme,
|
|
150
|
-
style,
|
|
151
|
-
...props
|
|
152
|
-
},
|
|
94
|
+
{ className, variant, size, render, nativeButton, theme, style, ...props },
|
|
153
95
|
ref,
|
|
154
96
|
) => {
|
|
155
97
|
// When render prop is provided, default nativeButton to false to suppress warnings
|
|
@@ -162,15 +104,13 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
162
104
|
const combinedStyles = hasTheme ? { ...themeStyles, ...style } : style;
|
|
163
105
|
|
|
164
106
|
// Resolve actual values for data attributes
|
|
165
|
-
const resolvedVariant = effectiveVariant ?? "
|
|
166
|
-
const resolvedColorScheme = colorScheme ?? "dark";
|
|
107
|
+
const resolvedVariant = effectiveVariant ?? "default";
|
|
167
108
|
const resolvedSize = size ?? "default";
|
|
168
109
|
|
|
169
110
|
return (
|
|
170
111
|
<BaseButton
|
|
171
112
|
className={buttonVariants({
|
|
172
113
|
variant: effectiveVariant,
|
|
173
|
-
colorScheme,
|
|
174
114
|
size,
|
|
175
115
|
class: className,
|
|
176
116
|
})}
|
|
@@ -179,7 +119,6 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
179
119
|
nativeButton={isNativeButton}
|
|
180
120
|
style={combinedStyles}
|
|
181
121
|
data-variant={resolvedVariant}
|
|
182
|
-
data-color-scheme={resolvedColorScheme}
|
|
183
122
|
data-size={resolvedSize}
|
|
184
123
|
{...props}
|
|
185
124
|
/>
|
|
@@ -4,69 +4,66 @@ import { page } from "vitest/browser";
|
|
|
4
4
|
import { Button } from "./button";
|
|
5
5
|
|
|
6
6
|
describe("Button Visual Regression", () => {
|
|
7
|
-
test("
|
|
8
|
-
render(<Button variant="
|
|
7
|
+
test("primary variant renders correctly", async () => {
|
|
8
|
+
render(<Button variant="primary">Primary Button</Button>);
|
|
9
9
|
|
|
10
10
|
await expect(
|
|
11
|
-
page.getByRole("button", { name: "
|
|
12
|
-
).toMatchScreenshot("button-
|
|
11
|
+
page.getByRole("button", { name: "Primary Button" }),
|
|
12
|
+
).toMatchScreenshot("button-primary");
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
test("
|
|
16
|
-
render(<Button variant="
|
|
15
|
+
test("primary-outline variant renders correctly", async () => {
|
|
16
|
+
render(<Button variant="primary-outline">Primary Outline Button</Button>);
|
|
17
17
|
|
|
18
18
|
await expect(
|
|
19
|
-
page.getByRole("button", { name: "
|
|
20
|
-
).toMatchScreenshot("button-
|
|
19
|
+
page.getByRole("button", { name: "Primary Outline Button" }),
|
|
20
|
+
).toMatchScreenshot("button-primary-outline");
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
test("
|
|
23
|
+
test("secondary variant renders correctly", async () => {
|
|
24
24
|
render(
|
|
25
|
-
<
|
|
25
|
+
<div style={{ background: "#1a1a1a", padding: "20px" }}>
|
|
26
|
+
<Button variant="secondary">Secondary Button</Button>
|
|
27
|
+
</div>,
|
|
26
28
|
);
|
|
27
29
|
|
|
28
30
|
await expect(
|
|
29
|
-
page.getByRole("button", { name: "
|
|
30
|
-
).toMatchScreenshot("button-
|
|
31
|
+
page.getByRole("button", { name: "Secondary Button" }),
|
|
32
|
+
).toMatchScreenshot("button-secondary");
|
|
31
33
|
});
|
|
32
34
|
|
|
33
|
-
test("
|
|
35
|
+
test("secondary-outline variant renders correctly", async () => {
|
|
34
36
|
render(
|
|
35
37
|
<div style={{ background: "#1a1a1a", padding: "20px" }}>
|
|
36
|
-
<Button variant="
|
|
38
|
+
<Button variant="secondary-outline">Secondary Outline Button</Button>
|
|
37
39
|
</div>,
|
|
38
40
|
);
|
|
39
41
|
|
|
40
42
|
await expect(
|
|
41
|
-
page.getByRole("button", { name: "
|
|
42
|
-
).toMatchScreenshot("button-
|
|
43
|
+
page.getByRole("button", { name: "Secondary Outline Button" }),
|
|
44
|
+
).toMatchScreenshot("button-secondary-outline");
|
|
43
45
|
});
|
|
44
46
|
|
|
45
|
-
test("
|
|
46
|
-
render(
|
|
47
|
-
<div style={{ background: "#1a1a1a", padding: "20px" }}>
|
|
48
|
-
<Button variant="ivoryOutline">Ivory Outline Button</Button>
|
|
49
|
-
</div>,
|
|
50
|
-
);
|
|
47
|
+
test("ghost variant renders correctly", async () => {
|
|
48
|
+
render(<Button variant="ghost">Ghost Button</Button>);
|
|
51
49
|
|
|
52
50
|
await expect(
|
|
53
|
-
page.getByRole("button", { name: "
|
|
54
|
-
).toMatchScreenshot("button-
|
|
51
|
+
page.getByRole("button", { name: "Ghost Button" }),
|
|
52
|
+
).toMatchScreenshot("button-ghost");
|
|
55
53
|
});
|
|
56
54
|
|
|
57
|
-
test("
|
|
55
|
+
test("ghost-inverse variant renders correctly", async () => {
|
|
58
56
|
render(
|
|
59
57
|
<div style={{ background: "#1a1a1a", padding: "20px" }}>
|
|
60
|
-
<Button variant="
|
|
58
|
+
<Button variant="ghost-inverse">Ghost Inverse Button</Button>
|
|
61
59
|
</div>,
|
|
62
60
|
);
|
|
63
61
|
|
|
64
62
|
await expect(
|
|
65
|
-
page.getByRole("button", { name: "
|
|
66
|
-
).toMatchScreenshot("button-
|
|
63
|
+
page.getByRole("button", { name: "Ghost Inverse Button" }),
|
|
64
|
+
).toMatchScreenshot("button-ghost-inverse");
|
|
67
65
|
});
|
|
68
66
|
|
|
69
|
-
// Size variants
|
|
70
67
|
test("small size renders correctly", async () => {
|
|
71
68
|
render(<Button size="sm">Small Button</Button>);
|
|
72
69
|
|
|
@@ -75,8 +72,8 @@ describe("Button Visual Regression", () => {
|
|
|
75
72
|
).toMatchScreenshot("button-size-small");
|
|
76
73
|
});
|
|
77
74
|
|
|
78
|
-
test("medium
|
|
79
|
-
render(<Button size="
|
|
75
|
+
test("medium size renders correctly", async () => {
|
|
76
|
+
render(<Button size="md">Medium Button</Button>);
|
|
80
77
|
|
|
81
78
|
await expect(
|
|
82
79
|
page.getByRole("button", { name: "Medium Button" }),
|
|
@@ -91,7 +88,6 @@ describe("Button Visual Regression", () => {
|
|
|
91
88
|
).toMatchScreenshot("button-size-large");
|
|
92
89
|
});
|
|
93
90
|
|
|
94
|
-
// Disabled state
|
|
95
91
|
test("disabled state renders correctly", async () => {
|
|
96
92
|
render(<Button disabled>Disabled Button</Button>);
|
|
97
93
|
|