@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,245 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { page } from "vitest/browser";
3
+ import { render } from "vitest-browser-react";
4
+ import { Button } from "../../atoms/button";
5
+ import {
6
+ Card,
7
+ CardActions,
8
+ CardBody,
9
+ CardContent,
10
+ CardDescription,
11
+ CardEyebrow,
12
+ CardImage,
13
+ CardTitle,
14
+ } from "./card";
15
+
16
+ describe("Card", () => {
17
+ describe("Accessibility", () => {
18
+ test("CardTitle renders as h3 heading by default", async () => {
19
+ render(
20
+ <Card>
21
+ <CardContent>
22
+ <CardBody>
23
+ <CardTitle>Test Title</CardTitle>
24
+ </CardBody>
25
+ </CardContent>
26
+ </Card>,
27
+ );
28
+
29
+ await expect
30
+ .element(page.getByRole("heading", { level: 3, name: "Test Title" }))
31
+ .toBeInTheDocument();
32
+ });
33
+
34
+ test("CardTitle supports as prop to change heading level", async () => {
35
+ render(
36
+ <Card>
37
+ <CardContent>
38
+ <CardBody>
39
+ <CardTitle as="h2">H2 Title</CardTitle>
40
+ </CardBody>
41
+ </CardContent>
42
+ </Card>,
43
+ );
44
+
45
+ await expect
46
+ .element(page.getByRole("heading", { level: 2, name: "H2 Title" }))
47
+ .toBeInTheDocument();
48
+ });
49
+
50
+ test("CardImage renders img with alt text when src provided", async () => {
51
+ render(
52
+ <Card>
53
+ <CardImage
54
+ src="https://example.com/image.jpg"
55
+ alt="Test image description"
56
+ />
57
+ <CardContent>
58
+ <CardBody>
59
+ <CardTitle>Card with Image</CardTitle>
60
+ </CardBody>
61
+ </CardContent>
62
+ </Card>,
63
+ );
64
+
65
+ await expect
66
+ .element(page.getByRole("img", { name: "Test image description" }))
67
+ .toBeInTheDocument();
68
+ });
69
+
70
+ test("CardImage does not render img when no src provided", async () => {
71
+ render(
72
+ <Card>
73
+ <CardImage />
74
+ <CardContent>
75
+ <CardBody>
76
+ <CardTitle>Card without Image Source</CardTitle>
77
+ </CardBody>
78
+ </CardContent>
79
+ </Card>,
80
+ );
81
+
82
+ // Should not have an img element
83
+ const images = page.getByRole("img");
84
+ await expect.element(images).not.toBeInTheDocument();
85
+ });
86
+
87
+ test("buttons in CardActions are accessible", async () => {
88
+ render(
89
+ <Card>
90
+ <CardContent>
91
+ <CardBody>
92
+ <CardTitle>Card with Actions</CardTitle>
93
+ </CardBody>
94
+ <CardActions>
95
+ <Button>Primary Action</Button>
96
+ <Button variant="charcoalOutline">Secondary Action</Button>
97
+ </CardActions>
98
+ </CardContent>
99
+ </Card>,
100
+ );
101
+
102
+ await expect
103
+ .element(page.getByRole("button", { name: "Primary Action" }))
104
+ .toBeInTheDocument();
105
+ await expect
106
+ .element(page.getByRole("button", { name: "Secondary Action" }))
107
+ .toBeInTheDocument();
108
+ });
109
+
110
+ test("Card structure is semantically correct", async () => {
111
+ render(
112
+ <Card data-testid="card">
113
+ <CardContent>
114
+ <CardBody>
115
+ <CardEyebrow>Eyebrow Text</CardEyebrow>
116
+ <CardTitle>Card Title</CardTitle>
117
+ <CardDescription>Card description text</CardDescription>
118
+ </CardBody>
119
+ </CardContent>
120
+ </Card>,
121
+ );
122
+
123
+ // Verify all text content is present and readable
124
+ await expect.element(page.getByText("Eyebrow Text")).toBeInTheDocument();
125
+ await expect.element(page.getByText("Card Title")).toBeInTheDocument();
126
+ await expect
127
+ .element(page.getByText("Card description text"))
128
+ .toBeInTheDocument();
129
+ });
130
+ });
131
+
132
+ describe("Layout Variants", () => {
133
+ test("vertical layout applies correct classes", async () => {
134
+ render(
135
+ <Card layout="vertical" data-testid="card">
136
+ <CardContent>
137
+ <CardBody>
138
+ <CardTitle>Vertical Card</CardTitle>
139
+ </CardBody>
140
+ </CardContent>
141
+ </Card>,
142
+ );
143
+
144
+ const card = page.getByTestId("card");
145
+ await expect.element(card).toHaveClass(/flex-col/);
146
+ });
147
+
148
+ test("horizontal layout applies correct classes", async () => {
149
+ render(
150
+ <Card layout="horizontal" data-testid="card">
151
+ <CardContent>
152
+ <CardBody>
153
+ <CardTitle>Horizontal Card</CardTitle>
154
+ </CardBody>
155
+ </CardContent>
156
+ </Card>,
157
+ );
158
+
159
+ const card = page.getByTestId("card");
160
+ await expect.element(card).toHaveClass(/flex-row/);
161
+ });
162
+
163
+ test("default layout is vertical", async () => {
164
+ render(
165
+ <Card data-testid="card">
166
+ <CardContent>
167
+ <CardBody>
168
+ <CardTitle>Default Card</CardTitle>
169
+ </CardBody>
170
+ </CardContent>
171
+ </Card>,
172
+ );
173
+
174
+ const card = page.getByTestId("card");
175
+ await expect.element(card).toHaveClass(/flex-col/);
176
+ });
177
+ });
178
+
179
+ describe("Component Composition", () => {
180
+ test("renders complete card with all sub-components", async () => {
181
+ render(
182
+ <Card>
183
+ <CardImage src="https://example.com/test.jpg" alt="Test" />
184
+ <CardContent>
185
+ <CardBody>
186
+ <CardEyebrow>Category</CardEyebrow>
187
+ <CardTitle>Full Card Example</CardTitle>
188
+ <CardDescription>
189
+ This is a complete card with all components.
190
+ </CardDescription>
191
+ </CardBody>
192
+ <CardActions>
193
+ <Button>Action</Button>
194
+ </CardActions>
195
+ </CardContent>
196
+ </Card>,
197
+ );
198
+
199
+ await expect
200
+ .element(page.getByRole("img", { name: "Test" }))
201
+ .toBeInTheDocument();
202
+ await expect.element(page.getByText("Category")).toBeInTheDocument();
203
+ await expect
204
+ .element(page.getByRole("heading", { name: "Full Card Example" }))
205
+ .toBeInTheDocument();
206
+ await expect
207
+ .element(page.getByText("This is a complete card with all components."))
208
+ .toBeInTheDocument();
209
+ await expect
210
+ .element(page.getByRole("button", { name: "Action" }))
211
+ .toBeInTheDocument();
212
+ });
213
+
214
+ test("renders minimal card with only required components", async () => {
215
+ render(
216
+ <Card>
217
+ <CardContent>
218
+ <CardBody>
219
+ <CardTitle>Minimal</CardTitle>
220
+ </CardBody>
221
+ </CardContent>
222
+ </Card>,
223
+ );
224
+
225
+ await expect
226
+ .element(page.getByRole("heading", { name: "Minimal" }))
227
+ .toBeInTheDocument();
228
+ });
229
+
230
+ test("supports custom className on Card", async () => {
231
+ render(
232
+ <Card className="custom-class" data-testid="card">
233
+ <CardContent>
234
+ <CardBody>
235
+ <CardTitle>Custom Card</CardTitle>
236
+ </CardBody>
237
+ </CardContent>
238
+ </Card>,
239
+ );
240
+
241
+ const card = page.getByTestId("card");
242
+ await expect.element(card).toHaveClass(/custom-class/);
243
+ });
244
+ });
245
+ });
@@ -0,0 +1,225 @@
1
+ import * as React from "react";
2
+ import { tv, type VariantProps } from "tailwind-variants";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const cardVariants = tv({
6
+ base: "flex overflow-hidden rounded-surface-card bg-card-background stroke-surface-card border-border-subtle border-solid",
7
+ variants: {
8
+ layout: {
9
+ vertical: "w-full flex-col",
10
+ horizontal: "w-full flex-row",
11
+ },
12
+ },
13
+ defaultVariants: {
14
+ layout: "vertical",
15
+ },
16
+ });
17
+
18
+ export interface CardProps
19
+ extends React.HTMLAttributes<HTMLDivElement>,
20
+ VariantProps<typeof cardVariants> {}
21
+
22
+ /**
23
+ * Card component for displaying content in a contained, scannable format.
24
+ *
25
+ * Layouts:
26
+ * - vertical: Image on top, content below (default)
27
+ * - horizontal: Image on left, content on right
28
+ *
29
+ * Use with CardImage, CardContent, CardEyebrow, CardTitle, CardDescription, and CardActions.
30
+ */
31
+ const Card = React.forwardRef<HTMLDivElement, CardProps>(
32
+ ({ className, layout, ...props }, ref) => {
33
+ return (
34
+ <div
35
+ ref={ref}
36
+ className={cardVariants({ layout, class: className })}
37
+ {...props}
38
+ />
39
+ );
40
+ },
41
+ );
42
+ Card.displayName = "Card";
43
+
44
+ export interface CardImageProps extends React.HTMLAttributes<HTMLDivElement> {
45
+ /**
46
+ * The image source URL
47
+ */
48
+ src?: string;
49
+ /**
50
+ * Alt text for the image
51
+ */
52
+ alt?: string;
53
+ }
54
+
55
+ /**
56
+ * Card image area. For vertical layout, displays with 16:9 aspect ratio.
57
+ * For horizontal layout, takes up ~40% width and stretches to content height.
58
+ */
59
+ const CardImage = React.forwardRef<HTMLDivElement, CardImageProps>(
60
+ ({ className, src, alt = "", ...props }, ref) => {
61
+ return (
62
+ <div
63
+ ref={ref}
64
+ className={cn(
65
+ "relative shrink-0 bg-gray-500",
66
+ // Vertical: full width with aspect ratio
67
+ "aspect-video w-full",
68
+ // When in horizontal card (parent has flex-row), override
69
+ "[.flex-row>&]:aspect-auto [.flex-row>&]:w-2/5 [.flex-row>&]:self-stretch",
70
+ className,
71
+ )}
72
+ {...props}
73
+ >
74
+ {src && (
75
+ <img
76
+ src={src}
77
+ alt={alt}
78
+ className="absolute inset-0 size-full object-cover"
79
+ />
80
+ )}
81
+ </div>
82
+ );
83
+ },
84
+ );
85
+ CardImage.displayName = "CardImage";
86
+
87
+ export interface CardContentProps
88
+ extends React.HTMLAttributes<HTMLDivElement> {}
89
+
90
+ /**
91
+ * Card content container with proper padding and spacing.
92
+ * Uses spatial card tokens for consistent sizing.
93
+ */
94
+ const CardContent = React.forwardRef<HTMLDivElement, CardContentProps>(
95
+ ({ className, ...props }, ref) => {
96
+ return (
97
+ <div
98
+ ref={ref}
99
+ className={cn(
100
+ "flex w-full flex-1 flex-col gap-spatial-card-large-gap p-spatial-card-large-padding",
101
+ className,
102
+ )}
103
+ {...props}
104
+ />
105
+ );
106
+ },
107
+ );
108
+ CardContent.displayName = "CardContent";
109
+
110
+ export interface CardEyebrowProps
111
+ extends React.HTMLAttributes<HTMLParagraphElement> {}
112
+
113
+ /**
114
+ * Optional eyebrow text above the card title.
115
+ */
116
+ const CardEyebrow = React.forwardRef<HTMLParagraphElement, CardEyebrowProps>(
117
+ ({ className, ...props }, ref) => {
118
+ return (
119
+ <p
120
+ ref={ref}
121
+ className={cn("typography-caption-large text-gray-500", className)}
122
+ {...props}
123
+ />
124
+ );
125
+ },
126
+ );
127
+ CardEyebrow.displayName = "CardEyebrow";
128
+
129
+ export interface CardTitleProps
130
+ extends React.HTMLAttributes<HTMLHeadingElement> {
131
+ /**
132
+ * The heading level to render (h1-h6). Defaults to h3.
133
+ */
134
+ as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
135
+ }
136
+
137
+ /**
138
+ * Card title/heading. Use the `as` prop to change the heading level.
139
+ */
140
+ const CardTitle = React.forwardRef<HTMLHeadingElement, CardTitleProps>(
141
+ ({ className, as: Component = "h3", ...props }, ref) => {
142
+ return (
143
+ <Component
144
+ ref={ref}
145
+ className={cn("typography-subheading-small text-gray-1100", className)}
146
+ {...props}
147
+ />
148
+ );
149
+ },
150
+ );
151
+ CardTitle.displayName = "CardTitle";
152
+
153
+ export interface CardDescriptionProps
154
+ extends React.HTMLAttributes<HTMLParagraphElement> {}
155
+
156
+ /**
157
+ * Card body/description text.
158
+ */
159
+ const CardDescription = React.forwardRef<
160
+ HTMLParagraphElement,
161
+ CardDescriptionProps
162
+ >(({ className, ...props }, ref) => {
163
+ return (
164
+ <p
165
+ ref={ref}
166
+ className={cn("typography-body-small text-gray-800", className)}
167
+ {...props}
168
+ />
169
+ );
170
+ });
171
+ CardDescription.displayName = "CardDescription";
172
+
173
+ export interface CardBodyProps extends React.HTMLAttributes<HTMLDivElement> {}
174
+
175
+ /**
176
+ * Container for card text content (eyebrow, title, description).
177
+ * Uses spatial card tokens for consistent sizing.
178
+ */
179
+ const CardBody = React.forwardRef<HTMLDivElement, CardBodyProps>(
180
+ ({ className, ...props }, ref) => {
181
+ return (
182
+ <div
183
+ ref={ref}
184
+ className={cn(
185
+ "flex w-full flex-col gap-spatial-card-small-gap",
186
+ className,
187
+ )}
188
+ {...props}
189
+ />
190
+ );
191
+ },
192
+ );
193
+ CardBody.displayName = "CardBody";
194
+
195
+ export interface CardActionsProps
196
+ extends React.HTMLAttributes<HTMLDivElement> {}
197
+
198
+ /**
199
+ * Container for card action buttons.
200
+ * Uses primitive spacing tokens.
201
+ */
202
+ const CardActions = React.forwardRef<HTMLDivElement, CardActionsProps>(
203
+ ({ className, ...props }, ref) => {
204
+ return (
205
+ <div
206
+ ref={ref}
207
+ className={cn("flex gap-spacing-12", className)}
208
+ {...props}
209
+ />
210
+ );
211
+ },
212
+ );
213
+ CardActions.displayName = "CardActions";
214
+
215
+ export {
216
+ Card,
217
+ cardVariants,
218
+ CardImage,
219
+ CardContent,
220
+ CardEyebrow,
221
+ CardTitle,
222
+ CardDescription,
223
+ CardBody,
224
+ CardActions,
225
+ };
@@ -0,0 +1,197 @@
1
+ import { render } from "@testing-library/react";
2
+ import { describe, expect, test } from "vitest";
3
+ import { page } from "vitest/browser";
4
+ import { Button } from "../../atoms/button";
5
+ import {
6
+ Card,
7
+ CardActions,
8
+ CardBody,
9
+ CardContent,
10
+ CardDescription,
11
+ CardEyebrow,
12
+ CardImage,
13
+ CardTitle,
14
+ } from "./card";
15
+
16
+ describe("Card Visual Regression", () => {
17
+ test("default vertical layout renders correctly", async () => {
18
+ render(
19
+ <div
20
+ style={{ width: "418px", backgroundColor: "#1a1a1a", padding: "20px" }}
21
+ >
22
+ <Card data-testid="card">
23
+ <CardImage />
24
+ <CardContent>
25
+ <CardBody>
26
+ <CardEyebrow>Optional Eyebrow</CardEyebrow>
27
+ <div className="flex flex-col gap-[6px]">
28
+ <CardTitle>Card Title</CardTitle>
29
+ <CardDescription>
30
+ Use cards when citizens need to scan items at a glance.
31
+ </CardDescription>
32
+ </div>
33
+ </CardBody>
34
+ <CardActions>
35
+ <Button size="default" variant="charcoal">
36
+ Primary
37
+ </Button>
38
+ <Button size="default" variant="charcoalOutline">
39
+ Secondary
40
+ </Button>
41
+ </CardActions>
42
+ </CardContent>
43
+ </Card>
44
+ </div>,
45
+ );
46
+
47
+ await expect(page.getByTestId("card")).toMatchScreenshot(
48
+ "card-default-vertical",
49
+ );
50
+ });
51
+
52
+ test("horizontal layout renders correctly", async () => {
53
+ render(
54
+ <div
55
+ style={{ width: "700px", backgroundColor: "#1a1a1a", padding: "20px" }}
56
+ >
57
+ <Card layout="horizontal" data-testid="card">
58
+ <CardImage />
59
+ <CardContent>
60
+ <CardBody>
61
+ <CardEyebrow>Eyebrow</CardEyebrow>
62
+ <div className="flex flex-col gap-[9px]">
63
+ <CardTitle>
64
+ Cards can support multi line headings easily.
65
+ </CardTitle>
66
+ <CardDescription>
67
+ Use cards when citizens need to scan items at a glance.
68
+ </CardDescription>
69
+ </div>
70
+ </CardBody>
71
+ <CardActions>
72
+ <Button size="sm" variant="charcoal">
73
+ Primary
74
+ </Button>
75
+ <Button size="sm" variant="charcoalOutline">
76
+ Secondary
77
+ </Button>
78
+ </CardActions>
79
+ </CardContent>
80
+ </Card>
81
+ </div>,
82
+ );
83
+
84
+ await expect(page.getByTestId("card")).toMatchScreenshot("card-horizontal");
85
+ });
86
+
87
+ test("without image renders correctly", async () => {
88
+ render(
89
+ <div
90
+ style={{ width: "418px", backgroundColor: "#1a1a1a", padding: "20px" }}
91
+ >
92
+ <Card data-testid="card">
93
+ <CardContent>
94
+ <CardBody>
95
+ <CardEyebrow>Category</CardEyebrow>
96
+ <div className="flex flex-col gap-[6px]">
97
+ <CardTitle>Card Without Image</CardTitle>
98
+ <CardDescription>
99
+ Cards can be used without images for text-focused content.
100
+ </CardDescription>
101
+ </div>
102
+ </CardBody>
103
+ <CardActions>
104
+ <Button size="default" variant="charcoal">
105
+ Learn More
106
+ </Button>
107
+ </CardActions>
108
+ </CardContent>
109
+ </Card>
110
+ </div>,
111
+ );
112
+
113
+ await expect(page.getByTestId("card")).toMatchScreenshot(
114
+ "card-without-image",
115
+ );
116
+ });
117
+
118
+ test("without eyebrow renders correctly", async () => {
119
+ render(
120
+ <div
121
+ style={{ width: "418px", backgroundColor: "#1a1a1a", padding: "20px" }}
122
+ >
123
+ <Card data-testid="card">
124
+ <CardImage />
125
+ <CardContent>
126
+ <CardBody>
127
+ <div className="flex flex-col gap-[6px]">
128
+ <CardTitle>Card Title</CardTitle>
129
+ <CardDescription>
130
+ The eyebrow is optional and can be omitted when not needed.
131
+ </CardDescription>
132
+ </div>
133
+ </CardBody>
134
+ <CardActions>
135
+ <Button size="default" variant="charcoal">
136
+ Primary
137
+ </Button>
138
+ </CardActions>
139
+ </CardContent>
140
+ </Card>
141
+ </div>,
142
+ );
143
+
144
+ await expect(page.getByTestId("card")).toMatchScreenshot(
145
+ "card-without-eyebrow",
146
+ );
147
+ });
148
+
149
+ test("without actions renders correctly", async () => {
150
+ render(
151
+ <div
152
+ style={{ width: "418px", backgroundColor: "#1a1a1a", padding: "20px" }}
153
+ >
154
+ <Card data-testid="card">
155
+ <CardImage />
156
+ <CardContent>
157
+ <CardBody>
158
+ <CardEyebrow>Information</CardEyebrow>
159
+ <div className="flex flex-col gap-[6px]">
160
+ <CardTitle>Informational Card</CardTitle>
161
+ <CardDescription>
162
+ Cards without actions can be used for purely informational
163
+ content.
164
+ </CardDescription>
165
+ </div>
166
+ </CardBody>
167
+ </CardContent>
168
+ </Card>
169
+ </div>,
170
+ );
171
+
172
+ await expect(page.getByTestId("card")).toMatchScreenshot(
173
+ "card-without-actions",
174
+ );
175
+ });
176
+
177
+ test("minimal card renders correctly", async () => {
178
+ render(
179
+ <div
180
+ style={{ width: "418px", backgroundColor: "#1a1a1a", padding: "20px" }}
181
+ >
182
+ <Card data-testid="card">
183
+ <CardContent>
184
+ <CardBody>
185
+ <CardTitle>Minimal Card</CardTitle>
186
+ <CardDescription>
187
+ A minimal card with just title and description.
188
+ </CardDescription>
189
+ </CardBody>
190
+ </CardContent>
191
+ </Card>
192
+ </div>,
193
+ );
194
+
195
+ await expect(page.getByTestId("card")).toMatchScreenshot("card-minimal");
196
+ });
197
+ });
@@ -0,0 +1,19 @@
1
+ export {
2
+ Card,
3
+ CardActions,
4
+ type CardActionsProps,
5
+ CardBody,
6
+ type CardBodyProps,
7
+ CardContent,
8
+ type CardContentProps,
9
+ CardDescription,
10
+ type CardDescriptionProps,
11
+ CardEyebrow,
12
+ type CardEyebrowProps,
13
+ CardImage,
14
+ type CardImageProps,
15
+ type CardProps,
16
+ CardTitle,
17
+ type CardTitleProps,
18
+ cardVariants,
19
+ } from "./card";