@arbor-education/design-system.components 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/.agent-memory/blanche-designspert/MEMORY.md +189 -0
  2. package/.agent-memory/dorothy-fact-checker/MEMORY.md +228 -0
  3. package/.agent-memory/dorothy-fact-checker/numberinput_component.md +53 -0
  4. package/.agent-memory/dorothy-fact-checker/progress_component.md +36 -0
  5. package/.agent-memory/rose-storybookspert/MEMORY.md +105 -0
  6. package/.agent-memory/sophia-componentspert/MEMORY.md +34 -0
  7. package/{.claude/agent-memory → .agent-memory}/sophia-componentspert/components.md +170 -17
  8. package/{.claude → .gather}/agents/blanche-designspert.md +7 -2
  9. package/{.claude → .gather}/agents/dorothy-fact-checker.md +7 -2
  10. package/{.claude → .gather}/agents/rose-storybookspert.md +80 -11
  11. package/{.claude → .gather}/agents/sophia-componentspert.md +9 -4
  12. package/.gather/gather.yaml +9 -0
  13. package/{CLAUDE.md → .gather/instructions/project-overview.md} +42 -9
  14. package/{.claude → .gather}/skills/analyze-design/README.md +5 -0
  15. package/{.claude → .gather}/skills/analyze-design/SKILL.md +1 -1
  16. package/.gather/skills/analyze-design/meta.md +4 -0
  17. package/{.claude → .gather}/skills/create-page/README.md +5 -0
  18. package/{.claude → .gather}/skills/create-page/design-analysis-template.md +5 -0
  19. package/.gather/skills/create-page/meta.md +4 -0
  20. package/{.claude → .gather}/skills/create-page/page-template.scss +5 -0
  21. package/{.claude → .gather}/skills/create-page/page-template.tsx +5 -0
  22. package/{.claude → .gather}/skills/map-legacy/README.md +5 -0
  23. package/.gather/skills/map-legacy/meta.md +4 -0
  24. package/{.claude → .gather}/skills/migrate-page/README.md +5 -0
  25. package/.gather/skills/migrate-page/meta.md +4 -0
  26. package/.gather/skills/write-stories/README.md +157 -0
  27. package/.gather/skills/write-stories/SKILL.md +841 -0
  28. package/.gather/skills/write-stories/meta.md +4 -0
  29. package/.ralph/storybook-upgrade/knowledge.md +308 -0
  30. package/.ralph/storybook-upgrade/prd.json +777 -0
  31. package/.ralph/storybook-upgrade/progress.md +342 -0
  32. package/.storybook/DocsTemplate.tsx +122 -0
  33. package/.storybook/preview.ts +40 -0
  34. package/.stylelintignore +2 -0
  35. package/CHANGELOG.md +14 -0
  36. package/{.claude/component-library.md → component-library.md} +27 -10
  37. package/dist/components/articleCard/ArticleCard.d.ts +30 -0
  38. package/dist/components/articleCard/ArticleCard.d.ts.map +1 -0
  39. package/dist/components/articleCard/ArticleCard.js +24 -0
  40. package/dist/components/articleCard/ArticleCard.js.map +1 -0
  41. package/dist/components/articleCard/ArticleCard.stories.d.ts +18 -0
  42. package/dist/components/articleCard/ArticleCard.stories.d.ts.map +1 -0
  43. package/dist/components/articleCard/ArticleCard.stories.js +112 -0
  44. package/dist/components/articleCard/ArticleCard.stories.js.map +1 -0
  45. package/dist/components/articleCard/ArticleCard.test.d.ts +2 -0
  46. package/dist/components/articleCard/ArticleCard.test.d.ts.map +1 -0
  47. package/dist/components/articleCard/ArticleCard.test.js +49 -0
  48. package/dist/components/articleCard/ArticleCard.test.js.map +1 -0
  49. package/dist/components/badge/Badge.stories.d.ts +85 -6
  50. package/dist/components/badge/Badge.stories.d.ts.map +1 -1
  51. package/dist/components/badge/Badge.stories.js +626 -27
  52. package/dist/components/badge/Badge.stories.js.map +1 -1
  53. package/dist/components/banner/Banner.stories.d.ts +129 -63
  54. package/dist/components/banner/Banner.stories.d.ts.map +1 -1
  55. package/dist/components/banner/Banner.stories.js +855 -39
  56. package/dist/components/banner/Banner.stories.js.map +1 -1
  57. package/dist/components/button/Button.stories.d.ts +148 -8
  58. package/dist/components/button/Button.stories.d.ts.map +1 -1
  59. package/dist/components/button/Button.stories.js +1089 -80
  60. package/dist/components/button/Button.stories.js.map +1 -1
  61. package/dist/components/card/Card.d.ts +41 -12
  62. package/dist/components/card/Card.d.ts.map +1 -1
  63. package/dist/components/card/Card.js +46 -17
  64. package/dist/components/card/Card.js.map +1 -1
  65. package/dist/components/card/Card.stories.d.ts +9 -84
  66. package/dist/components/card/Card.stories.d.ts.map +1 -1
  67. package/dist/components/card/Card.stories.js +15 -73
  68. package/dist/components/card/Card.stories.js.map +1 -1
  69. package/dist/components/card/Card.test.js +50 -152
  70. package/dist/components/card/Card.test.js.map +1 -1
  71. package/dist/components/dot/Dot.stories.d.ts +46 -11
  72. package/dist/components/dot/Dot.stories.d.ts.map +1 -1
  73. package/dist/components/dot/Dot.stories.js +504 -15
  74. package/dist/components/dot/Dot.stories.js.map +1 -1
  75. package/dist/components/dropdown/Dropdown.stories.d.ts +89 -14
  76. package/dist/components/dropdown/Dropdown.stories.d.ts.map +1 -1
  77. package/dist/components/dropdown/Dropdown.stories.js +769 -17
  78. package/dist/components/dropdown/Dropdown.stories.js.map +1 -1
  79. package/dist/components/formField/FormField.stories.d.ts +95 -35
  80. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  81. package/dist/components/formField/FormField.stories.js +1174 -69
  82. package/dist/components/formField/FormField.stories.js.map +1 -1
  83. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.d.ts +96 -9
  84. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.d.ts.map +1 -1
  85. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.js +717 -10
  86. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.js.map +1 -1
  87. package/dist/components/formField/inputs/number/NumberInput.stories.d.ts +149 -11
  88. package/dist/components/formField/inputs/number/NumberInput.stories.d.ts.map +1 -1
  89. package/dist/components/formField/inputs/number/NumberInput.stories.js +624 -10
  90. package/dist/components/formField/inputs/number/NumberInput.stories.js.map +1 -1
  91. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.d.ts +74 -1
  92. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.d.ts.map +1 -1
  93. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.js +673 -44
  94. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.js.map +1 -1
  95. package/dist/components/formField/inputs/text/TextInput.stories.d.ts +119 -1
  96. package/dist/components/formField/inputs/text/TextInput.stories.d.ts.map +1 -1
  97. package/dist/components/formField/inputs/text/TextInput.stories.js +549 -10
  98. package/dist/components/formField/inputs/text/TextInput.stories.js.map +1 -1
  99. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +129 -4
  100. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
  101. package/dist/components/formField/inputs/textArea/TextArea.stories.js +577 -3
  102. package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
  103. package/dist/components/formField/inputs/time/TimeInput.d.ts +1 -1
  104. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +1 -1
  105. package/dist/components/heading/Heading.stories.d.ts +449 -50
  106. package/dist/components/heading/Heading.stories.d.ts.map +1 -1
  107. package/dist/components/heading/Heading.stories.js +536 -60
  108. package/dist/components/heading/Heading.stories.js.map +1 -1
  109. package/dist/components/icoText/IcoText.d.ts +37 -0
  110. package/dist/components/icoText/IcoText.d.ts.map +1 -0
  111. package/dist/components/icoText/IcoText.js +29 -0
  112. package/dist/components/icoText/IcoText.js.map +1 -0
  113. package/dist/components/icoText/IcoText.stories.d.ts +34 -0
  114. package/dist/components/icoText/IcoText.stories.d.ts.map +1 -0
  115. package/dist/components/icoText/IcoText.stories.js +24 -0
  116. package/dist/components/icoText/IcoText.stories.js.map +1 -0
  117. package/dist/components/icoText/IcoText.test.d.ts +2 -0
  118. package/dist/components/icoText/IcoText.test.d.ts.map +1 -0
  119. package/dist/components/icoText/IcoText.test.js +27 -0
  120. package/dist/components/icoText/IcoText.test.js.map +1 -0
  121. package/dist/components/icon/Icon.stories.d.ts +81 -10
  122. package/dist/components/icon/Icon.stories.d.ts.map +1 -1
  123. package/dist/components/icon/Icon.stories.js +979 -8
  124. package/dist/components/icon/Icon.stories.js.map +1 -1
  125. package/dist/components/kpiCard/KPICard.d.ts +13 -0
  126. package/dist/components/kpiCard/KPICard.d.ts.map +1 -0
  127. package/dist/components/kpiCard/KPICard.js +8 -0
  128. package/dist/components/kpiCard/KPICard.js.map +1 -0
  129. package/dist/components/kpiCard/KPICard.stories.d.ts +9 -0
  130. package/dist/components/kpiCard/KPICard.stories.d.ts.map +1 -0
  131. package/dist/components/kpiCard/KPICard.stories.js +18 -0
  132. package/dist/components/kpiCard/KPICard.stories.js.map +1 -0
  133. package/dist/components/kpiCard/KPICard.test.d.ts +2 -0
  134. package/dist/components/kpiCard/KPICard.test.d.ts.map +1 -0
  135. package/dist/components/kpiCard/KPICard.test.js +37 -0
  136. package/dist/components/kpiCard/KPICard.test.js.map +1 -0
  137. package/dist/components/kvpList/KVPList.d.ts +34 -0
  138. package/dist/components/kvpList/KVPList.d.ts.map +1 -0
  139. package/dist/components/kvpList/KVPList.js +20 -0
  140. package/dist/components/kvpList/KVPList.js.map +1 -0
  141. package/dist/components/kvpList/KVPList.stories.d.ts +27 -0
  142. package/dist/components/kvpList/KVPList.stories.d.ts.map +1 -0
  143. package/dist/components/kvpList/KVPList.stories.js +18 -0
  144. package/dist/components/kvpList/KVPList.stories.js.map +1 -0
  145. package/dist/components/kvpList/KVPList.test.d.ts +2 -0
  146. package/dist/components/kvpList/KVPList.test.d.ts.map +1 -0
  147. package/dist/components/kvpList/KVPList.test.js +29 -0
  148. package/dist/components/kvpList/KVPList.test.js.map +1 -0
  149. package/dist/components/pill/Pill.stories.d.ts +71 -19
  150. package/dist/components/pill/Pill.stories.d.ts.map +1 -1
  151. package/dist/components/pill/Pill.stories.js +573 -14
  152. package/dist/components/pill/Pill.stories.js.map +1 -1
  153. package/dist/components/progress/Progress.stories.d.ts +75 -298
  154. package/dist/components/progress/Progress.stories.d.ts.map +1 -1
  155. package/dist/components/progress/Progress.stories.js +449 -52
  156. package/dist/components/progress/Progress.stories.js.map +1 -1
  157. package/dist/components/separator/Separator.stories.d.ts +58 -5
  158. package/dist/components/separator/Separator.stories.d.ts.map +1 -1
  159. package/dist/components/separator/Separator.stories.js +443 -4
  160. package/dist/components/separator/Separator.stories.js.map +1 -1
  161. package/dist/components/singleUser/SingleUser.d.ts +1 -1
  162. package/dist/components/tabs/TabsItem.stories.d.ts +2 -2
  163. package/dist/components/tag/Tag.stories.d.ts +116 -5
  164. package/dist/components/tag/Tag.stories.d.ts.map +1 -1
  165. package/dist/components/tag/Tag.stories.js +581 -28
  166. package/dist/components/tag/Tag.stories.js.map +1 -1
  167. package/dist/index.css +194 -23
  168. package/dist/index.css.map +1 -1
  169. package/dist/index.d.ts +13 -4
  170. package/dist/index.d.ts.map +1 -1
  171. package/dist/index.js +12 -3
  172. package/dist/index.js.map +1 -1
  173. package/eslint.config.mts +5 -1
  174. package/package.json +3 -3
  175. package/src/components/articleCard/ArticleCard.stories.tsx +132 -0
  176. package/src/components/articleCard/ArticleCard.test.tsx +121 -0
  177. package/src/components/articleCard/ArticleCard.tsx +100 -0
  178. package/src/components/articleCard/articleCard.scss +39 -0
  179. package/src/components/badge/Badge.stories.tsx +869 -42
  180. package/src/components/banner/Banner.stories.tsx +1081 -63
  181. package/src/components/button/Button.stories.tsx +1394 -99
  182. package/src/components/card/Card.stories.tsx +35 -79
  183. package/src/components/card/Card.test.tsx +72 -190
  184. package/src/components/card/Card.tsx +117 -58
  185. package/src/components/card/card.scss +18 -31
  186. package/src/components/dot/Dot.stories.tsx +723 -32
  187. package/src/components/dropdown/Dropdown.stories.tsx +1174 -35
  188. package/src/components/formField/FormField.stories.tsx +1522 -105
  189. package/src/components/formField/inputs/checkbox/CheckboxInput.stories.tsx +1020 -15
  190. package/src/components/formField/inputs/number/NumberInput.stories.tsx +908 -15
  191. package/src/components/formField/inputs/radio/RadioButtonInput.stories.tsx +932 -51
  192. package/src/components/formField/inputs/text/TextInput.stories.tsx +773 -13
  193. package/src/components/formField/inputs/textArea/TextArea.stories.tsx +756 -8
  194. package/src/components/heading/Heading.stories.tsx +752 -120
  195. package/src/components/icoText/IcoText.stories.tsx +47 -0
  196. package/src/components/icoText/IcoText.test.tsx +41 -0
  197. package/src/components/icoText/IcoText.tsx +93 -0
  198. package/src/components/icoText/icoText.scss +34 -0
  199. package/src/components/icon/Icon.stories.tsx +1446 -12
  200. package/src/components/kpiCard/KPICard.stories.tsx +47 -0
  201. package/src/components/kpiCard/KPICard.test.tsx +60 -0
  202. package/src/components/kpiCard/KPICard.tsx +45 -0
  203. package/src/components/kpiCard/kpiCard.scss +35 -0
  204. package/src/components/kvpList/KVPList.stories.tsx +51 -0
  205. package/src/components/kvpList/KVPList.test.tsx +66 -0
  206. package/src/components/kvpList/KVPList.tsx +109 -0
  207. package/src/components/kvpList/kvpList.scss +64 -0
  208. package/src/components/pill/Pill.stories.tsx +867 -21
  209. package/src/components/progress/Progress.stories.tsx +625 -58
  210. package/src/components/separator/Separator.stories.tsx +730 -8
  211. package/src/components/separator/separator.scss +12 -3
  212. package/src/components/tag/Tag.stories.tsx +755 -53
  213. package/src/index.scss +4 -0
  214. package/src/index.ts +13 -4
  215. package/src/tokens.scss +6 -0
  216. package/tokens/json/Arbor.json +30 -0
  217. package/.claude/agent-memory/blanche-designspert/MEMORY.md +0 -64
  218. package/.claude/agent-memory/dorothy-fact-checker/MEMORY.md +0 -129
  219. package/.claude/agent-memory/rose-storybookspert/MEMORY.md +0 -29
  220. package/.claude/agent-memory/sophia-componentspert/MEMORY.md +0 -14
  221. package/.claude/design-assessment-daily-attendance-2026-04-10.md +0 -566
  222. package/.claude/figma-assessment-7154-58899.md +0 -404
  223. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-11086-97537.md +0 -392
  224. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-551-41974.md +0 -474
  225. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-551-43094.md +0 -462
  226. package/.claude/figma-assessment-fcFK4CGzkz2fVyY3koX8ZE-7154-59061.md +0 -440
  227. package/.claude/migration-report-custom-report-writer-2026-02-19.md +0 -591
  228. /package/{.claude/agent-memory → .agent-memory}/blanche-designspert/token-review-patterns.md +0 -0
  229. /package/{.claude/agent-memory → .agent-memory}/rose-storybookspert/patterns.md +0 -0
  230. /package/{.claude → .gather}/skills/create-page/SKILL.md +0 -0
  231. /package/{.claude → .gather}/skills/map-legacy/SKILL.md +0 -0
  232. /package/{.claude → .gather}/skills/migrate-page/SKILL.md +0 -0
