@nationaldesignstudio/react 0.0.14 → 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 (166) hide show
  1. package/dist/tailwind.css +15 -1
  2. package/dist/tokens.css +45 -60
  3. package/package.json +5 -10
  4. package/src/App.css +0 -0
  5. package/src/App.tsx +7 -0
  6. package/src/assets/fonts/PPNeueMontreal-Variable.woff2 +0 -0
  7. package/src/assets/react.svg +1 -0
  8. package/src/components/atoms/accordion/accordion.stories.tsx +228 -0
  9. package/src/components/atoms/accordion/accordion.tsx +219 -0
  10. package/src/components/atoms/accordion/index.ts +6 -0
  11. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-chromium-darwin.png +0 -0
  12. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-chromium-linux.png +0 -0
  13. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-chromium-darwin.png +0 -0
  14. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-chromium-linux.png +0 -0
  15. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-quiet-chromium-darwin.png +0 -0
  16. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-charcoal-outline-quiet-chromium-linux.png +0 -0
  17. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-disabled-chromium-darwin.png +0 -0
  18. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-disabled-chromium-linux.png +0 -0
  19. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-chromium-darwin.png +0 -0
  20. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-chromium-linux.png +0 -0
  21. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-chromium-darwin.png +0 -0
  22. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-chromium-linux.png +0 -0
  23. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-quiet-chromium-darwin.png +0 -0
  24. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-ivory-outline-quiet-chromium-linux.png +0 -0
  25. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-large-chromium-darwin.png +0 -0
  26. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-large-chromium-linux.png +0 -0
  27. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-medium-chromium-darwin.png +0 -0
  28. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-medium-chromium-linux.png +0 -0
  29. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-small-chromium-darwin.png +0 -0
  30. package/src/components/atoms/button/__screenshots__/button.visual.test.tsx/button-size-small-chromium-linux.png +0 -0
  31. package/src/components/atoms/button/button.stories.tsx +102 -0
  32. package/src/components/atoms/button/button.test.tsx +135 -0
  33. package/src/components/atoms/button/button.tsx +139 -0
  34. package/src/components/atoms/button/button.visual.test.tsx +102 -0
  35. package/src/components/atoms/button/icon-button.stories.tsx +166 -0
  36. package/src/components/atoms/button/icon-button.tsx +120 -0
  37. package/src/components/atoms/button/index.ts +6 -0
  38. package/src/components/atoms/ndstudio-footer/index.ts +1 -0
  39. package/src/components/atoms/ndstudio-footer/ndstudio-footer.tsx +55 -0
  40. package/src/components/atoms/pager-control/index.ts +5 -0
  41. package/src/components/atoms/pager-control/pager-control.stories.tsx +209 -0
  42. package/src/components/atoms/pager-control/pager-control.test.tsx +130 -0
  43. package/src/components/atoms/pager-control/pager-control.tsx +329 -0
  44. package/src/components/dev-tools/dev-toolbar/dev-toolbar.stories.tsx +82 -0
  45. package/src/components/dev-tools/dev-toolbar/dev-toolbar.tsx +196 -0
  46. package/src/components/dev-tools/dev-toolbar/index.ts +1 -0
  47. package/src/components/dev-tools/grid-overlay/grid-overlay.tsx +41 -0
  48. package/src/components/dev-tools/grid-overlay/index.ts +1 -0
  49. package/src/components/dev-tools/index.ts +2 -0
  50. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-default-vertical-chromium-darwin.png +0 -0
  51. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-default-vertical-chromium-linux.png +0 -0
  52. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-horizontal-chromium-darwin.png +0 -0
  53. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-horizontal-chromium-linux.png +0 -0
  54. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-minimal-chromium-darwin.png +0 -0
  55. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-minimal-chromium-linux.png +0 -0
  56. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-actions-chromium-darwin.png +0 -0
  57. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-actions-chromium-linux.png +0 -0
  58. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-eyebrow-chromium-darwin.png +0 -0
  59. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-eyebrow-chromium-linux.png +0 -0
  60. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-image-chromium-darwin.png +0 -0
  61. package/src/components/organisms/card/__screenshots__/card.visual.test.tsx/card-without-image-chromium-linux.png +0 -0
  62. package/src/components/organisms/card/card.stories.tsx +293 -0
  63. package/src/components/organisms/card/card.test.tsx +245 -0
  64. package/src/components/organisms/card/card.tsx +225 -0
  65. package/src/components/organisms/card/card.visual.test.tsx +197 -0
  66. package/src/components/organisms/card/index.ts +19 -0
  67. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-active-link-chromium-darwin.png +0 -0
  68. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-active-link-chromium-linux.png +0 -0
  69. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-brand-only-chromium-darwin.png +0 -0
  70. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-brand-only-chromium-linux.png +0 -0
  71. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-default-chromium-darwin.png +0 -0
  72. package/src/components/organisms/navbar/__screenshots__/navbar.visual.test.tsx/navbar-default-chromium-linux.png +0 -0
  73. package/src/components/organisms/navbar/index.ts +18 -0
  74. package/src/components/organisms/navbar/navbar.stories.tsx +313 -0
  75. package/src/components/organisms/navbar/navbar.test.tsx +190 -0
  76. package/src/components/organisms/navbar/navbar.tsx +323 -0
  77. package/src/components/organisms/navbar/navbar.visual.test.tsx +85 -0
  78. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-icon-chromium-darwin.png +0 -0
  79. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-icon-chromium-linux.png +0 -0
  80. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-text-chromium-darwin.png +0 -0
  81. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-custom-text-chromium-linux.png +0 -0
  82. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-default-chromium-darwin.png +0 -0
  83. package/src/components/organisms/us-gov-banner/__screenshots__/us-gov-banner.visual.test.tsx/us-gov-banner-default-chromium-linux.png +0 -0
  84. package/src/components/organisms/us-gov-banner/index.ts +1 -0
  85. package/src/components/organisms/us-gov-banner/us-gov-banner.stories.tsx +35 -0
  86. package/src/components/organisms/us-gov-banner/us-gov-banner.test.tsx +107 -0
  87. package/src/components/organisms/us-gov-banner/us-gov-banner.tsx +73 -0
  88. package/src/components/organisms/us-gov-banner/us-gov-banner.visual.test.tsx +46 -0
  89. package/src/components/sections/banner/banner.stories.tsx +150 -0
  90. package/src/components/sections/banner/banner.test.tsx +185 -0
  91. package/src/components/sections/banner/banner.tsx +130 -0
  92. package/src/components/sections/banner/index.ts +2 -0
  93. package/src/components/sections/card-grid/card-grid.stories.tsx +351 -0
  94. package/src/components/sections/card-grid/card-grid.tsx +116 -0
  95. package/src/components/sections/card-grid/index.ts +1 -0
  96. package/src/components/sections/faq-section/faq-section.stories.tsx +453 -0
  97. package/src/components/sections/faq-section/faq-section.tsx +84 -0
  98. package/src/components/sections/faq-section/index.ts +2 -0
  99. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-desktop-chromium-darwin.png +0 -0
  100. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-desktop-chromium-linux.png +0 -0
  101. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-mobile-chromium-darwin.png +0 -0
  102. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-mobile-chromium-linux.png +0 -0
  103. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-tablet-chromium-darwin.png +0 -0
  104. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a1-tablet-chromium-linux.png +0 -0
  105. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-desktop-chromium-darwin.png +0 -0
  106. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-desktop-chromium-linux.png +0 -0
  107. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-mobile-chromium-darwin.png +0 -0
  108. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-mobile-chromium-linux.png +0 -0
  109. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-tablet-chromium-darwin.png +0 -0
  110. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a2-tablet-chromium-linux.png +0 -0
  111. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-desktop-chromium-darwin.png +0 -0
  112. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-desktop-chromium-linux.png +0 -0
  113. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-mobile-chromium-darwin.png +0 -0
  114. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-mobile-chromium-linux.png +0 -0
  115. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-tablet-chromium-darwin.png +0 -0
  116. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-a3-tablet-chromium-linux.png +0 -0
  117. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-custom-class-chromium-darwin.png +0 -0
  118. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-custom-class-chromium-linux.png +0 -0
  119. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-default-chromium-linux.png +0 -0
  120. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-long-title-chromium-darwin.png +0 -0
  121. package/src/components/sections/hero/__screenshots__/hero.visual.test.tsx/hero-long-title-chromium-linux.png +0 -0
  122. package/src/components/sections/hero/hero.stories.tsx +274 -0
  123. package/src/components/sections/hero/hero.test.tsx +135 -0
  124. package/src/components/sections/hero/hero.tsx +453 -0
  125. package/src/components/sections/hero/hero.visual.test.tsx +140 -0
  126. package/src/components/sections/hero/index.ts +10 -0
  127. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-h3-heading-chromium-darwin.png +0 -0
  128. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-h3-heading-chromium-linux.png +0 -0
  129. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-paragraphs-chromium-darwin.png +0 -0
  130. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-paragraphs-chromium-linux.png +0 -0
  131. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-sections-chromium-darwin.png +0 -0
  132. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-multiple-sections-chromium-linux.png +0 -0
  133. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-single-section-chromium-darwin.png +0 -0
  134. package/src/components/sections/prose/__screenshots__/prose.visual.test.tsx/prose-single-section-chromium-linux.png +0 -0
  135. package/src/components/sections/prose/index.ts +6 -0
  136. package/src/components/sections/prose/prose.stories.tsx +144 -0
  137. package/src/components/sections/prose/prose.test.tsx +178 -0
  138. package/src/components/sections/prose/prose.tsx +88 -0
  139. package/src/components/sections/prose/prose.visual.test.tsx +105 -0
  140. package/src/components/sections/river/index.ts +1 -0
  141. package/src/components/sections/river/river.stories.tsx +237 -0
  142. package/src/components/sections/river/river.test.tsx +268 -0
  143. package/src/components/sections/river/river.tsx +173 -0
  144. package/src/components/sections/tout/index.ts +1 -0
  145. package/src/components/sections/tout/tout.stories.tsx +171 -0
  146. package/src/components/sections/tout/tout.test.tsx +242 -0
  147. package/src/components/sections/tout/tout.tsx +270 -0
  148. package/src/components/sections/two-column-section/index.ts +5 -0
  149. package/src/components/sections/two-column-section/two-column-section.stories.tsx +285 -0
  150. package/src/components/sections/two-column-section/two-column-section.tsx +162 -0
  151. package/src/hooks/index.ts +1 -0
  152. package/src/hooks/use-event-listener.ts +73 -0
  153. package/src/index.ts +155 -0
  154. package/src/lib/theme.ts +1000 -0
  155. package/src/lib/utils.ts +6 -0
  156. package/src/main.tsx +13 -0
  157. package/src/stories/GridSystem.stories.tsx +84 -0
  158. package/src/stories/Introduction.mdx +114 -0
  159. package/src/stories/ThemeProvider.stories.tsx +357 -0
  160. package/src/stories/TokenShowcase.stories.tsx +92 -0
  161. package/src/stories/TokenShowcase.tsx +1429 -0
  162. package/src/styles.css +11 -0
  163. package/src/theme/ThemeProvider.tsx +297 -0
  164. package/src/theme/hooks.ts +40 -0
  165. package/src/theme/index.ts +43 -0
  166. package/src/theme/utils.ts +104 -0
