@nationaldesignstudio/react 0.0.15 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/package.json +3 -2
  2. package/src/App.css +0 -0
  3. package/src/App.tsx +7 -0
  4. package/src/assets/fonts/PPNeueMontreal-Variable.woff2 +0 -0
  5. package/src/assets/react.svg +1 -0
  6. package/src/components/atoms/accordion/accordion.stories.tsx +228 -0
  7. package/src/components/atoms/accordion/accordion.tsx +219 -0
  8. package/src/components/atoms/accordion/index.ts +6 -0
  9. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-chromium-darwin.png +0 -0
  10. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-chromium-linux.png +0 -0
  11. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-chromium-darwin.png +0 -0
  12. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-chromium-linux.png +0 -0
  13. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-quiet-chromium-darwin.png +0 -0
  14. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-quiet-chromium-linux.png +0 -0
  15. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-disabled-chromium-darwin.png +0 -0
  16. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-disabled-chromium-linux.png +0 -0
  17. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-chromium-darwin.png +0 -0
  18. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-chromium-linux.png +0 -0
  19. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-chromium-darwin.png +0 -0
  20. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-chromium-linux.png +0 -0
  21. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-quiet-chromium-darwin.png +0 -0
  22. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-quiet-chromium-linux.png +0 -0
  23. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-large-chromium-darwin.png +0 -0
  24. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-large-chromium-linux.png +0 -0
  25. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-medium-chromium-darwin.png +0 -0
  26. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-medium-chromium-linux.png +0 -0
  27. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-small-chromium-darwin.png +0 -0
  28. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-small-chromium-linux.png +0 -0
  29. package/src/components/atoms/button/button.stories.tsx +102 -0
  30. package/src/components/atoms/button/button.test.tsx +135 -0
  31. package/src/components/atoms/button/button.tsx +139 -0
  32. package/src/components/atoms/button/button.visual.test.tsx +102 -0
  33. package/src/components/atoms/button/icon-button.stories.tsx +166 -0
  34. package/src/components/atoms/button/icon-button.tsx +120 -0
  35. package/src/components/atoms/button/index.ts +6 -0
  36. package/src/components/atoms/ndstudio-footer/index.ts +1 -0
  37. package/src/components/atoms/ndstudio-footer/ndstudio-footer.tsx +55 -0
  38. package/src/components/atoms/pager-control/index.ts +5 -0
  39. package/src/components/atoms/pager-control/pager-control.stories.tsx +209 -0
  40. package/src/components/atoms/pager-control/pager-control.test.tsx +130 -0
  41. package/src/components/atoms/pager-control/pager-control.tsx +329 -0
  42. package/src/components/dev-tools/dev-toolbar/dev-toolbar.stories.tsx +82 -0
  43. package/src/components/dev-tools/dev-toolbar/dev-toolbar.tsx +196 -0
  44. package/src/components/dev-tools/dev-toolbar/index.ts +1 -0
  45. package/src/components/dev-tools/grid-overlay/grid-overlay.tsx +41 -0
  46. package/src/components/dev-tools/grid-overlay/index.ts +1 -0
  47. package/src/components/dev-tools/index.ts +2 -0
  48. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-default-vertical-chromium-darwin.png +0 -0
  49. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-default-vertical-chromium-linux.png +0 -0
  50. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-horizontal-chromium-darwin.png +0 -0
  51. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-horizontal-chromium-linux.png +0 -0
  52. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-minimal-chromium-darwin.png +0 -0
  53. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-minimal-chromium-linux.png +0 -0
  54. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-actions-chromium-darwin.png +0 -0
  55. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-actions-chromium-linux.png +0 -0
  56. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-eyebrow-chromium-darwin.png +0 -0
  57. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-eyebrow-chromium-linux.png +0 -0
  58. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-image-chromium-darwin.png +0 -0
  59. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-image-chromium-linux.png +0 -0
  60. package/src/components/organisms/card/card.stories.tsx +293 -0
  61. package/src/components/organisms/card/card.test.tsx +245 -0
  62. package/src/components/organisms/card/card.tsx +225 -0
  63. package/src/components/organisms/card/card.visual.test.tsx +197 -0
  64. package/src/components/organisms/card/index.ts +19 -0
  65. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-active-link-chromium-darwin.png +0 -0
  66. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-active-link-chromium-linux.png +0 -0
  67. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-brand-only-chromium-darwin.png +0 -0
  68. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-brand-only-chromium-linux.png +0 -0
  69. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-default-chromium-darwin.png +0 -0
  70. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-default-chromium-linux.png +0 -0
  71. package/src/components/organisms/navbar/index.ts +18 -0
  72. package/src/components/organisms/navbar/navbar.stories.tsx +313 -0
  73. package/src/components/organisms/navbar/navbar.test.tsx +190 -0
  74. package/src/components/organisms/navbar/navbar.tsx +323 -0
  75. package/src/components/organisms/navbar/navbar.visual.test.tsx +85 -0
  76. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-icon-chromium-darwin.png +0 -0
  77. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-icon-chromium-linux.png +0 -0
  78. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-text-chromium-darwin.png +0 -0
  79. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-text-chromium-linux.png +0 -0
  80. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-default-chromium-darwin.png +0 -0
  81. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-default-chromium-linux.png +0 -0
  82. package/src/components/organisms/us-gov-banner/index.ts +1 -0
  83. package/src/components/organisms/us-gov-banner/us-gov-banner.stories.tsx +35 -0
  84. package/src/components/organisms/us-gov-banner/us-gov-banner.test.tsx +107 -0
  85. package/src/components/organisms/us-gov-banner/us-gov-banner.tsx +73 -0
  86. package/src/components/organisms/us-gov-banner/us-gov-banner.visual.test.tsx +46 -0
  87. package/src/components/sections/banner/banner.stories.tsx +150 -0
  88. package/src/components/sections/banner/banner.test.tsx +185 -0
  89. package/src/components/sections/banner/banner.tsx +130 -0
  90. package/src/components/sections/banner/index.ts +2 -0
  91. package/src/components/sections/card-grid/card-grid.stories.tsx +351 -0
  92. package/src/components/sections/card-grid/card-grid.tsx +116 -0
  93. package/src/components/sections/card-grid/index.ts +1 -0
  94. package/src/components/sections/faq-section/faq-section.stories.tsx +453 -0
  95. package/src/components/sections/faq-section/faq-section.tsx +84 -0
  96. package/src/components/sections/faq-section/index.ts +2 -0
  97. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-desktop-chromium-darwin.png +0 -0
  98. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-desktop-chromium-linux.png +0 -0
  99. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-mobile-chromium-darwin.png +0 -0
  100. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-mobile-chromium-linux.png +0 -0
  101. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-tablet-chromium-darwin.png +0 -0
  102. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-tablet-chromium-linux.png +0 -0
  103. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-desktop-chromium-darwin.png +0 -0
  104. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-desktop-chromium-linux.png +0 -0
  105. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-mobile-chromium-darwin.png +0 -0
  106. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-mobile-chromium-linux.png +0 -0
  107. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-tablet-chromium-darwin.png +0 -0
  108. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-tablet-chromium-linux.png +0 -0
  109. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-desktop-chromium-darwin.png +0 -0
  110. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-desktop-chromium-linux.png +0 -0
  111. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-mobile-chromium-darwin.png +0 -0
  112. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-mobile-chromium-linux.png +0 -0
  113. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-tablet-chromium-darwin.png +0 -0
  114. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-tablet-chromium-linux.png +0 -0
  115. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-custom-class-chromium-darwin.png +0 -0
  116. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-custom-class-chromium-linux.png +0 -0
  117. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-default-chromium-linux.png +0 -0
  118. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-long-title-chromium-darwin.png +0 -0
  119. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-long-title-chromium-linux.png +0 -0
  120. package/src/components/sections/hero/hero.stories.tsx +274 -0
  121. package/src/components/sections/hero/hero.test.tsx +135 -0
  122. package/src/components/sections/hero/hero.tsx +453 -0
  123. package/src/components/sections/hero/hero.visual.test.tsx +140 -0
  124. package/src/components/sections/hero/index.ts +10 -0
  125. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-h3-heading-chromium-darwin.png +0 -0
  126. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-h3-heading-chromium-linux.png +0 -0
  127. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-paragraphs-chromium-darwin.png +0 -0
  128. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-paragraphs-chromium-linux.png +0 -0
  129. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-sections-chromium-darwin.png +0 -0
  130. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-sections-chromium-linux.png +0 -0
  131. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-single-section-chromium-darwin.png +0 -0
  132. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-single-section-chromium-linux.png +0 -0
  133. package/src/components/sections/prose/index.ts +6 -0
  134. package/src/components/sections/prose/prose.stories.tsx +144 -0
  135. package/src/components/sections/prose/prose.test.tsx +178 -0
  136. package/src/components/sections/prose/prose.tsx +88 -0
  137. package/src/components/sections/prose/prose.visual.test.tsx +105 -0
  138. package/src/components/sections/river/index.ts +1 -0
  139. package/src/components/sections/river/river.stories.tsx +237 -0
  140. package/src/components/sections/river/river.test.tsx +268 -0
  141. package/src/components/sections/river/river.tsx +173 -0
  142. package/src/components/sections/tout/index.ts +1 -0
  143. package/src/components/sections/tout/tout.stories.tsx +171 -0
  144. package/src/components/sections/tout/tout.test.tsx +242 -0
  145. package/src/components/sections/tout/tout.tsx +270 -0
  146. package/src/components/sections/two-column-section/index.ts +5 -0
  147. package/src/components/sections/two-column-section/two-column-section.stories.tsx +285 -0
  148. package/src/components/sections/two-column-section/two-column-section.tsx +162 -0
  149. package/src/hooks/index.ts +1 -0
  150. package/src/hooks/use-event-listener.ts +73 -0
  151. package/src/index.ts +155 -0
  152. package/src/lib/theme.ts +1000 -0
  153. package/src/lib/utils.ts +6 -0
  154. package/src/main.tsx +13 -0
  155. package/src/stories/GridSystem.stories.tsx +84 -0
  156. package/src/stories/Introduction.mdx +114 -0
  157. package/src/stories/ThemeProvider.stories.tsx +357 -0
  158. package/src/stories/TokenShowcase.stories.tsx +92 -0
  159. package/src/stories/TokenShowcase.tsx +1429 -0
  160. package/src/styles.css +11 -0
  161. package/src/theme/ThemeProvider.tsx +297 -0
  162. package/src/theme/hooks.ts +40 -0
  163. package/src/theme/index.ts +43 -0
  164. package/src/theme/utils.ts +104 -0
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { page, userEvent } from "vitest/browser";
3
+ import { render } from "vitest-browser-react";
4
+ import { Button } from "./button";
5
+
6
+ describe("Button", () => {
7
+ describe("Accessibility", () => {
8
+ test("has correct button role", async () => {
9
+ render(<Button>Click me</Button>);
10
+ await expect
11
+ .element(page.getByRole("button", { name: "Click me" }))
12
+ .toBeInTheDocument();
13
+ });
14
+
15
+ test("is focusable via keyboard", async () => {
16
+ render(<Button>Focusable</Button>);
17
+ await userEvent.keyboard("{Tab}");
18
+ await expect
19
+ .element(page.getByRole("button", { name: "Focusable" }))
20
+ .toHaveFocus();
21
+ });
22
+
23
+ test("disabled button has disabled attribute", async () => {
24
+ render(<Button disabled>Disabled</Button>);
25
+ await expect
26
+ .element(page.getByRole("button", { name: "Disabled" }))
27
+ .toBeDisabled();
28
+ });
29
+
30
+ test("disabled button is not focusable", async () => {
31
+ render(
32
+ <>
33
+ <Button disabled>Disabled</Button>
34
+ <Button>After</Button>
35
+ </>,
36
+ );
37
+ await userEvent.keyboard("{Tab}");
38
+ // Focus should skip the disabled button and go to the next one
39
+ await expect
40
+ .element(page.getByRole("button", { name: "After" }))
41
+ .toHaveFocus();
42
+ });
43
+
44
+ test("button with aria-label has accessible name", async () => {
45
+ render(<Button aria-label="Close dialog">×</Button>);
46
+ await expect
47
+ .element(page.getByRole("button", { name: "Close dialog" }))
48
+ .toBeInTheDocument();
49
+ });
50
+
51
+ test("button type defaults to button (not submit)", async () => {
52
+ render(<Button>Click me</Button>);
53
+ await expect
54
+ .element(page.getByRole("button", { name: "Click me" }))
55
+ .toHaveAttribute("type", "button");
56
+ });
57
+
58
+ test("button type can be set explicitly to submit", async () => {
59
+ render(<Button type="submit">Submit</Button>);
60
+ await expect
61
+ .element(page.getByRole("button", { name: "Submit" }))
62
+ .toHaveAttribute("type", "submit");
63
+ });
64
+ });
65
+
66
+ describe("Interactions", () => {
67
+ test("calls onClick when clicked", async () => {
68
+ const handleClick = vi.fn();
69
+ render(<Button onClick={handleClick}>Click me</Button>);
70
+ await page.getByRole("button", { name: "Click me" }).click();
71
+ expect(handleClick).toHaveBeenCalledOnce();
72
+ });
73
+
74
+ test("responds to Enter key when focused", async () => {
75
+ const handleClick = vi.fn();
76
+ render(<Button onClick={handleClick}>Enter key</Button>);
77
+ page.getByRole("button", { name: "Enter key" }).element().focus();
78
+ await userEvent.keyboard("{Enter}");
79
+ expect(handleClick).toHaveBeenCalledOnce();
80
+ });
81
+
82
+ test("responds to Space key when focused", async () => {
83
+ const handleClick = vi.fn();
84
+ render(<Button onClick={handleClick}>Space key</Button>);
85
+ page.getByRole("button", { name: "Space key" }).element().focus();
86
+ await userEvent.keyboard(" ");
87
+ expect(handleClick).toHaveBeenCalledOnce();
88
+ });
89
+
90
+ test("does not fire onClick when disabled", async () => {
91
+ const handleClick = vi.fn();
92
+ render(
93
+ <Button disabled onClick={handleClick}>
94
+ Disabled
95
+ </Button>,
96
+ );
97
+ await page
98
+ .getByRole("button", { name: "Disabled" })
99
+ .click({ force: true });
100
+ expect(handleClick).not.toHaveBeenCalled();
101
+ });
102
+
103
+ test("supports multiple clicks", async () => {
104
+ const handleClick = vi.fn();
105
+ render(<Button onClick={handleClick}>Multi click</Button>);
106
+ const button = page.getByRole("button", { name: "Multi click" });
107
+ await button.click();
108
+ await button.click();
109
+ await button.click();
110
+ expect(handleClick).toHaveBeenCalledTimes(3);
111
+ });
112
+ });
113
+
114
+ describe("render prop", () => {
115
+ test("renders as anchor element with button role when render prop is used", async () => {
116
+ render(
117
+ // biome-ignore lint/a11y/useAnchorContent: Content provided via Button children
118
+ <Button render={<a href="/test" />}>Link Button</Button>,
119
+ );
120
+ // Base UI keeps role="button" for accessibility when rendering as another element
121
+ const button = page.getByRole("button", { name: "Link Button" });
122
+ await expect.element(button).toBeInTheDocument();
123
+ await expect.element(button).toHaveAttribute("href", "/test");
124
+ });
125
+ });
126
+
127
+ describe("Variants", () => {
128
+ test("applies primary variant classes by default", async () => {
129
+ render(<Button>Default</Button>);
130
+ const button = page.getByRole("button", { name: "Default" });
131
+ // Button uses semantic token classes
132
+ await expect.element(button).toHaveClass(/bg-button-primary-bg/);
133
+ });
134
+ });
135
+ });
@@ -0,0 +1,139 @@
1
+ import {
2
+ Button as BaseButton,
3
+ type ButtonProps as BaseButtonProps,
4
+ } from "@base-ui-components/react/button";
5
+ import * as React from "react";
6
+ import { tv, type VariantProps } from "tailwind-variants";
7
+ import { type ButtonTheme, buttonThemeToStyleVars } from "../../../lib/theme";
8
+
9
+ /**
10
+ * Button component based on Figma BaseKit / Interface / Buttons
11
+ *
12
+ * Variants:
13
+ * - primary: Primary filled button using semantic color tokens
14
+ * - primaryOutline: Outlined primary button (for light backgrounds)
15
+ * - secondary: Secondary button using semantic color tokens
16
+ * - charcoal: Dark filled button (for light backgrounds) - legacy
17
+ * - charcoalOutline: Dark outlined button (for light backgrounds)
18
+ * - charcoalOutlineQuiet: Subtle dark outlined button (for light backgrounds)
19
+ * - ivory: Light filled button (for dark backgrounds)
20
+ * - ivoryOutline: Light outlined button (for dark backgrounds)
21
+ * - ivoryOutlineQuiet: Subtle light outlined button (for dark backgrounds)
22
+ * - gray: Gray filled button (for dark backgrounds)
23
+ *
24
+ * Sizes:
25
+ * - lg: Large buttons
26
+ * - default: Medium buttons
27
+ * - sm: Small buttons
28
+ *
29
+ * For icon-only buttons, use the IconButton component instead.
30
+ *
31
+ * Theme Support:
32
+ * Pass a `theme` prop to override default colors via CSS custom properties.
33
+ * Surface tokens (--radius-surface-button, --surface-button-stroke) control
34
+ * border radius and stroke width across all variants.
35
+ */
36
+ const buttonVariants = tv({
37
+ base: "inline-flex items-center justify-center gap-spacing-8 whitespace-nowrap transition-colors duration-150 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 rounded-surface-button stroke-surface-button border-solid",
38
+ variants: {
39
+ variant: {
40
+ // Primary - uses semantic color tokens
41
+ primary:
42
+ "bg-button-primary-bg text-text-inverted hover:bg-button-primary-bg-hover border-transparent focus-visible:ring-button-primary-bg",
43
+ // Primary Outline - outlined primary (for light backgrounds)
44
+ primaryOutline:
45
+ "bg-transparent text-text-primary border-border-strong hover:bg-alpha-black-5 active:bg-alpha-black-10 focus-visible:ring-button-primary-bg",
46
+ // Secondary - uses semantic color tokens
47
+ secondary:
48
+ "bg-button-secondary-bg text-text-primary border-border-subtle hover:bg-button-secondary-bg-hover focus-visible:ring-button-primary-bg",
49
+ // Charcoal (dark filled) - primary dark (legacy)
50
+ charcoal:
51
+ "bg-gray-1200 text-gray-100 hover:bg-gray-1100 active:bg-gray-1000 border-transparent focus-visible:ring-gray-1000",
52
+ // Charcoal Outline - outlined dark (for light backgrounds)
53
+ charcoalOutline:
54
+ "border-border-strong text-gray-1000 hover:bg-alpha-black-5 active:bg-alpha-black-10 focus-visible:ring-gray-1000",
55
+ // Charcoal Outline Quiet - subtle outlined dark (for light backgrounds)
56
+ charcoalOutlineQuiet:
57
+ "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",
58
+ // Ivory (light filled) - primary light (for dark backgrounds)
59
+ ivory:
60
+ "bg-gray-50 text-gray-1000 hover:bg-gray-100 active:bg-gray-200 border-transparent focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
61
+ // Ivory Outline - outlined light (for dark backgrounds)
62
+ ivoryOutline:
63
+ "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",
64
+ // Ivory Outline Quiet - subtle light outline (for dark backgrounds)
65
+ ivoryOutlineQuiet:
66
+ "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",
67
+ // Gray - gray filled button (for dark backgrounds)
68
+ gray: "bg-gray-800 text-gray-100 hover:bg-gray-700 active:bg-gray-600 border-transparent focus-visible:ring-gray-700 focus-visible:ring-offset-gray-1000",
69
+ // Themed - uses CSS custom properties for styling
70
+ themed:
71
+ "[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)))]",
72
+ },
73
+ size: {
74
+ // Large button - uses primitive spacing tokens
75
+ lg: "px-spacing-24 py-spacing-12 typography-brand-large-button-large h-spacing-48",
76
+ // Medium button (default) - uses primitive spacing tokens
77
+ default:
78
+ "px-spacing-20 py-spacing-10 typography-brand-medium-button-medium h-spacing-40",
79
+ // Small button - uses primitive spacing tokens
80
+ sm: "px-spacing-16 py-spacing-8 typography-brand-small-button-small h-spacing-32",
81
+ },
82
+ },
83
+ defaultVariants: {
84
+ variant: "primary",
85
+ size: "default",
86
+ },
87
+ });
88
+
89
+ export type HTMLButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
90
+ export type ButtonProps = BaseButtonProps &
91
+ VariantProps<typeof buttonVariants> &
92
+ HTMLButtonProps & {
93
+ /**
94
+ * Theme overrides for button styling via CSS custom properties
95
+ */
96
+ theme?: ButtonTheme;
97
+ };
98
+
99
+ /**
100
+ * Check if a ButtonTheme has any actual values set
101
+ */
102
+ function hasThemeValues(theme: ButtonTheme | undefined): boolean {
103
+ if (!theme) return false;
104
+ return Object.values(theme).some((v) => v !== undefined && v !== null);
105
+ }
106
+
107
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
108
+ (
109
+ { className, variant, size, render, nativeButton, theme, style, ...props },
110
+ ref,
111
+ ) => {
112
+ // When render prop is provided, default nativeButton to false to suppress warnings
113
+ const isNativeButton = nativeButton ?? render === undefined;
114
+
115
+ // If theme has actual values, use "themed" variant to enable CSS custom property styling
116
+ const hasTheme = hasThemeValues(theme);
117
+ const effectiveVariant = hasTheme ? "themed" : variant;
118
+ const themeStyles = buttonThemeToStyleVars(theme);
119
+ const combinedStyles = hasTheme ? { ...themeStyles, ...style } : style;
120
+
121
+ return (
122
+ <BaseButton
123
+ className={buttonVariants({
124
+ variant: effectiveVariant,
125
+ size,
126
+ class: className,
127
+ })}
128
+ ref={ref}
129
+ render={render}
130
+ nativeButton={isNativeButton}
131
+ style={combinedStyles}
132
+ {...props}
133
+ />
134
+ );
135
+ },
136
+ );
137
+ Button.displayName = "Button";
138
+
139
+ export { Button, buttonVariants };
@@ -0,0 +1,102 @@
1
+ import { render } from "@testing-library/react";
2
+ import { describe, expect, test } from "vitest";
3
+ import { page } from "vitest/browser";
4
+ import { Button } from "./button";
5
+
6
+ describe("Button Visual Regression", () => {
7
+ test("charcoal variant renders correctly", async () => {
8
+ render(<Button variant="charcoal">Charcoal Button</Button>);
9
+
10
+ await expect(
11
+ page.getByRole("button", { name: "Charcoal Button" }),
12
+ ).toMatchScreenshot("button-charcoal");
13
+ });
14
+
15
+ test("charcoalOutline variant renders correctly", async () => {
16
+ render(<Button variant="charcoalOutline">Charcoal Outline Button</Button>);
17
+
18
+ await expect(
19
+ page.getByRole("button", { name: "Charcoal Outline Button" }),
20
+ ).toMatchScreenshot("button-charcoal-outline");
21
+ });
22
+
23
+ test("charcoalOutlineQuiet variant renders correctly", async () => {
24
+ render(
25
+ <Button variant="charcoalOutlineQuiet">Charcoal Outline Quiet</Button>,
26
+ );
27
+
28
+ await expect(
29
+ page.getByRole("button", { name: "Charcoal Outline Quiet" }),
30
+ ).toMatchScreenshot("button-charcoal-outline-quiet");
31
+ });
32
+
33
+ test("ivory variant renders correctly", async () => {
34
+ render(
35
+ <div style={{ background: "#1a1a1a", padding: "20px" }}>
36
+ <Button variant="ivory">Ivory Button</Button>
37
+ </div>,
38
+ );
39
+
40
+ await expect(
41
+ page.getByRole("button", { name: "Ivory Button" }),
42
+ ).toMatchScreenshot("button-ivory");
43
+ });
44
+
45
+ test("ivoryOutline variant renders correctly", async () => {
46
+ render(
47
+ <div style={{ background: "#1a1a1a", padding: "20px" }}>
48
+ <Button variant="ivoryOutline">Ivory Outline Button</Button>
49
+ </div>,
50
+ );
51
+
52
+ await expect(
53
+ page.getByRole("button", { name: "Ivory Outline Button" }),
54
+ ).toMatchScreenshot("button-ivory-outline");
55
+ });
56
+
57
+ test("ivoryOutlineQuiet variant renders correctly", async () => {
58
+ render(
59
+ <div style={{ background: "#1a1a1a", padding: "20px" }}>
60
+ <Button variant="ivoryOutlineQuiet">Ivory Outline Quiet</Button>
61
+ </div>,
62
+ );
63
+
64
+ await expect(
65
+ page.getByRole("button", { name: "Ivory Outline Quiet" }),
66
+ ).toMatchScreenshot("button-ivory-outline-quiet");
67
+ });
68
+
69
+ // Size variants
70
+ test("small size renders correctly", async () => {
71
+ render(<Button size="sm">Small Button</Button>);
72
+
73
+ await expect(
74
+ page.getByRole("button", { name: "Small Button" }),
75
+ ).toMatchScreenshot("button-size-small");
76
+ });
77
+
78
+ test("medium (default) size renders correctly", async () => {
79
+ render(<Button size="default">Medium Button</Button>);
80
+
81
+ await expect(
82
+ page.getByRole("button", { name: "Medium Button" }),
83
+ ).toMatchScreenshot("button-size-medium");
84
+ });
85
+
86
+ test("large size renders correctly", async () => {
87
+ render(<Button size="lg">Large Button</Button>);
88
+
89
+ await expect(
90
+ page.getByRole("button", { name: "Large Button" }),
91
+ ).toMatchScreenshot("button-size-large");
92
+ });
93
+
94
+ // Disabled state
95
+ test("disabled state renders correctly", async () => {
96
+ render(<Button disabled>Disabled Button</Button>);
97
+
98
+ await expect(
99
+ page.getByRole("button", { name: "Disabled Button" }),
100
+ ).toMatchScreenshot("button-disabled");
101
+ });
102
+ });
@@ -0,0 +1,166 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { IconButton } from ".";
3
+
4
+ // Simple placeholder icons for stories
5
+ const SearchIcon = () => (
6
+ <svg
7
+ width="16"
8
+ height="16"
9
+ viewBox="0 0 16 16"
10
+ fill="none"
11
+ xmlns="http://www.w3.org/2000/svg"
12
+ aria-hidden="true"
13
+ >
14
+ <path
15
+ d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z"
16
+ stroke="currentColor"
17
+ strokeWidth="1.5"
18
+ strokeLinecap="round"
19
+ strokeLinejoin="round"
20
+ />
21
+ <path
22
+ d="M14 14L11.1 11.1"
23
+ stroke="currentColor"
24
+ strokeWidth="1.5"
25
+ strokeLinecap="round"
26
+ strokeLinejoin="round"
27
+ />
28
+ </svg>
29
+ );
30
+
31
+ const ArrowRightIcon = () => (
32
+ <svg
33
+ width="12"
34
+ height="12"
35
+ viewBox="0 0 12 12"
36
+ fill="none"
37
+ xmlns="http://www.w3.org/2000/svg"
38
+ aria-hidden="true"
39
+ >
40
+ <path
41
+ d="M2.5 6H9.5M9.5 6L6 2.5M9.5 6L6 9.5"
42
+ stroke="currentColor"
43
+ strokeWidth="1.5"
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ />
47
+ </svg>
48
+ );
49
+
50
+ const meta: Meta<typeof IconButton> = {
51
+ title: "Atoms/IconButton",
52
+ } as Meta<typeof IconButton>;
53
+
54
+ export default meta;
55
+ type Story = StoryObj<typeof IconButton>;
56
+
57
+ export const Playground: Story = {
58
+ render: (args) => (
59
+ <IconButton {...args}>
60
+ <SearchIcon />
61
+ </IconButton>
62
+ ),
63
+ };
64
+ Playground.argTypes = {
65
+ size: {
66
+ control: {
67
+ type: "radio",
68
+ },
69
+ options: ["sm", "default", "lg"],
70
+ },
71
+ disabled: {
72
+ control: {
73
+ type: "boolean",
74
+ },
75
+ },
76
+ variant: {
77
+ control: {
78
+ type: "radio",
79
+ },
80
+ options: [
81
+ "charcoal",
82
+ "charcoalOutline",
83
+ "charcoalOutlineQuiet",
84
+ "ivory",
85
+ "ivoryOutline",
86
+ "ivoryOutlineQuiet",
87
+ ],
88
+ },
89
+ };
90
+ Playground.args = {
91
+ size: "default",
92
+ disabled: false,
93
+ variant: "charcoal",
94
+ };
95
+
96
+ // =============================================================================
97
+ // Variants
98
+ // =============================================================================
99
+
100
+ export const Charcoal = () => (
101
+ <IconButton variant="charcoal">
102
+ <SearchIcon />
103
+ </IconButton>
104
+ );
105
+
106
+ export const CharcoalOutline = () => (
107
+ <IconButton variant="charcoalOutline">
108
+ <SearchIcon />
109
+ </IconButton>
110
+ );
111
+
112
+ export const CharcoalOutlineQuiet = () => (
113
+ <IconButton variant="charcoalOutlineQuiet">
114
+ <SearchIcon />
115
+ </IconButton>
116
+ );
117
+
118
+ export const Ivory = () => (
119
+ <IconButton variant="ivory">
120
+ <SearchIcon />
121
+ </IconButton>
122
+ );
123
+
124
+ export const IvoryOutline = () => (
125
+ <IconButton variant="ivoryOutline">
126
+ <ArrowRightIcon />
127
+ </IconButton>
128
+ );
129
+
130
+ export const IvoryOutlineQuiet = () => (
131
+ <IconButton variant="ivoryOutlineQuiet">
132
+ <ArrowRightIcon />
133
+ </IconButton>
134
+ );
135
+
136
+ // =============================================================================
137
+ // Sizes
138
+ // =============================================================================
139
+
140
+ export const Small = () => (
141
+ <IconButton size="sm">
142
+ <ArrowRightIcon />
143
+ </IconButton>
144
+ );
145
+
146
+ export const Medium = () => (
147
+ <IconButton size="default">
148
+ <SearchIcon />
149
+ </IconButton>
150
+ );
151
+
152
+ export const Large = () => (
153
+ <IconButton size="lg">
154
+ <SearchIcon />
155
+ </IconButton>
156
+ );
157
+
158
+ // =============================================================================
159
+ // States
160
+ // =============================================================================
161
+
162
+ export const Disabled = () => (
163
+ <IconButton disabled>
164
+ <SearchIcon />
165
+ </IconButton>
166
+ );
@@ -0,0 +1,120 @@
1
+ import { Slot } from "@radix-ui/react-slot";
2
+ import * as React from "react";
3
+ import { tv, type VariantProps } from "tailwind-variants";
4
+
5
+ /**
6
+ * IconButton component based on Figma BaseKit / Interface / Icon Button
7
+ *
8
+ * **IMPORTANT: Accessibility Requirement**
9
+ * Icon-only buttons MUST have an accessible label. Provide one of:
10
+ * - `aria-label`: A text description of the button's action (recommended)
11
+ * - `aria-labelledby`: Reference to an element containing the label
12
+ * - `title`: Tooltip text (less preferred, but provides a label)
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * // Correct usage with aria-label
17
+ * <IconButton aria-label="Close menu">
18
+ * <CloseIcon />
19
+ * </IconButton>
20
+ *
21
+ * // Correct usage with aria-labelledby
22
+ * <IconButton aria-labelledby="close-label">
23
+ * <CloseIcon />
24
+ * </IconButton>
25
+ * <span id="close-label" className="sr-only">Close menu</span>
26
+ * ```
27
+ *
28
+ * Variants:
29
+ * - charcoal: Dark filled button (for light backgrounds)
30
+ * - charcoalOutline: Dark outlined button (for light backgrounds)
31
+ * - charcoalOutlineQuiet: Subtle dark outlined button (for light backgrounds)
32
+ * - ghost: No background/border, just icon (for light backgrounds)
33
+ * - ghostDark: No background/border, just icon (for dark backgrounds)
34
+ * - ivory: Light filled button (for dark backgrounds)
35
+ * - ivoryOutline: Light outlined button (for dark backgrounds)
36
+ * - ivoryOutlineQuiet: Subtle light outlined button (for dark backgrounds)
37
+ *
38
+ * Sizes:
39
+ * - lg: Large (46x46)
40
+ * - default: Medium (36x36)
41
+ * - sm: Small (29x29)
42
+ */
43
+ const iconButtonVariants = tv({
44
+ base: "inline-flex items-center justify-center whitespace-nowrap transition-colors duration-150 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
45
+ variants: {
46
+ variant: {
47
+ // Charcoal (dark filled) - primary dark
48
+ charcoal:
49
+ "bg-gray-1200 text-gray-100 hover:bg-gray-1100 active:bg-gray-1000 focus-visible:ring-gray-1000",
50
+ // Charcoal Outline - outlined dark (for light backgrounds)
51
+ charcoalOutline:
52
+ "border border-alpha-black-30 text-gray-1000 hover:bg-alpha-black-5 active:bg-alpha-black-10 focus-visible:ring-gray-1000",
53
+ // Charcoal Outline Quiet - subtle outlined dark (for light backgrounds)
54
+ charcoalOutlineQuiet:
55
+ "border border-alpha-black-20 text-alpha-black-60 hover:border-alpha-black-30 hover:text-alpha-black-80 active:bg-alpha-black-5 focus-visible:ring-gray-1000",
56
+ // Ghost - no background/border (for light backgrounds)
57
+ ghost:
58
+ "text-gray-700 hover:text-gray-900 hover:bg-alpha-black-5 active:bg-alpha-black-10 focus-visible:ring-gray-1000",
59
+ // Ghost Dark - no background/border (for dark backgrounds)
60
+ ghostDark:
61
+ "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",
62
+ // Ivory (light filled) - primary light (for dark backgrounds)
63
+ ivory:
64
+ "bg-gray-50 text-gray-1000 hover:bg-gray-100 active:bg-gray-200 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
65
+ // Ivory Outline - outlined light (for dark backgrounds)
66
+ ivoryOutline:
67
+ "border 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",
68
+ // Ivory Outline Quiet - subtle light outline (for dark backgrounds)
69
+ ivoryOutlineQuiet:
70
+ "border 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",
71
+ },
72
+ size: {
73
+ // Large (48x48) - uses primitive spacing tokens
74
+ lg: "rounded-radius-12 size-spacing-48",
75
+ // Medium (40x40) - default - uses primitive spacing tokens
76
+ default: "rounded-radius-12 size-spacing-40",
77
+ // Small (32x32) - uses primitive spacing tokens
78
+ sm: "rounded-radius-10 size-spacing-32",
79
+ },
80
+ },
81
+ defaultVariants: {
82
+ variant: "charcoal",
83
+ size: "default",
84
+ },
85
+ });
86
+
87
+ export interface IconButtonProps
88
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
89
+ VariantProps<typeof iconButtonVariants> {
90
+ asChild?: boolean;
91
+ }
92
+
93
+ const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
94
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
95
+ // Development warning for missing accessible label
96
+ React.useEffect(() => {
97
+ if (import.meta.env?.DEV) {
98
+ const hasAccessibleLabel =
99
+ props["aria-label"] || props["aria-labelledby"] || props.title;
100
+ if (!hasAccessibleLabel) {
101
+ console.warn(
102
+ "IconButton: Missing accessible label. Icon-only buttons must have an aria-label, aria-labelledby, or title attribute for screen reader users.",
103
+ );
104
+ }
105
+ }
106
+ }, [props["aria-label"], props["aria-labelledby"], props.title]);
107
+
108
+ const Comp = asChild ? Slot : "button";
109
+ return (
110
+ <Comp
111
+ className={iconButtonVariants({ variant, size, class: className })}
112
+ ref={ref}
113
+ {...props}
114
+ />
115
+ );
116
+ },
117
+ );
118
+ IconButton.displayName = "IconButton";
119
+
120
+ export { IconButton, iconButtonVariants };
@@ -0,0 +1,6 @@
1
+ export { Button, type ButtonProps, buttonVariants } from "./button";
2
+ export {
3
+ IconButton,
4
+ type IconButtonProps,
5
+ iconButtonVariants,
6
+ } from "./icon-button";
@@ -0,0 +1 @@
1
+ export { NdstudioFooter, type NdstudioFooterProps } from "./ndstudio-footer";