@@ -1,99 +1,55 @@
1
- import type { Meta } from '@storybook/react-vite';
2
- import { Card } from './Card';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
2
  import { fn } from 'storybook/test';
3
+ import { Card } from './Card';
4
4
 
5
- const meta: Meta<typeof Card> = {
5
+ const meta = {
6
6
  title: 'Components/Card',
7
7
  component: Card,
8
- };
8
+ } satisfies Meta<typeof Card>;
9
9
 
10
- export const CardWithTitleAndParagraph = {
11
- args: {
12
- title: 'Title of Card',
13
- paragraph: 'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
14
- disabled: false,
15
- onClick: fn(),
16
- onKeyDown: fn(),
17
- },
18
- };
10
+ type Story = StoryObj<typeof meta>;
19
11
 
20
- export const CardWithTitleParagraphAndIcon = {
21
- args: {
22
- title: 'Title of Card',
23
- paragraph: 'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
24
- icon: 'eye',
25
- disabled: false,
26
- onClick: fn(),
27
- onKeyDown: fn(),
28
- },
29
- };
30
-
31
- export const TheEverythingCard = {
32
- args: {
33
- title: 'Title of Card',
34
- paragraph: 'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
35
- icon: 'eye',
36
- disabled: false,
37
- pillText: 'argle bargle',
38
- pillColor: 'orange',
39
- onClick: fn(),
40
- onKeyDown: fn(),
41
- },
42
- };
43
-
44
- export const CardWithTitleParagraphAndPill = {
45
- args: {
46
- title: 'Title of Card',
47
- paragraph: 'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
48
- disabled: false,
49
- pillText: 'argle bargle',
50
- pillColor: 'orange',
51
- onClick: fn(),
52
- onKeyDown: fn(),
53
- },
54
- };
55
-
56
- export const CardWithTitleAndIcon = {
57
- args: {
58
- title: 'Title of Card',
59
- icon: 'eye',
60
- disabled: false,
61
- onClick: fn(),
62
- onKeyDown: fn(),
63
- },
64
- };
12
+ const sampleCardContent = (
13
+ <>
14
+ <h3>Attendance summary</h3>
15
+ <p>View the latest attendance and behaviour insights for this cohort.</p>
16
+ </>
17
+ );
65
18
 