@@ -0,0 +1,219 @@
1
+ "use client";
2
+
3
+ import { Accordion as BaseAccordion } from "@base-ui-components/react/accordion";
4
+ import * as React from "react";
5
+ import { tv, type VariantProps } from "tailwind-variants";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ // =============================================================================
9
+ // Accordion Variants
10
+ // =============================================================================
11
+
12
+ const accordionVariants = tv({
13
+ base: "flex flex-col",
14
+ variants: {
15
+ variant: {
16
+ dark: "",
17
+ light: "",
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: "dark",
22
+ },
23
+ });
24
+
25
+ const accordionItemVariants = tv({
26
+ base: "border-b overflow-hidden",
27
+ variants: {
28
+ variant: {
29
+ dark: "border-gray-300",
30
+ light: "border-gray-500",
31
+ },
32
+ },
33
+ defaultVariants: {
34
+ variant: "dark",
35
+ },
36
+ });
37
+
38
+ const accordionTriggerVariants = tv({
39
+ base: [
40
+ // Uses primitive spacing tokens
41
+ "flex w-full items-center justify-between py-spacing-24 text-left",
42
+ "typography-body-large transition-colors cursor-pointer",
43
+ ],
44
+ variants: {
45
+ variant: {
46
+ dark: "text-gray-100 data-[open]:text-white",
47
+ light: "text-gray-800 data-[open]:text-gray-900",
48
+ },
49
+ },
50
+ defaultVariants: {
51
+ variant: "dark",
52
+ },
53
+ });
54
+
55
+ const accordionPanelVariants = tv({
56
+ // Uses primitive spacing tokens
57
+ base: "typography-body-large pb-spacing-24",
58
+ variants: {
59
+ variant: {
60
+ dark: "text-gray-100",
61
+ light: "text-gray-800",
62
+ },
63
+ },
64
+ defaultVariants: {
65
+ variant: "dark",
66
+ },
67
+ });
68
+
69
+ // =============================================================================
70
+ // Accordion Context
71
+ // =============================================================================
72
+
73
+ const AccordionContext = React.createContext<{
74
+ variant: "dark" | "light";
75
+ }>({
76
+ variant: "dark",
77
+ });
78
+
79
+ // =============================================================================
80
+ // Accordion
81
+ // =============================================================================
82
+
83
+ export interface AccordionProps
84
+ extends React.HTMLAttributes<HTMLDivElement>,
85
+ VariantProps<typeof accordionVariants> {
86
+ /**
87
+ * Allow multiple items to be expanded at once
88
+ * @default false
89
+ */
90
+ allowMultiple?: boolean;
91
+ /**
92
+ * The ID(s) of items that should be expanded by default
93
+ */
94
+ defaultExpanded?: string | string[];
95
+ children: React.ReactNode;
96
+ }
97
+
98
+ /**
99
+ * Accordion component for expandable/collapsible content sections.
100
+ * Built on Base UI's Accordion primitive.
101
+ *
102
+ * Variants:
103
+ * - dark: Dark theme styling (default)
104
+ * - light: Light theme styling
105
+ *
106
+ * @example
107
+ * ```tsx
108
+ * <Accordion defaultExpanded="item-1" variant="dark">
109
+ * <AccordionItem id="item-1" title="Question 1">
110
+ * Answer to question 1
111
+ * </AccordionItem>
112
+ * <AccordionItem id="item-2" title="Question 2">
113
+ * Answer to question 2
114
+ * </AccordionItem>
115
+ * </Accordion>
116
+ * ```
117
+ */
118
+ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
119
+ (
120
+ {
121
+ className,
122
+ allowMultiple = false,
123
+ defaultExpanded,
124
+ variant = "light",
125
+ children,
126
+ },
127
+ ref,
128
+ ) => {
129
+ // Normalize defaultExpanded to array format for Base UI
130
+ const defaultValue = React.useMemo((): string[] | undefined => {
131
+ if (!defaultExpanded) return undefined;
132
+ if (Array.isArray(defaultExpanded)) return defaultExpanded;
133
+ return [defaultExpanded];
134
+ }, [defaultExpanded]);
135
+
136
+ return (
137
+ <AccordionContext.Provider value={{ variant: variant ?? "dark" }}>
138
+ <BaseAccordion.Root
139
+ ref={ref}
140
+ className={accordionVariants({ variant, class: className })}
141
+ defaultValue={defaultValue}
142
+ multiple={allowMultiple}
143
+ >
144
+ {children}
145
+ </BaseAccordion.Root>
146
+ </AccordionContext.Provider>
147
+ );
148
+ },
149
+ );
150
+ Accordion.displayName = "Accordion";
151
+
152
+ // =============================================================================
153
+ // AccordionItem
154
+ // =============================================================================
155
+
156
+ export interface AccordionItemProps
157
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
158
+ /**
159
+ * Unique identifier for this item
160
+ */
161
+ id: string;
162
+ /**
163
+ * The question/title displayed in the header
164
+ */
165
+ title: string;
166
+ /**
167
+ * The answer/content revealed when expanded
168
+ */
169
+ children: React.ReactNode;
170
+ }
171
+
172
+ /**
173
+ * Individual accordion item with collapsible content.
174
+ * Must be used within an Accordion component.
175
+ */
176
+ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
177
+ ({ className, id, title, children }, ref) => {
178
+ const { variant } = React.useContext(AccordionContext);
179
+
180
+ return (
181
+ <BaseAccordion.Item
182
+ ref={ref}
183
+ value={id}
184
+ className={accordionItemVariants({ variant, class: className })}
185
+ >
186
+ {/* Header - always visible */}
187
+ <BaseAccordion.Header>
188
+ <BaseAccordion.Trigger
189
+ className={accordionTriggerVariants({ variant })}
190
+ >
191
+ <span>{title}</span>
192
+ <span
193
+ className={cn(
194
+ "text-base transition-transform duration-200",
195
+ "[[data-open]_&]:rotate-45",
196
+ )}
197
+ >
198
+ +
199
+ </span>
200
+ </BaseAccordion.Trigger>
201
+ </BaseAccordion.Header>
202
+
203
+ {/* Content - collapsible */}
204
+ <BaseAccordion.Panel
205
+ className={cn(
206
+ "h-[var(--accordion-panel-height)] overflow-hidden",
207
+ "transition-[height] duration-300 ease-out",
208
+ "[&[data-starting-style]]:h-0 [&[data-ending-style]]:h-0",
209
+ )}
210
+ >
211
+ <div className={accordionPanelVariants({ variant })}>{children}</div>
212
+ </BaseAccordion.Panel>
213
+ </BaseAccordion.Item>
214
+ );
215
+ },
216
+ );
217
+ AccordionItem.displayName = "AccordionItem";
218
+
219
+ export { Accordion, AccordionItem, accordionVariants };
@@ -0,0 +1,6 @@
1
+ export {
2
+ Accordion,
3
+ AccordionItem,
4
+ type AccordionItemProps,
5
+ type AccordionProps,
6
+ } from "./accordion";
@@ -0,0 +1,102 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Button } from ".";
3
+
4
+ const meta: Meta<typeof Button> = {
5
+ title: "Atoms/Button",
6
+ } as Meta<typeof Button>;
7
+
8
+ export default meta;
9
+ type Story = StoryObj<typeof Button>;
10
+
11
+ export const Playground: Story = {
12
+ render: (args) => <Button {...args}>Button</Button>,
13
+ };
14
+ Playground.argTypes = {
15
+ size: {
16
+ control: {
17
+ type: "radio",
18
+ },
19
+ options: ["sm", "default", "lg"],
20
+ },
21
+ disabled: {
22
+ control: {
23
+ type: "boolean",
24
+ },
25
+ },
26
+ variant: {
27
+ control: {
28
+ type: "radio",
29
+ },
30
+ options: [
31
+ "primary",
32
+ "primaryOutline",
33
+ "secondary",
34
+ "charcoal",
35
+ "charcoalOutline",
36
+ "charcoalOutlineQuiet",
37
+ "ivory",
38
+ "ivoryOutline",
39
+ "ivoryOutlineQuiet",
40
+ "gray",
41
+ ],
42
+ },
43
+ };
44
+ Playground.args = {
45
+ size: "default",
46
+ disabled: false,
47
+ variant: "primary",
48
+ };
49
+
50
+ // =============================================================================
51
+ // Semantic Variants (recommended)
52
+ // =============================================================================
53
+
54
+ export const Primary = () => <Button variant="primary">Primary</Button>;
55
+
56
+ export const PrimaryOutline = () => (
57
+ <Button variant="primaryOutline">Primary Outline</Button>
58
+ );
59
+
60
+ export const Secondary = () => <Button variant="secondary">Secondary</Button>;
61
+
62
+ // =============================================================================
63
+ // Legacy Variants
64
+ // =============================================================================
65
+
66
+ export const Charcoal = () => <Button variant="charcoal">Charcoal</Button>;
67
+
68
+ export const CharcoalOutline = () => (
69
+ <Button variant="charcoalOutline">Charcoal Outline</Button>
70
+ );
71
+
72
+ export const CharcoalOutlineQuiet = () => (
73
+ <Button variant="charcoalOutlineQuiet">Charcoal Outline Quiet</Button>
74
+ );
75
+
76
+ export const Ivory = () => <Button variant="ivory">Ivory</Button>;
77
+
78
+ export const IvoryOutline = () => (
79
+ <Button variant="ivoryOutline">Ivory Outline</Button>
80
+ );
81
+
82
+ export const IvoryOutlineQuiet = () => (
83
+ <Button variant="ivoryOutlineQuiet">Ivory Outline Quiet</Button>
84
+ );
85
+
86
+ export const Gray = () => <Button variant="gray">Gray</Button>;
87
+
88
+ // =============================================================================
89
+ // Sizes
90
+ // =============================================================================
91
+
92
+ export const Small = () => <Button size="sm">Small</Button>;
93
+
94
+ export const Medium = () => <Button size="default">Medium</Button>;
95
+
96
+ export const Large = () => <Button size="lg">Large</Button>;
97
+
98
+ // =============================================================================
99
+ // States
100
+ // =============================================================================
101
+
102
+ export const Disabled = () => <Button disabled>Disabled</Button>;
@@ -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 };