@arbor-education/design-system.components 0.16.0 → 0.17.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 (85) hide show
  1. package/.gather/instructions/project-overview.md +0 -4
  2. package/.gather/skills/aroo-hunni/SKILL.md +58 -0
  3. package/CHANGELOG.md +31 -0
  4. package/CONTRIBUTING.md +1 -0
  5. package/dist/components/arborLogo/ArborLogo.d.ts +9 -0
  6. package/dist/components/arborLogo/ArborLogo.d.ts.map +1 -0
  7. package/dist/components/arborLogo/ArborLogo.js +17 -0
  8. package/dist/components/arborLogo/ArborLogo.js.map +1 -0
  9. package/dist/components/arborLogo/ArborLogo.stories.d.ts +94 -0
  10. package/dist/components/arborLogo/ArborLogo.stories.d.ts.map +1 -0
  11. package/dist/components/arborLogo/ArborLogo.stories.js +418 -0
  12. package/dist/components/arborLogo/ArborLogo.stories.js.map +1 -0
  13. package/dist/components/arborLogo/ArborLogo.test.d.ts +2 -0
  14. package/dist/components/arborLogo/ArborLogo.test.d.ts.map +1 -0
  15. package/dist/components/arborLogo/ArborLogo.test.js +32 -0
  16. package/dist/components/arborLogo/ArborLogo.test.js.map +1 -0
  17. package/dist/components/dataViewCard/DataViewCard.d.ts +19 -0
  18. package/dist/components/dataViewCard/DataViewCard.d.ts.map +1 -0
  19. package/dist/components/dataViewCard/DataViewCard.js +13 -0
  20. package/dist/components/dataViewCard/DataViewCard.js.map +1 -0
  21. package/dist/components/dataViewCard/DataViewCard.stories.d.ts +100 -0
  22. package/dist/components/dataViewCard/DataViewCard.stories.d.ts.map +1 -0
  23. package/dist/components/dataViewCard/DataViewCard.stories.js +317 -0
  24. package/dist/components/dataViewCard/DataViewCard.stories.js.map +1 -0
  25. package/dist/components/dataViewCard/DataViewCard.test.d.ts +2 -0
  26. package/dist/components/dataViewCard/DataViewCard.test.d.ts.map +1 -0
  27. package/dist/components/dataViewCard/DataViewCard.test.js +67 -0
  28. package/dist/components/dataViewCard/DataViewCard.test.js.map +1 -0
  29. package/dist/components/row/Row.d.ts +2 -1
  30. package/dist/components/row/Row.d.ts.map +1 -1
  31. package/dist/components/row/Row.js +2 -2
  32. package/dist/components/row/Row.js.map +1 -1
  33. package/dist/components/table/Table.stories.d.ts +1 -0
  34. package/dist/components/table/Table.stories.d.ts.map +1 -1
  35. package/dist/components/table/Table.stories.js +27 -2
  36. package/dist/components/table/Table.stories.js.map +1 -1
  37. package/dist/components/table/Table.test.js +30 -0
  38. package/dist/components/table/Table.test.js.map +1 -1
  39. package/dist/components/table/tableControls/HideColumnsDropdown.d.ts.map +1 -1
  40. package/dist/components/table/tableControls/HideColumnsDropdown.js +9 -3
  41. package/dist/components/table/tableControls/HideColumnsDropdown.js.map +1 -1
  42. package/dist/components/treeRow/TreeRow.d.ts +32 -0
  43. package/dist/components/treeRow/TreeRow.d.ts.map +1 -0
  44. package/dist/components/treeRow/TreeRow.js +19 -0
  45. package/dist/components/treeRow/TreeRow.js.map +1 -0
  46. package/dist/components/treeRow/TreeRow.stories.d.ts +13 -0
  47. package/dist/components/treeRow/TreeRow.stories.d.ts.map +1 -0
  48. package/dist/components/treeRow/TreeRow.stories.js +774 -0
  49. package/dist/components/treeRow/TreeRow.stories.js.map +1 -0
  50. package/dist/components/treeRow/TreeRow.test.d.ts +2 -0
  51. package/dist/components/treeRow/TreeRow.test.d.ts.map +1 -0
  52. package/dist/components/treeRow/TreeRow.test.js +262 -0
  53. package/dist/components/treeRow/TreeRow.test.js.map +1 -0
  54. package/dist/components/treeRow/TreeRowSection.d.ts +12 -0
  55. package/dist/components/treeRow/TreeRowSection.d.ts.map +1 -0
  56. package/dist/components/treeRow/TreeRowSection.js +20 -0
  57. package/dist/components/treeRow/TreeRowSection.js.map +1 -0
  58. package/dist/index.css +146 -1
  59. package/dist/index.css.map +1 -1
  60. package/dist/index.d.ts +4 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +4 -1
  63. package/dist/index.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/components/arborLogo/ArborLogo.stories.tsx +663 -0
  66. package/src/components/arborLogo/ArborLogo.test.tsx +36 -0
  67. package/src/components/arborLogo/ArborLogo.tsx +92 -0
  68. package/src/components/arborLogo/__snapshots__/ArborLogo.test.tsx.snap +424 -0
  69. package/src/components/dataViewCard/DataViewCard.stories.tsx +464 -0
  70. package/src/components/dataViewCard/DataViewCard.test.tsx +127 -0
  71. package/src/components/dataViewCard/DataViewCard.tsx +62 -0
  72. package/src/components/dataViewCard/dataViewCard.scss +25 -0
  73. package/src/components/row/Row.tsx +4 -1
  74. package/src/components/row/row.scss +9 -1
  75. package/src/components/table/Table.stories.tsx +49 -2
  76. package/src/components/table/Table.test.tsx +53 -0
  77. package/src/components/table/tableControls/HideColumnsDropdown.tsx +11 -2
  78. package/src/components/treeRow/TreeRow.stories.tsx +870 -0
  79. package/src/components/treeRow/TreeRow.test.tsx +371 -0
  80. package/src/components/treeRow/TreeRow.tsx +85 -0
  81. package/src/components/treeRow/TreeRowSection.tsx +56 -0
  82. package/src/components/treeRow/treeRow.scss +134 -0
  83. package/src/docs/Contributing.mdx +1 -0
  84. package/src/index.scss +2 -0
  85. package/src/index.ts +4 -1