66
- export const CardWithParagraph = {
19
+ export const StaticCard: Story = {
67
20
  args: {
68
- paragraph: 'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
69
- disabled: false,
70
- onClick: fn(),
71
- onKeyDown: fn(),
21
+ 'aria-label': 'Static summary card',
72
22
  },
23
+ render: args => (
24
+ <Card {...args}>
25
+ {sampleCardContent}
26
+ </Card>
27
+ ),
73
28
  };
74
29
 
75
- export const ClickableDisabledCard = {
30
+ export const ClickableCard: Story = {
76
31
  args: {
77
- title: 'Title of Card',
78
- paragraph: 'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
79
- icon: 'eye',
80
- disabled: true,
81
- pillText: 'argle bargle',
82
- pillColor: 'orange',
83
- onClick: fn(),
84
- onKeyDown: fn(),
32
+ 'onClick': fn(),
33
+ 'onKeyDown': fn(),
34
+ 'aria-label': 'Clickable card',
85
35
  },
36
+ render: args => (
37
+ <Card {...args}>
38
+ {sampleCardContent}
39
+ </Card>
40
+ ),
86
41
  };
87
42
 
88
- export const UnclickableCard = {
43
+ export const DenseCard: Story = {
89
44
  args: {
90
- title: 'Title of Card',
91
- paragraph: 'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
92
- icon: 'eye',
93
- disabled: true,
94
- pillText: 'argle bargle',
95
- pillColor: 'orange',
45
+ 'aria-label': 'Dense card',
46
+ 'spacing': 'dense',
96
47
  },
48
+ render: args => (
49
+ <Card {...args}>
50
+ {sampleCardContent}
51
+ </Card>
52
+ ),
97
53
  };
98
54
 
99
55
  export default meta;
@@ -4,222 +4,104 @@ import { render, screen, fireEvent } from '@testing-library/react';
4
4
  import { Card } from './Card';
5
5
  import '@testing-library/jest-dom/vitest';
6
6
 
7
- describe('Card Component', () => {
8
- test('renders empty card without any props', () => {
9
- render(<Card />);
10
- const card = screen.getByRole('article');
11
- expect(card).toBeInTheDocument();
12
- expect(card).toHaveAttribute('aria-label', 'Card');
13
- });
7
+ describe('Card', () => {
8
+ test('renders children inside the card shell', () => {
9
+ const { container } = render(
10
+ <Card aria-label="Summary card">
11
+ <div>Custom content</div>
12
+ </Card>,
13
+ );
14
14
 
15
- test('renders card with title', () => {
16
- render(<Card title="Test Title" />);
17
- expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Test Title');
18
- expect(screen.getByText('Test Title')).toHaveClass('ds-card__title');
19
- });
15
+ const card = container.querySelector('figure');
20
16
 
21
- test('renders card with paragraph', () => {
22
- render(<Card paragraph="Test paragraph content" />);
23
- expect(screen.getByText('Test paragraph content')).toBeInTheDocument();
24
- expect(screen.getByText('Test paragraph content')).toHaveClass('ds-card__paragraph');
17
+ expect(card).toBeInTheDocument();
18
+ expect(card).toHaveAttribute('aria-label', 'Summary card');
19
+ expect(screen.getByText('Custom content')).toBeInTheDocument();
25
20
  });
26
21
 
27
- test('renders card with both title and paragraph', () => {
28
- render(<Card title="Test Title" paragraph="Test paragraph" />);
29
- expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Test Title');
30
- expect(screen.getByText('Test paragraph')).toBeInTheDocument();
31
- });
22
+ test('renders clickable affordances when interactive', () => {
23
+ const mockClick = vi.fn();
24
+ const { container } = render(
25
+ <Card onClick={mockClick} aria-label="Clickable card">
26
+ <div>Clickable content</div>
27
+ </Card>,
28
+ );
32
29
 
33
- test('renders card with icon', () => {
34
- const { container } = render(<Card icon="eye" />);
35
- expect(container.querySelector('.ds-icon-eye')).toBeInTheDocument();
36
- });
30
+ const card = screen.getByRole('button', { name: 'Clickable card' });
31
+ fireEvent.click(card);
37
32
 
38
- test('renders card with pill', () => {
39
- render(<Card tagText="Test Pill" tagColor="orange" />);
40
- expect(screen.getByText('Test Pill')).toBeInTheDocument();
33
+ expect(mockClick).toHaveBeenCalledTimes(1);
34
+ expect(card).toHaveClass('ds-card__container--clickable');
35
+ expect(card).toHaveAttribute('tabIndex', '0');
36
+ expect(container.querySelector('.ds-icon-chevron-right')).toBeInTheDocument();
37
+ expect(container.querySelector('.ds-icon-arrow-right')).toBeInTheDocument();
41
38
  });
42
39
 
43
- test('renders complete card with all props', () => {
40
+ test('does not render clickable affordances when disabled', () => {
44
41
  const mockClick = vi.fn();
45
- const mockKeyDown = vi.fn();
46
-
47
42
  const { container } = render(
48
- <Card
49
- title="Complete Card"
50
- paragraph="This is a complete card"
51
- icon="eye"
52
- iconColor="#blue"
53
- tagText="Complete"
54
- tagColor="green"
55
- onClick={mockClick}
56
- onKeyDown={mockKeyDown}
57
- />,
43
+ <Card disabled onClick={mockClick} aria-label="Disabled card">
44
+ <div>Disabled content</div>
45
+ </Card>,
58
46
  );
59
47
 
60
- expect(screen.getByText('Complete Card')).toBeInTheDocument();
61
- expect(screen.getByText('This is a complete card')).toBeInTheDocument();
62
- expect(container.querySelector('.ds-icon-eye')).toBeInTheDocument();
63
- expect(screen.getByText('Complete')).toBeInTheDocument();
64
- });
65
-
66
- describe('Click interactions', () => {
67
- test('calls onClick handler when card is clicked', () => {
68
- const mockClick = vi.fn();
69
- render(<Card title="Clickable Card" onClick={mockClick} />);
70
-
71
- const card = screen.getByRole('article');
72
- fireEvent.click(card);
73
-
74
- expect(mockClick).toHaveBeenCalledTimes(1);
75
- });
76
-
77
- test('does not call onClick when card is disabled', () => {
78
- const mockClick = vi.fn();
79
- render(<Card title="Disabled Card" onClick={mockClick} disabled />);
80
-
81
- const card = screen.getByRole('article');
82
- fireEvent.click(card);
83
-
84
- expect(mockClick).not.toHaveBeenCalled();
85
- });
48
+ const card = screen.getByRole('button', { name: 'Disabled card' });
86
49
 
87
- test('shows click arrow icon when card is clickable', () => {
88
- const mockClick = vi.fn();
89
- const { container } = render(<Card title="Clickable Card" onClick={mockClick} />);
50
+ expect(card).toHaveClass('ds-card__container--disabled');
51
+ expect(card).toHaveAttribute('aria-disabled', 'true');
52
+ expect(card).toHaveAttribute('role', 'button');
53
+ expect(card).toHaveAttribute('tabIndex', '0');
54
+ expect(container.querySelector('.ds-icon-chevron-right')).not.toBeInTheDocument();
90
55
 
91
- expect(container.querySelector('.ds-icon-chevron-right')).toBeInTheDocument();
92
- });
56
+ card.focus();
57
+ expect(card).toHaveFocus();
93
58
 
94
- test('does not show click arrow icon when card is not clickable', () => {
95
- const { container } = render(<Card title="Non-clickable Card" />);
59
+ fireEvent.click(card);
60
+ fireEvent.keyDown(card, { key: 'Enter' });
61
+ fireEvent.keyDown(card, { key: ' ' });
96
62
 
97
- expect(container.querySelector('.ds-icon-chevron-right')).not.toBeInTheDocument();
98
- });
99
-
100
- test('does not show click arrow icon when card is disabled', () => {
101
- const mockClick = vi.fn();
102
- const { container } = render(<Card title="Disabled Card" onClick={mockClick} disabled />);
103
-
104
- expect(container.querySelector('.ds-icon-chevron-right')).not.toBeInTheDocument();
105
- });
63
+ expect(mockClick).not.toHaveBeenCalled();
106
64
  });
107
65
 
108
- describe('Keyboard interactions', () => {
109
- test('calls onKeyDown handler when key is pressed on clickable card', () => {
110
- const mockKeyDown = vi.fn();
111
- render(<Card title="Keyboard Card" onClick={vi.fn()} onKeyDown={mockKeyDown} />);
112
-
113
- const card = screen.getByRole('article');
114
- fireEvent.keyDown(card, { key: 'Enter' });
115
-
116
- expect(mockKeyDown).toHaveBeenCalledTimes(1);
117
- });
118
-
119
- test('does not call onKeyDown when card is disabled', () => {
120
- const mockKeyDown = vi.fn();
121
- render(<Card title="Disabled Card" onClick={vi.fn()} onKeyDown={mockKeyDown} disabled />);
122
-
123
- const card = screen.getByRole('article');
124
- fireEvent.keyDown(card, { key: 'Enter' });
125
-
126
- expect(mockKeyDown).not.toHaveBeenCalled();
127
- });
66
+ test('calls onKeyDown and keyboard-activates the card on Enter', () => {
67
+ const mockClick = vi.fn();
68
+ const mockKeyDown = vi.fn();
128
69
 
129
- test('does not call onKeyDown when card is not clickable', () => {
130
- const mockKeyDown = vi.fn();
131
- render(<Card title="Non-clickable Card" onKeyDown={mockKeyDown} />);
70
+ render(
71
+ <Card onClick={mockClick} onKeyDown={mockKeyDown} aria-label="Keyboard card">
72
+ <div>Keyboard content</div>
73
+ </Card>,
74
+ );
132
75
 
133
- const card = screen.getByRole('article');
134
- fireEvent.keyDown(card, { key: 'Enter' });
76
+ const card = screen.getByRole('button', { name: 'Keyboard card' });
77
+ fireEvent.keyDown(card, { key: 'Enter' });
135
78
 
136
- expect(mockKeyDown).not.toHaveBeenCalled();
137
- });
79
+ expect(mockKeyDown).toHaveBeenCalledTimes(1);
80
+ expect(mockClick).toHaveBeenCalledTimes(1);
138
81
  });
139
82
 
140
- describe('CSS classes and accessibility', () => {
141
- test('applies clickable class when onClick is provided and not disabled', () => {
142
- const mockClick = vi.fn();
143
- render(<Card onClick={mockClick} />);
144
-
145
- const card = screen.getByRole('article');
146
- expect(card).toHaveClass('ds-card__container--clickable');
147
- });
148
-
149
- test('applies disabled class when disabled', () => {
150
- render(<Card disabled />);
151
-
152
- const card = screen.getByRole('article');
153
- expect(card).toHaveClass('ds-card__container--disabled');
154
- });
155
-
156
- test('does not apply clickable class when disabled', () => {
157
- const mockClick = vi.fn();
158
- render(<Card onClick={mockClick} disabled />);
159
-
160
- const card = screen.getByRole('article');
161
- expect(card).not.toHaveClass('ds-card__container--clickable');
162
- expect(card).toHaveClass('ds-card__container--disabled');
163
- });
164
-
165
- test('sets correct tabIndex for clickable card', () => {
166
- const mockClick = vi.fn();
167
- render(<Card onClick={mockClick} />);
168
-
169
- const card = screen.getByRole('article');
170
- expect(card).toHaveAttribute('tabIndex', '0');
171
- });
172
-
173
- test('sets correct tabIndex for non-clickable card', () => {
174
- render(<Card />);
175
-
176
- const card = screen.getByRole('article');
177
- expect(card).toHaveAttribute('tabIndex', '-1');
178
- });
179
-
180
- test('sets correct tabIndex for disabled card', () => {
181
- const mockClick = vi.fn();
182
- render(<Card onClick={mockClick} disabled />);
183
-
184
- const card = screen.getByRole('article');
185
- expect(card).toHaveAttribute('tabIndex', '-1');
186
- });
187
-
188
- test('has correct aria-label', () => {
189
- render(<Card />);
83
+ test('passes through aria-labelledby', () => {
84
+ const { container } = render(
85
+ <>
86
+ <h2 id="card-heading">Linked heading</h2>
87
+ <Card aria-labelledby="card-heading">
88
+ <div>Linked content</div>
89
+ </Card>
90
+ </>,
91
+ );
190
92
 
191
- const card = screen.getByRole('article');
192
- expect(card).toHaveAttribute('aria-label', 'Card');
193
- });
93
+ const card = container.querySelector('figure');
94
+ expect(card).toHaveAttribute('aria-labelledby', 'card-heading');
194
95
  });
195
96
 
196
- describe('Event handler edge cases', () => {
197
- test('handles onClick without onKeyDown', () => {
198
- const mockClick = vi.fn();
199
- render(<Card title="Click only" onClick={mockClick} />);
200
-
201
- const card = screen.getByRole('article');
202
- fireEvent.click(card);
203
-
204
- expect(mockClick).toHaveBeenCalledTimes(1);
205
- });
206
-
207
- test('handles onKeyDown without onClick', () => {
208
- const mockKeyDown = vi.fn();
209
- render(<Card title="KeyDown only" onKeyDown={mockKeyDown} />);
210
-
211
- const card = screen.getByRole('article');
212
- fireEvent.keyDown(card, { key: 'Enter' });
213
-
214
- // Should not be called because card is not clickable (no onClick)
215
- expect(mockKeyDown).not.toHaveBeenCalled();
216
- });
217
-
218
- test('does not throw error when clicking card without handlers', () => {
219
- render(<Card title="No handlers" />);
97
+ test('applies dense spacing when requested', () => {
98
+ const { container } = render(
99
+ <Card spacing="dense">
100
+ <div>Dense content</div>
101
+ </Card>,
102
+ );
220
103
 
221
- const card = screen.getByRole('article');
222
- expect(() => fireEvent.click(card)).not.toThrow();
223
- });
104
+ const card = container.querySelector('figure');
105
+ expect(card).toHaveClass('ds-card__container--dense');
224
106
  });
225
107
  });
@@ -1,86 +1,142 @@
1
1
  import classNames from 'classnames';
2
-
3
2
  import { Icon } from '../icon/Icon';
4
- import { allowedIcons } from '../icon/allowedIcons';
5
- import { Tag, type TagColor } from 'Components/tag/Tag';
6
-
7
- type CardProps = {
8
- title?: string;
9
- paragraph?: string;
10
- icon?: keyof typeof allowedIcons;
11
- iconColor?: string;
3
+
4
+ type CardBaseProps = {
5
+ children?: React.ReactNode;
6
+ className?: string;
12
7
  disabled?: boolean;
13
- tagText?: string;
14
- tagColor?: TagColor;
15
- onClick?: (e: React.MouseEvent<HTMLElement>) => void;
8
+ spacing?: 'default' | 'dense';
9
+ };
10
+
11
+ type CardAccessibleNameProps
12
+ = | {
13
+ 'aria-label': string;
14
+ 'aria-labelledby'?: string;
15
+ }
16
+ | {
17
+ 'aria-label'?: string;
18
+ 'aria-labelledby': string;
19
+ };
20
+
21
+ type CardInteractiveProps = CardAccessibleNameProps & {
22
+ onClick: (e: React.MouseEvent<HTMLElement>) => void;
16
23
  onKeyDown?: (e: React.KeyboardEvent<HTMLElement>) => void;
17
- iconScreenReaderText?: string;
18
24
  };
19
25
 
20
- export const Card = ({
21
- title,
22
- paragraph,
23
- icon,
24
- iconColor,
25
- iconScreenReaderText,
26
- onClick,
27
- onKeyDown,
28
- disabled,
29
- tagText,
30
- tagColor,
31
- }: CardProps) => {
32
- const handleClick = (e: React.MouseEvent<HTMLElement>) => {
33
- if (onClick) {
34
- onClick(e);
35
- }
26
+ type CardStaticProps = {
27
+ 'onClick'?: undefined;
28
+ 'onKeyDown'?: (e: React.KeyboardEvent<HTMLElement>) => void;
29
+ 'aria-label'?: string;
30
+ 'aria-labelledby'?: string;
31
+ };
32
+
33
+ type CardInteractionProps = CardInteractiveProps | CardStaticProps;
34
+
35
+ type CardResolvedInteractionProps
36
+ = | {
37
+ 'onClick': (e: React.MouseEvent<HTMLElement>) => void;
38
+ 'onKeyDown'?: (e: React.KeyboardEvent<HTMLElement>) => void;
39
+ 'aria-label': string;
40
+ }
41
+ | {
42
+ 'onClick': (e: React.MouseEvent<HTMLElement>) => void;
43
+ 'onKeyDown'?: (e: React.KeyboardEvent<HTMLElement>) => void;
44
+ 'aria-labelledby': string;
45
+ }
46
+ | {
47
+ 'onClick'?: undefined;
48
+ 'onKeyDown'?: (e: React.KeyboardEvent<HTMLElement>) => void;
49
+ 'aria-label'?: string;
50
+ 'aria-labelledby'?: string;
36
51
  };
37
52
 
38
- const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
39
- if (onKeyDown) {
40
- onKeyDown(e);
53
+ export type CardProps = CardBaseProps & CardInteractionProps;
54
+
55
+ export const getCardInteractionProps = (props: CardInteractionProps): CardResolvedInteractionProps => {
56
+ if (props.onClick === undefined) {
57
+ return {
58
+ 'onKeyDown': props.onKeyDown,
59
+ 'aria-label': props['aria-label'],
60
+ 'aria-labelledby': props['aria-labelledby'],
61
+ };
62
+ }
63
+
64
+ if (props['aria-label'] !== undefined) {
65
+ return {
66
+ 'onClick': props.onClick,
67
+ 'onKeyDown': props.onKeyDown,
68
+ 'aria-label': props['aria-label'],
69
+ };
70
+ }
71
+
72
+ if (props['aria-labelledby'] === undefined) {
73
+ if (process.env.NODE_ENV !== 'production') {
74
+ throw new Error('Interactive Card requires aria-label or aria-labelledby.');
41
75
  }
76
+
77
+ console.error('Interactive Card requires aria-label or aria-labelledby.');
78
+
79
+ return {
80
+ onClick: props.onClick,
81
+ onKeyDown: props.onKeyDown,
82
+ } as CardResolvedInteractionProps;
83
+ }
84
+
85
+ return {
86
+ 'onClick': props.onClick,
87
+ 'onKeyDown': props.onKeyDown,
88
+ 'aria-labelledby': props['aria-labelledby'],
42
89
  };
90
+ };
43
91
 
44
- const isCardClickable = onClick && !disabled;
92
+ export const Card = ({
93
+ children,
94
+ className,
95
+ onClick,
96
+ onKeyDown,
97
+ disabled = false,
98
+ spacing = 'default',
99
+ 'aria-label': ariaLabel,
100
+ 'aria-labelledby': ariaLabelledBy,
101
+ }: CardProps): React.JSX.Element => {
102
+ const isCardInteractive = Boolean(onClick);
103
+ const isCardClickable = isCardInteractive && !disabled;
45
104
 
46
105
  return (
47
- <article
48
- className={classNames('ds-card__container', {
106
+ <figure
107
+ className={classNames('ds-card__container', className, {
49
108
  'ds-card__container--clickable': isCardClickable,
50
109
  'ds-card__container--disabled': disabled,
110
+ 'ds-card__container--dense': spacing === 'dense',
51
111
  })}
52
112
  onClick={(e) => {
53
113
  if (isCardClickable) {
54
- handleClick(e);
114
+ onClick?.(e);
55
115
  }
56
116
  }}
57
117
  onKeyDown={(e) => {
118
+ if (!isCardInteractive) return;
119
+
120
+ if (e.key === 'Enter' || e.key === ' ') {
121
+ e.preventDefault();
122
+
123
+ if (isCardClickable) {
124
+ e.currentTarget.click();
125
+ }
126
+ }
127
+
58
128
  if (isCardClickable) {
59
- handleKeyDown(e);
129
+ onKeyDown?.(e);
60
130
  }
61
131
  }}
62
- aria-label="Card"
63
- tabIndex={isCardClickable ? 0 : -1}
132
+ aria-disabled={isCardInteractive && disabled ? true : undefined}
133
+ aria-label={ariaLabel}
134
+ aria-labelledby={ariaLabelledBy}
135
+ role={isCardInteractive ? 'button' : undefined}
136
+ tabIndex={isCardInteractive ? 0 : undefined}
64
137
  >
65
138
  <div className="ds-card__content">
66
- {icon && (
67
- <Icon
68
- name={icon}
69
- className="ds-card__icon-left"
70
- screenReaderText={iconScreenReaderText}
71
- color={iconColor}
72
- size={24}
73
- />
74
- )}
75
- <div className="ds-card__text">
76
- {title && (
77
- <span className="ds-card__title-container">
78
- {title && <h4 className="ds-card__title">{title}</h4>}
79
- </span>
80
- )}
81
- {paragraph && <p className="ds-card__paragraph">{paragraph}</p>}
82
- {tagText && <Tag color={tagColor}>{tagText}</Tag>}
83
- </div>
139
+ <div className="ds-card__body">{children}</div>
84
140
  {isCardClickable && (
85
141
  <>
86
142
  <Icon
@@ -96,10 +152,13 @@ export const Card = ({
96
152
  </>
97
153
  )}
98
154
  </div>
99
- </article>
155
+ </figure>
100
156
  );
101
157
  };
102
158
 
103
159
  export namespace Card {
104
160
  export type Props = CardProps;
161
+ export type AccessibleNameProps = CardAccessibleNameProps;
162
+ export type InteractiveProps = CardInteractiveProps;
163
+ export type InteractionProps = CardInteractionProps;
105
164
  }