@@ -0,0 +1,464 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import {
3
+ Controls,
4
+ Heading as DocHeading,
5
+ Markdown,
6
+ Primary as DocPrimary,
7
+ Stories,
8
+ Subtitle,
9
+ Title,
10
+ } from '@storybook/addon-docs/blocks';
11
+ import { DataViewCard } from './DataViewCard';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Docs page content
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const DESCRIPTION_INTRO = [
18
+ 'DataViewCard is a precomposed [`Card`](?path=/docs/components-card--docs) variant for displaying a person or entity',
19
+ 'as a tile — an [`Avatar`](?path=/docs/components-avatar--docs) on the left, a [`Heading`](?path=/docs/components-heading--docs)',
20
+ 'for the name, and a stack of key-value [`Row`](?path=/docs/components-row--docs)s separated by [`Separator`](?path=/docs/components-separator--docs)s.',
21
+ 'Built for grids of records (e.g. browse-students, staff directories) where the whole tile navigates to a profile.',
22
+ ].join('\n');
23
+
24
+ const USAGE_GUIDANCE = [
25
+ '### When to use',
26
+ '',
27
+ '- **Browse views of people / records** — staff list, student list, contact directory',
28
+ '- **Tile-based navigation** — when clicking the whole card should drill into a detail view',
29
+ '- **Summary at a glance** — a heading plus 2–4 quick facts (year group, role, ID, etc.)',
30
+ '',
31
+ '---',
32
+ '',
33
+ '### When NOT to use',
34
+ '',
35
+ '| Situation | Use instead |',
36
+ '|---|---|',
37
+ '| Free-form content (paragraphs, mixed media) | [`ArticleCard`](?path=/docs/components-articlecard--docs) |',
38
+ '| Single metric / KPI display | [`KPICard`](?path=/docs/components-kpicard--docs) |',
39
+ '| Long lists where row density matters | [`Table`](?path=/docs/components-table--docs) |',
40
+ '| Just an avatar with name and role | [`SingleUser`](?path=/docs/components-singleuser--docs) |',
41
+ ].join('\n');
42
+
43
+ const DEVELOPER_NOTES = [
44
+ '### Critical usage patterns',
45
+ '',
46
+ '**`aria-label` (or `aria-labelledby`) is REQUIRED when `onClick` is set.** The underlying `Card` renders',
47
+ '`role="button"` for clickable cards — a button without an accessible name is a WCAG 2.1 failure (4.1.2).',
48
+ '',
49
+ '**Rows are passed as data, not children.** The component owns the layout — you provide an array of',
50
+ '`{ label, value, note? }` objects and the component renders them as `Row`s with separators between.',
51
+ '',
52
+ '**Avatar is optional.** Omit the `avatar` prop entirely when you do not have an image (the layout collapses',
53
+ 'cleanly to header-plus-rows). Pass full `Avatar.Props` for full control over `src`/`initials`/`size`/`alt`.',
54
+ '',
55
+ '**Header is `string` only.** Use the `aria-labelledby` pattern if you need richer markup for the accessible',
56
+ 'name; otherwise pass the plain person/entity name as `header`.',
57
+ '',
58
+ '---',
59
+ '',
60
+ '### Accessibility',
61
+ '',
62
+ '- Heading renders as a real `<h4>` — fits inside an `<h3>` section/page heading',
63
+ '- Whole card is keyboard activatable (`Enter` / `Space`) when `onClick` is set, via the `Card` shell',
64
+ '- `disabled` removes click handling and applies `aria-disabled="true"` — focus is preserved',
65
+ '- Always pair an `Avatar` with `alt` text describing who it represents (or empty `alt=""` if purely decorative)',
66
+ '',
67
+ '---',
68
+ '',
69
+ '### TypeScript types',
70
+ '',
71
+ '```ts',
72
+ "import { DataViewCard } from '@arbor-education/design-system.components';",
73
+ '',
74
+ 'function MyCard(props: DataViewCard.Props) { ... }',
75
+ 'const rows: DataViewCard.RowData[] = [{ label: \'Year\', value: \'Year 8\' }];',
76
+ '```',
77
+ '',
78
+ '| Type | Description |',
79
+ '|---|---|',
80
+ '| `DataViewCard.Props` | Full props interface |',
81
+ '| `DataViewCard.RowData` | Alias for `Row.Props` — accepts the full [`Row`](?path=/docs/components-row--docs) API (`label`, `value`, `note`, plus `onClick` / `className`) |',
82
+ ].join('\n');
83
+
84
+ const RELATED_COMPONENTS = [
85
+ '## Related components',
86
+ '',
87
+ '[Card](?path=/docs/components-card--docs) · [ArticleCard](?path=/docs/components-articlecard--docs) · [KPICard](?path=/docs/components-kpicard--docs) · [Avatar](?path=/docs/components-avatar--docs) · [Row](?path=/docs/components-row--docs) · [Heading](?path=/docs/components-heading--docs) · [Separator](?path=/docs/components-separator--docs)',
88
+ ].join('\n');
89
+
90
+ const PROPS_INTRO = 'The preview below is wired to the **Controls** panel — tweak any prop to see the story update in real time.';
91
+
92
+ function DataViewCardDocsPage() {
93
+ return (
94
+ <>
95
+ <Title />
96
+ <Subtitle />
97
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
98
+ <DocHeading>Interactive example</DocHeading>
99
+ <Markdown>{PROPS_INTRO}</Markdown>
100
+ <DocPrimary />
101
+ <Controls />
102
+ <DocHeading>Usage guidance</DocHeading>
103
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
104
+ <DocHeading>Developer notes</DocHeading>
105
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
106
+ <DocHeading>Examples</DocHeading>
107
+ <Stories title="" />
108
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
109
+ </>
110
+ );
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Meta
115
+ // ---------------------------------------------------------------------------
116
+
117
+ const sampleRows = [
118
+ { label: 'Year', value: 'Year 13' },
119
+ { label: 'Form', value: 'Form 13DZ' },
120
+ { label: 'DOB', value: '20 May 1932' },
121
+ ];
122
+
123
+ const AVATAR_BASE = 'https://i.pravatar.cc/150?img=';
124
+
125
+ const meta = {
126
+ title: 'Components/Card/DataViewCard',
127
+ component: DataViewCard,
128
+ parameters: {
129
+ layout: 'padded',
130
+ docs: {
131
+ page: DataViewCardDocsPage,
132
+ },
133
+ },
134
+ tags: ['autodocs'],
135
+ argTypes: {
136
+ 'header': {
137
+ control: 'text',
138
+ description: 'The card heading — rendered as an `<h4>`. Pass the person or entity name.',
139
+ table: {
140
+ type: { summary: 'string' },
141
+ },
142
+ },
143
+ 'avatar': {
144
+ control: 'object',
145
+ description: [
146
+ 'Optional `Avatar.Props` — accepts `src`, `initials`, `alt`, and `size` (defaults to `extra-large`).',
147
+ 'Omit the prop entirely to render the card without an avatar.',
148
+ ].join(' '),
149
+ table: {
150
+ type: { summary: 'Avatar.Props' },
151
+ },
152
+ },
153
+ 'rows': {
154
+ control: 'object',
155
+ description: 'Array of `{ label, value, note? }` — each is rendered as a `Row`, separated by `Separator`s.',
156
+ table: {
157
+ type: { summary: 'DataViewCard.RowData[]' },
158
+ },
159
+ },
160
+ 'disabled': {
161
+ control: 'boolean',
162
+ description: 'Disables click handling and applies `aria-disabled="true"`.',
163
+ table: {
164
+ type: { summary: 'boolean' },
165
+ defaultValue: { summary: 'false' },
166
+ },
167
+ },
168
+ 'onClick': {
169
+ control: false,
170
+ description: 'When set, the whole card becomes a `role="button"` and `aria-label` (or `aria-labelledby`) is required.',
171
+ table: {
172
+ type: { summary: '(e: MouseEvent<HTMLElement>) => void' },
173
+ },
174
+ },
175
+ 'aria-label': {
176
+ control: 'text',
177
+ description: 'Required when `onClick` is set (unless `aria-labelledby` is provided).',
178
+ table: {
179
+ type: { summary: 'string' },
180
+ },
181
+ },
182
+ 'aria-labelledby': {
183
+ control: false,
184
+ description: 'Alternative to `aria-label` — point to a visible element\'s id when one exists.',
185
+ table: {
186
+ type: { summary: 'string' },
187
+ },
188
+ },
189
+ 'className': {
190
+ control: false,
191
+ description: 'Additional CSS class names on the underlying Card root.',
192
+ table: {
193
+ type: { summary: 'string' },
194
+ },
195
+ },
196
+ },
197
+ } satisfies Meta<typeof DataViewCard>;
198
+
199
+ export default meta;
200
+ type Story = StoryObj<typeof DataViewCard>;
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Helpers
204
+ // ---------------------------------------------------------------------------
205
+
206
+ const withDescription = (story: Story, description: string): Story => ({
207
+ ...story,
208
+ parameters: {
209
+ ...story.parameters,
210
+ docs: {
211
+ ...story.parameters?.docs,
212
+ description: {
213
+ story: description,
214
+ },
215
+ },
216
+ },
217
+ });
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Templates
221
+ // ---------------------------------------------------------------------------
222
+
223
+ const GridTemplate = () => (
224
+ <div
225
+ style={{
226
+ display: 'grid',
227
+ gap: 'var(--spacing-large)',
228
+ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
229
+ }}
230
+ >
231
+ <DataViewCard
232
+ aria-label="View Dorothy Zbornak"
233
+ avatar={{ src: `${AVATAR_BASE}47`, alt: 'Dorothy Zbornak' }}
234
+ header="Dorothy Zbornak"
235
+ onClick={() => {}}
236
+ rows={[
237
+ { label: 'Year', value: 'Year 13' },
238
+ { label: 'Form', value: 'Form 13DZ' },
239
+ { label: 'DOB', value: '20 May 1932' },
240
+ ]}
241
+ />
242
+ <DataViewCard
243
+ aria-label="View Rose Nylund"
244
+ avatar={{ src: `${AVATAR_BASE}5`, alt: 'Rose Nylund' }}
245
+ header="Rose Nylund"
246
+ onClick={() => {}}
247
+ rows={[
248
+ { label: 'Year', value: 'Year 12' },
249
+ { label: 'Form', value: 'Form 12RN' },
250
+ { label: 'DOB', value: '21 Jul 1928' },
251
+ ]}
252
+ />
253
+ <DataViewCard
254
+ aria-label="View Blanche Devereaux"
255
+ avatar={{ src: `${AVATAR_BASE}49`, alt: 'Blanche Devereaux' }}
256
+ header="Blanche Devereaux"
257
+ onClick={() => {}}
258
+ rows={[
259
+ { label: 'Year', value: 'Year 13' },
260
+ { label: 'Form', value: 'Form 13BD' },
261
+ { label: 'DOB', value: '17 Aug 1934' },
262
+ ]}
263
+ />
264
+ <DataViewCard
265
+ aria-label="View Sophia Petrillo"
266
+ avatar={{ initials: 'SP', alt: 'Sophia Petrillo' }}
267
+ header="Sophia Petrillo"
268
+ onClick={() => {}}
269
+ rows={[
270
+ { label: 'Year', value: 'Year 13' },
271
+ { label: 'Form', value: 'Form 13SP' },
272
+ { label: 'DOB', value: '17 Mar 1908' },
273
+ ]}
274
+ />
275
+ <DataViewCard
276
+ aria-label="View Bella Swan"
277
+ avatar={{ src: `${AVATAR_BASE}21`, alt: 'Bella Swan' }}
278
+ header="Bella Swan"
279
+ onClick={() => {}}
280
+ rows={[
281
+ { label: 'Year', value: 'Year 11' },
282
+ { label: 'Form', value: 'Form 11FH' },
283
+ { label: 'DOB', value: '13 Sep 1987' },
284
+ ]}
285
+ />
286
+ <DataViewCard
287
+ aria-label="View Jacob Black"
288
+ avatar={{ initials: 'JB', alt: 'Jacob Black' }}
289
+ header="Jacob Black"
290
+ onClick={() => {}}
291
+ rows={[
292
+ { label: 'Year', value: 'Year 11' },
293
+ { label: 'Form', value: 'Form 11LP' },
294
+ { label: 'DOB', value: '14 Jan 1990' },
295
+ ]}
296
+ />
297
+ </div>
298
+ );
299
+
300
+ const NoAvatarTemplate = () => (
301
+ <DataViewCard
302
+ aria-label="View record A1024"
303
+ header="Record A1024"
304
+ onClick={() => {}}
305
+ rows={[
306
+ { label: 'Type', value: 'Behaviour incident' },
307
+ { label: 'Reported', value: '14 Mar 2026' },
308
+ { label: 'Status', value: 'Open' },
309
+ ]}
310
+ />
311
+ );
312
+
313
+ const StaticTemplate = () => (
314
+ <DataViewCard
315
+ avatar={{ src: `${AVATAR_BASE}58`, alt: 'Edward Cullen' }}
316
+ header="Edward Cullen"
317
+ rows={[
318
+ { label: 'Year', value: 'Year 11' },
319
+ { label: 'Form', value: 'Form 11FH' },
320
+ { label: 'DOB', value: '20 Jun 1901' },
321
+ ]}
322
+ />
323
+ );
324
+
325
+ const DisabledTemplate = () => (
326
+ <DataViewCard
327
+ aria-label="View Alice Cullen"
328
+ avatar={{ src: `${AVATAR_BASE}25`, alt: 'Alice Cullen' }}
329
+ disabled
330
+ header="Alice Cullen"
331
+ onClick={() => {}}
332
+ rows={[
333
+ { label: 'Year', value: 'Year 12' },
334
+ { label: 'Form', value: 'Form 12FH' },
335
+ { label: 'DOB', value: '06 Jan 1901' },
336
+ ]}
337
+ />
338
+ );
339
+
340
+ const WithNoteTemplate = () => (
341
+ <DataViewCard
342
+ aria-label="View Blanche Devereaux"
343
+ avatar={{ src: `${AVATAR_BASE}49`, alt: 'Blanche Devereaux' }}
344
+ header="Blanche Devereaux"
345
+ onClick={() => {}}
346
+ rows={[
347
+ { label: 'Year', value: 'Year 13', note: 'Glamour studies major' },
348
+ { label: 'Form', value: 'Form 13BD', note: 'Tutor: Ms Petrillo' },
349
+ { label: 'DOB', value: '17 Aug 1934' },
350
+ ]}
351
+ />
352
+ );
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Stories
356
+ // ---------------------------------------------------------------------------
357
+
358
+ export const Default: Story = withDescription(
359
+ {
360
+ args: {
361
+ 'aria-label': 'View Dorothy Zbornak',
362
+ 'avatar': { src: `${AVATAR_BASE}47`, alt: 'Dorothy Zbornak' },
363
+ 'header': 'Dorothy Zbornak',
364
+ 'onClick': () => {},
365
+ 'rows': sampleRows,
366
+ },
367
+ render: args => <DataViewCard {...args} />,
368
+ },
369
+ [
370
+ 'The interactive canvas — every prop is wired to the Controls panel below.',
371
+ 'Tweak the `rows` array, swap the `avatar` for `{ src: \'…\' }` to test imagery, or remove `onClick` to flip the card to a static figure.',
372
+ ].join(' '),
373
+ );
374
+
375
+ export const Grid: Story = withDescription(
376
+ {
377
+ render: GridTemplate,
378
+ parameters: {
379
+ controls: { disable: true },
380
+ docs: {
381
+ source: {
382
+ language: 'tsx',
383
+ code: `
384
+ import { DataViewCard } from '@arbor-education/design-system.components';
385
+
386
+ function StudentGrid() {
387
+ return (
388
+ <div
389
+ style={{
390
+ display: 'grid',
391
+ gap: 'var(--spacing-large)',
392
+ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
393
+ }}
394
+ >
395
+ <DataViewCard
396
+ aria-label="View Dorothy Zbornak"
397
+ avatar={{ src: 'https://i.pravatar.cc/150?img=47', alt: 'Dorothy Zbornak' }}
398
+ header="Dorothy Zbornak"
399
+ onClick={() => {}}
400
+ rows={[
401
+ { label: 'Year', value: 'Year 13' },
402
+ { label: 'Form', value: 'Form 13DZ' },
403
+ { label: 'DOB', value: '20 May 1932' },
404
+ ]}
405
+ />
406
+ {/* …more cards… */}
407
+ </div>
408
+ );
409
+ }
410
+ export default StudentGrid;
411
+ `.trim(),
412
+ },
413
+ },
414
+ },
415
+ },
416
+ [
417
+ 'The canonical browse-students layout: a responsive CSS grid of clickable cards (`auto-fill, minmax(320px, 1fr)`).',
418
+ 'Each card has a unique `aria-label` describing the record it links to.',
419
+ ].join(' '),
420
+ );
421
+
422
+ export const NoAvatar: Story = withDescription(
423
+ {
424
+ render: NoAvatarTemplate,
425
+ parameters: { controls: { disable: true } },
426
+ },
427
+ [
428
+ 'When the record has no portrait or logo, simply omit the `avatar` prop —',
429
+ 'the layout collapses to header-plus-rows without leaving an empty placeholder.',
430
+ ].join(' '),
431
+ );
432
+
433
+ export const Static: Story = withDescription(
434
+ {
435
+ render: StaticTemplate,
436
+ parameters: { controls: { disable: true } },
437
+ },
438
+ [
439
+ 'Without an `onClick`, the card renders as a static `<figure>` with no `role="button"` and no chevron icon.',
440
+ 'Use this when the card is a read-only summary alongside a separate action button.',
441
+ ].join(' '),
442
+ );
443
+
444
+ export const Disabled: Story = withDescription(
445
+ {
446
+ render: DisabledTemplate,
447
+ parameters: { controls: { disable: true } },
448
+ },
449
+ [
450
+ 'The `disabled` prop suppresses click handling and sets `aria-disabled="true"` on the underlying Card.',
451
+ 'The card still renders fully and remains focusable — only the click is suppressed.',
452
+ ].join(' '),
453
+ );
454
+
455
+ export const WithNote: Story = withDescription(
456
+ {
457
+ render: WithNoteTemplate,
458
+ parameters: { controls: { disable: true } },
459
+ },
460
+ [
461
+ 'Each row supports an optional `note` (italicised by `Row`) for secondary context — e.g. tutor name, promotion date.',
462
+ 'Leave it off when not needed; rows render at consistent height regardless.',
463
+ ].join(' '),
464
+ );
@@ -0,0 +1,127 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import '@testing-library/jest-dom/vitest';
3
+ import { describe, expect, test, vi } from 'vitest';
4
+ import { DataViewCard } from './DataViewCard';
5
+
6
+ const baseRows = [
7
+ { label: 'Year', value: 'Year 8' },
8
+ { label: 'Form', value: 'Form 8LR' },
9
+ { label: 'DOB', value: '29 Jan 2012' },
10
+ ];
11
+
12
+ describe('DataViewCard', () => {
13
+ test('renders the header as an h4', () => {
14
+ render(<DataViewCard header="Christopher Longbottom" rows={baseRows} />);
15
+
16
+ expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Christopher Longbottom');
17
+ });
18
+
19
+ test('renders every row label and value', () => {
20
+ render(<DataViewCard header="Christopher Longbottom" rows={baseRows} />);
21
+
22
+ baseRows.forEach((row) => {
23
+ expect(screen.getByText(row.label)).toBeInTheDocument();
24
+ expect(screen.getByText(row.value)).toBeInTheDocument();
25
+ });
26
+ });
27
+
28
+ test('renders a row note when provided', () => {
29
+ render(
30
+ <DataViewCard
31
+ header="Christopher Longbottom"
32
+ rows={[{ label: 'Year', value: 'Year 8', note: 'Promoted Sept 2024' }]}
33
+ />,
34
+ );
35
+
36
+ expect(screen.getByText('Promoted Sept 2024')).toBeInTheDocument();
37
+ });
38
+
39
+ test('renders an Avatar when avatar props are provided', () => {
40
+ const { container } = render(
41
+ <DataViewCard
42
+ avatar={{ initials: 'CL', alt: 'Christopher Longbottom avatar' }}
43
+ header="Christopher Longbottom"
44
+ rows={baseRows}
45
+ />,
46
+ );
47
+
48
+ expect(container.querySelector('.ds-avatar')).toBeInTheDocument();
49
+ expect(screen.getByText('CL')).toBeInTheDocument();
50
+ });
51
+
52
+ test('omits the Avatar when no avatar props are provided', () => {
53
+ const { container } = render(
54
+ <DataViewCard header="Christopher Longbottom" rows={baseRows} />,
55
+ );
56
+
57
+ expect(container.querySelector('.ds-avatar')).not.toBeInTheDocument();
58
+ });
59
+
60
+ test('uses the Card shell as a button when onClick is provided', () => {
61
+ const handleClick = vi.fn();
62
+
63
+ render(
64
+ <DataViewCard
65
+ aria-label="View Christopher Longbottom"
66
+ header="Christopher Longbottom"
67
+ onClick={handleClick}
68
+ rows={baseRows}
69
+ />,
70
+ );
71
+
72
+ const card = screen.getByRole('button', { name: 'View Christopher Longbottom' });
73
+ fireEvent.click(card);
74
+
75
+ expect(handleClick).toHaveBeenCalledTimes(1);
76
+ });
77
+
78
+ test('renders as a static figure when not clickable', () => {
79
+ const { container } = render(
80
+ <DataViewCard header="Christopher Longbottom" rows={baseRows} />,
81
+ );
82
+
83
+ const card = container.querySelector('figure');
84
+
85
+ expect(card).toBeInTheDocument();
86
+ expect(card).not.toHaveAttribute('role', 'button');
87
+ });
88
+
89
+ test('does not invoke onClick when disabled', () => {
90
+ const handleClick = vi.fn();
91
+
92
+ render(
93
+ <DataViewCard
94
+ aria-label="View Christopher Longbottom"
95
+ disabled
96
+ header="Christopher Longbottom"
97
+ onClick={handleClick}
98
+ rows={baseRows}
99
+ />,
100
+ );
101
+
102
+ const card = screen.getByLabelText('View Christopher Longbottom');
103
+ fireEvent.click(card);
104
+
105
+ expect(handleClick).not.toHaveBeenCalled();
106
+ expect(card).toHaveAttribute('aria-disabled', 'true');
107
+ });
108
+
109
+ test('renders no rows when an empty rows array is provided', () => {
110
+ render(<DataViewCard header="Christopher Longbottom" rows={[]} />);
111
+
112
+ expect(screen.getByRole('heading', { level: 4 })).toBeInTheDocument();
113
+ expect(screen.queryByText('Year')).not.toBeInTheDocument();
114
+ });
115
+
116
+ test('forwards a custom className to the underlying Card', () => {
117
+ const { container } = render(
118
+ <DataViewCard
119
+ className="custom-data-view-card"
120
+ header="Christopher Longbottom"
121
+ rows={baseRows}
122
+ />,
123
+ );
124
+
125
+ expect(container.querySelector('.custom-data-view-card')).toBeInTheDocument();
126
+ });
127
+ });
@@ -0,0 +1,62 @@
1
+ import classNames from 'classnames';
2
+ import { Fragment } from 'react';
3
+ import { Avatar } from 'Components/avatar/Avatar';
4
+ import { Card, getCardInteractionProps } from 'Components/card/Card';
5
+ import { Heading } from 'Components/heading/Heading';
6
+ import { Row } from 'Components/row/Row';
7
+ import { Separator } from 'Components/separator/Separator';
8
+
9
+ export type DataViewCardRow = Row.Props;
10
+
11
+ type DataViewCardBaseProps = {
12
+ className?: string;
13
+ header: string;
14
+ avatar?: Avatar.Props;
15
+ rows: DataViewCardRow[];
16
+ disabled?: boolean;
17
+ };
18
+
19
+ export type DataViewCardProps = DataViewCardBaseProps & Card.InteractionProps;
20
+
21
+ export const DataViewCard = (props: DataViewCardProps): React.JSX.Element => {
22
+ const { className, header, avatar, rows, disabled = false } = props;
23
+
24
+ return (
25
+ <Card
26
+ {...getCardInteractionProps(props)}
27
+ className={classNames('ds-data-view-card__container', className)}
28
+ disabled={disabled}
29
+ spacing="default"
30
+ >
31
+ <article className="ds-data-view-card">
32
+ {avatar && (
33
+ <Avatar
34
+ size="extra-large"
35
+ {...avatar}
36
+ className={classNames(
37
+ 'ds-data-view-card__avatar',
38
+ avatar.className,
39
+ )}
40
+ />
41
+ )}
42
+ <div className="ds-data-view-card__content">
43
+ <Heading level={4} className="ds-data-view-card__header">
44
+ {header}
45
+ </Heading>
46
+ <Separator />
47
+ {rows.map((row, index) => (
48
+ <Fragment key={`${row.label}-${index}`}>
49
+ <Row {...row} />
50
+ {index < rows.length - 1 && <Separator />}
51
+ </Fragment>
52
+ ))}
53
+ </div>
54
+ </article>
55
+ </Card>
56
+ );
57
+ };
58
+
59
+ export namespace DataViewCard {
60
+ export type Props = DataViewCardProps;
61
+ export type RowData = DataViewCardRow;
62
+ }
@@ -0,0 +1,25 @@
1
+ .ds-data-view-card {
2
+ display: flex;
3
+ flex-direction: row;
4
+ align-items: flex-start;
5
+ gap: var(--card-spacing-gap-horizontal);
6
+ width: 100%;
7
+ }
8
+
9
+ .ds-data-view-card__avatar {
10
+ flex-shrink: 0;
11
+ }
12
+
13
+ .ds-data-view-card__content {
14
+ display: flex;
15
+ flex: 1 1 auto;
16
+ flex-direction: column;
17
+ min-width: 0;
18
+ }
19
+
20
+ .ds-data-view-card__header {
21
+ margin: 0;
22
+ overflow: hidden;
23
+ text-overflow: ellipsis;
24
+ white-space: nowrap;
25
+ }