@arbor-education/design-system.components 3.2.0 → 3.3.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 (90) hide show
  1. package/.gather/skills/write-stories/README.md +8 -4
  2. package/.gather/skills/write-stories/SKILL.md +59 -5
  3. package/CHANGELOG.md +24 -0
  4. package/dist/components/breadcrumbs/Breadcrumbs.stories.d.ts +1 -0
  5. package/dist/components/breadcrumbs/Breadcrumbs.stories.d.ts.map +1 -1
  6. package/dist/components/breadcrumbs/Breadcrumbs.stories.js +103 -21
  7. package/dist/components/breadcrumbs/Breadcrumbs.stories.js.map +1 -1
  8. package/dist/components/card/Card.stories.d.ts.map +1 -1
  9. package/dist/components/card/Card.stories.js +46 -41
  10. package/dist/components/card/Card.stories.js.map +1 -1
  11. package/dist/components/editableText/EditableText.stories.d.ts.map +1 -1
  12. package/dist/components/editableText/EditableText.stories.js +19 -6
  13. package/dist/components/editableText/EditableText.stories.js.map +1 -1
  14. package/dist/components/filterBar/FilterBar.d.ts +2 -2
  15. package/dist/components/filterBar/FilterBar.d.ts.map +1 -1
  16. package/dist/components/filterBar/FilterBar.js +7 -3
  17. package/dist/components/filterBar/FilterBar.js.map +1 -1
  18. package/dist/components/filterBar/FilterBar.stories.d.ts +16 -0
  19. package/dist/components/filterBar/FilterBar.stories.d.ts.map +1 -1
  20. package/dist/components/filterBar/FilterBar.stories.js +101 -7
  21. package/dist/components/filterBar/FilterBar.stories.js.map +1 -1
  22. package/dist/components/filterBar/FilterBar.test.js +30 -0
  23. package/dist/components/filterBar/FilterBar.test.js.map +1 -1
  24. package/dist/components/formField/fieldset/Fieldset.stories.d.ts.map +1 -1
  25. package/dist/components/formField/fieldset/Fieldset.stories.js +5 -3
  26. package/dist/components/formField/fieldset/Fieldset.stories.js.map +1 -1
  27. package/dist/components/row/Row.stories.d.ts.map +1 -1
  28. package/dist/components/row/Row.stories.js +22 -13
  29. package/dist/components/row/Row.stories.js.map +1 -1
  30. package/dist/components/sidebar/Sidebar.stories.d.ts.map +1 -1
  31. package/dist/components/sidebar/Sidebar.stories.js +19 -24
  32. package/dist/components/sidebar/Sidebar.stories.js.map +1 -1
  33. package/dist/components/table/Table.d.ts.map +1 -1
  34. package/dist/components/table/Table.js +2 -1
  35. package/dist/components/table/Table.js.map +1 -1
  36. package/dist/components/table/Table.stories.d.ts +7 -0
  37. package/dist/components/table/Table.stories.d.ts.map +1 -1
  38. package/dist/components/table/Table.stories.js +60 -10
  39. package/dist/components/table/Table.stories.js.map +1 -1
  40. package/dist/components/table/Table.test.js +23 -0
  41. package/dist/components/table/Table.test.js.map +1 -1
  42. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.d.ts.map +1 -1
  43. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.js +8 -5
  44. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.js.map +1 -1
  45. package/dist/components/table/tableControls/TableControls.d.ts +2 -0
  46. package/dist/components/table/tableControls/TableControls.d.ts.map +1 -1
  47. package/dist/components/table/tableControls/TableControls.js +2 -2
  48. package/dist/components/table/tableControls/TableControls.js.map +1 -1
  49. package/dist/components/table/tableControls/TableControls.stories.d.ts +26 -1
  50. package/dist/components/table/tableControls/TableControls.stories.d.ts.map +1 -1
  51. package/dist/components/table/tableControls/TableControls.stories.js +50 -21
  52. package/dist/components/table/tableControls/TableControls.stories.js.map +1 -1
  53. package/dist/components/table/tableControls/TableControls.test.js +16 -0
  54. package/dist/components/table/tableControls/TableControls.test.js.map +1 -1
  55. package/dist/components/table/useTableSettings.d.ts +2 -0
  56. package/dist/components/table/useTableSettings.d.ts.map +1 -1
  57. package/dist/components/table/useTableSettings.js +12 -7
  58. package/dist/components/table/useTableSettings.js.map +1 -1
  59. package/dist/components/widget/Widget.stories.d.ts +13 -0
  60. package/dist/components/widget/Widget.stories.d.ts.map +1 -1
  61. package/dist/components/widget/Widget.stories.js +45 -6
  62. package/dist/components/widget/Widget.stories.js.map +1 -1
  63. package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts +88 -16
  64. package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts.map +1 -1
  65. package/dist/components/widgetCalendar/WidgetCalendar.stories.js +443 -30
  66. package/dist/components/widgetCalendar/WidgetCalendar.stories.js.map +1 -1
  67. package/dist/index.css +1 -4
  68. package/dist/index.css.map +1 -1
  69. package/package.json +1 -1
  70. package/src/components/breadcrumbs/Breadcrumbs.stories.tsx +112 -21
  71. package/src/components/card/Card.stories.tsx +46 -41
  72. package/src/components/datePicker/datePicker.scss +0 -5
  73. package/src/components/editableText/EditableText.stories.tsx +26 -29
  74. package/src/components/filterBar/FilterBar.stories.tsx +126 -4
  75. package/src/components/filterBar/FilterBar.test.tsx +51 -0
  76. package/src/components/filterBar/FilterBar.tsx +18 -12
  77. package/src/components/formField/fieldset/Fieldset.stories.tsx +5 -3
  78. package/src/components/row/Row.stories.tsx +20 -14
  79. package/src/components/sidebar/Sidebar.stories.tsx +61 -112
  80. package/src/components/table/Table.stories.tsx +82 -10
  81. package/src/components/table/Table.test.tsx +42 -0
  82. package/src/components/table/Table.tsx +2 -0
  83. package/src/components/table/cellRenderers/SelectDropdownCellRenderer.stories.tsx +8 -5
  84. package/src/components/table/tableControls/TableControls.stories.tsx +54 -21
  85. package/src/components/table/tableControls/TableControls.test.tsx +20 -0
  86. package/src/components/table/tableControls/TableControls.tsx +6 -0
  87. package/src/components/table/useTableSettings.ts +20 -6
  88. package/src/components/widget/Widget.stories.tsx +61 -6
  89. package/src/components/widgetCalendar/WidgetCalendar.stories.tsx +547 -74
  90. package/src/tokens.scss +1 -0
@@ -1,17 +1,22 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
1
2
  import {
2
3
  Controls,
3
4
  Heading as DocHeading,
4
- Primary as DocPrimary,
5
5
  Markdown,
6
+ Primary as DocPrimary,
6
7
  Stories,
7
8
  Subtitle,
8
9
  Title,
9
10
  } from '@storybook/addon-docs/blocks';
10
- import type { Meta, StoryObj } from '@storybook/react-vite';
11
+ import { useState } from 'react';
11
12
  import { Widget } from 'Components/widget/Widget';
12
13
  import { fn } from 'storybook/test';
13
14
  import { WidgetCalendar } from './WidgetCalendar.js';
14
15
 
16
+ // ---------------------------------------------------------------------------
17
+ // Fixture data
18
+ // ---------------------------------------------------------------------------
19
+
15
20
  const classEventTime = {
16
21
  eventSecondaryTerm: 'Time and location',
17
22
  eventDateTime: '2026-06-08T08:45/2026-06-08T09:00',
@@ -88,11 +93,77 @@ const overflowItems: WidgetCalendar.ItemData[] = [
88
93
  ...initialItems.map((item, index) => ({ ...item, id: `${item.id}-overflow-${index}` })),
89
94
  ];
90
95
 
96
+ // ---------------------------------------------------------------------------
97
+ // Docs page content
98
+ // ---------------------------------------------------------------------------
99
+
91
100
  const DESCRIPTION_INTRO = [
92
- '`WidgetCalendar` is a pre-composed dashboard widget for upcoming calendar events.',
93
- 'It wraps [`Widget`](?path=/docs/components-widget--docs) with event rows, type indicators, and optional navigation.',
94
- 'Pass ISO `eventDateTime` and a formatted `eventTimeLabel` for each item — the component renders a semantic `<time>` element, matching the pattern used by `WidgetNotifications`.',
95
- ].join(' ');
101
+ '`WidgetCalendar` is a pre-composed dashboard widget for date/time-anchored calendar events.',
102
+ 'It wraps [`Widget`](?path=/docs/components-widget--docs) with an event list, primary/secondary type indicators, and',
103
+ 'optional heading navigation. Each item renders a semantic `<time>` element from an ISO `eventDateTime` and a',
104
+ 'human-readable `eventTimeLabel`.',
105
+ ].join('\n');
106
+
107
+ const PROPS_INTRO = 'The preview below is wired to the **Controls** panel — tweak any prop to see the story update in real time.';
108
+
109
+ const USAGE_GUIDANCE = [
110
+ '### When to use',
111
+ '',
112
+ '- Date/time-anchored list items — events, appointments, or timetable entries — that need a semantic `<time>` element',
113
+ '- Optional location context alongside the time (classroom, hall, room number)',
114
+ '- A dashboard widget where rows may optionally navigate somewhere on click',
115
+ '',
116
+ '---',
117
+ '',
118
+ '### When NOT to use',
119
+ '',
120
+ '| Situation | Use instead |',
121
+ '|---|---|',
122
+ '| Favourite/shortcut links with no date fields, needing a remove action | [`WidgetFavourites`](?path=/docs/components-widget-widgetfavourites--docs) (link + remove pattern) |',
123
+ '| Read/unread notification feed with a mark-all-as-read action | [`WidgetNotifications`](?path=/docs/components-widget-widgetnotifications--docs) |',
124
+ ].join('\n');
125
+
126
+ const DEVELOPER_NOTES = [
127
+ '### Critical usage patterns',
128
+ '',
129
+ '**`onItemClick` signature differs between `WidgetCalendar` and `WidgetCalendar.Item`.** The parent\'s',
130
+ '`onItemClick` is `(id: string) => void` — it receives the clicked item\'s id. `WidgetCalendar.Item`\'s own',
131
+ '`onItemClick` prop takes **no arguments** (`() => void`). When composing manually (see the Composable',
132
+ 'story), you must close over the id yourself: `onItemClick={() => handleClick(item.id)}`.',
133
+ '',
134
+ '**`buttonA11yText` fallback only fires for plain strings.** When `onItemClick` makes a row interactive,',
135
+ 'the accessible label defaults to `Open ${eventPrimaryDefinition}` — but only when `eventPrimaryDefinition`',
136
+ 'is a plain string. Pass a JSX node as the primary definition and the fallback silently degrades to the',
137
+ 'generic `\'Open event\'`. Always supply `buttonA11yText` explicitly when `eventPrimaryDefinition` is rich content.',
138
+ '',
139
+ '**`eventSecondaryDefinition` is an all-or-nothing override.** When provided, it entirely replaces the',
140
+ 'default time + location composition — `eventLocation` becomes dead data with no warning. Never pass both',
141
+ 'in the same item.',
142
+ '',
143
+ '**The type-bar colour is semantic, not decorative.** `eventType="primary"` renders the brand-green bar',
144
+ '(`--widget-calendar-type-primary-color`), `"secondary"` renders neutral grey',
145
+ '(`--widget-calendar-type-secondary-color`). Choose deliberately — don\'t treat it as a purely visual toggle.',
146
+ '',
147
+ '**No loading, disabled, or error state exists on this component.** Handle those states in the consumer',
148
+ '(e.g. render a Skeleton in place of `WidgetCalendar` while data loads).',
149
+ '',
150
+ '---',
151
+ '',
152
+ '### Accessibility',
153
+ '',
154
+ '- Each item renders a `<dl>` with `sr-only` `<dt>` terms and visible `<dd>` definitions, pairing both the',
155
+ ' primary and secondary content for screen readers.',
156
+ '- `eventDateTime` must be a valid ISO datetime string — it is passed directly to the `<time dateTime>` attribute.',
157
+ '- Interactive rows (`onItemClick` provided) get `role="button"`, `tabIndex={0}`, and respond to both',
158
+ ' Enter and Space, matching the standard button-role keyboard contract.',
159
+ '- Rows without `onItemClick` render as plain, non-interactive `<li>` elements — no button semantics are added.',
160
+ ].join('\n');
161
+
162
+ const RELATED_COMPONENTS = [
163
+ '## Related components',
164
+ '',
165
+ '[Widget](?path=/docs/components-widget--docs) · [WidgetFavourites](?path=/docs/components-widget-widgetfavourites--docs) · [WidgetNotifications](?path=/docs/components-widget-widgetnotifications--docs)',
166
+ ].join('\n');
96
167
 
97
168
  function WidgetCalendarDocsPage() {
98
169
  return (
@@ -101,25 +172,135 @@ function WidgetCalendarDocsPage() {
101
172
  <Subtitle />
102
173
  <Markdown>{DESCRIPTION_INTRO}</Markdown>
103
174
  <DocHeading>Interactive example</DocHeading>
175
+ <Markdown>{PROPS_INTRO}</Markdown>
104
176
  <DocPrimary />
105
177
  <Controls />
178
+ <DocHeading>Usage guidance</DocHeading>
179
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
180
+ <DocHeading>Developer notes</DocHeading>
181
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
106
182
  <DocHeading>Examples</DocHeading>
107
183
  <Stories title="" />
184
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
108
185
  </>
109
186
  );
110
187
  }
111
188
 
112
- const withDescription = (story: StoryObj, description: string): StoryObj => ({
189
+ // ---------------------------------------------------------------------------
190
+ // Meta
191
+ // ---------------------------------------------------------------------------
192
+
193
+ const meta = {
194
+ title: 'Components/Widget/WidgetCalendar',
195
+ component: WidgetCalendar,
196
+ parameters: {
197
+ layout: 'padded',
198
+ docs: {
199
+ page: WidgetCalendarDocsPage,
200
+ },
201
+ },
202
+ tags: ['autodocs'],
203
+ argTypes: {
204
+ heading: {
205
+ control: 'text',
206
+ description: 'Content rendered in the widget header. Usually a plain string, but accepts any `React.ReactNode`.',
207
+ table: {
208
+ type: { summary: 'React.ReactNode' },
209
+ },
210
+ },
211
+ tooltip: {
212
+ control: 'text',
213
+ description: 'Optional tooltip text shown alongside the heading, for supplementary context.',
214
+ table: {
215
+ type: { summary: 'string' },
216
+ },
217
+ },
218
+ headingHref: {
219
+ control: 'text',
220
+ description: 'When provided, renders the heading as a link to this URL (e.g. a full calendar page).',
221
+ table: {
222
+ type: { summary: 'string' },
223
+ },
224
+ },
225
+ items: {
226
+ control: false,
227
+ description: [
228
+ 'Array of `WidgetCalendar.ItemData` objects rendered as event rows. Each item requires `id`,',
229
+ '`eventPrimaryTerm`, `eventPrimaryDefinition`, `eventSecondaryTerm`, `eventDateTime`, and `eventTimeLabel`.',
230
+ '`eventType`, `eventLocation`, `eventSecondaryDefinition`, and `buttonA11yText` are optional.',
231
+ ].join(' '),
232
+ table: {
233
+ type: { summary: 'WidgetCalendar.ItemData[]' },
234
+ },
235
+ },
236
+ isFloating: {
237
+ control: 'boolean',
238
+ description: 'Applies the floating (elevated/shadowed) widget style. Defaults to `false`.',
239
+ table: {
240
+ type: { summary: 'boolean' },
241
+ defaultValue: { summary: 'false' },
242
+ },
243
+ },
244
+ onItemClick: {
245
+ control: false,
246
+ description: [
247
+ 'Called with the clicked item\'s `id` when a row is activated (click, Enter, or Space).',
248
+ 'Rows with no `onItemClick` render as plain, non-interactive list items.',
249
+ ].join(' '),
250
+ table: {
251
+ type: { summary: '(id: string) => void' },
252
+ },
253
+ },
254
+ emptyState: {
255
+ control: false,
256
+ description: [
257
+ 'Custom content rendered in place of the event list when `items` is empty.',
258
+ 'Defaults to a built-in EmptyState with a date icon, "Nothing scheduled" heading, and',
259
+ '"There are no upcoming events." sub-heading.',
260
+ ].join(' '),
261
+ table: {
262
+ type: { summary: 'React.ReactNode' },
263
+ defaultValue: { summary: 'Built-in EmptyState ("Nothing scheduled")' },
264
+ },
265
+ },
266
+ className: {
267
+ control: false,
268
+ description: 'Additional CSS class names on the root Widget element. Use sparingly.',
269
+ table: {
270
+ type: { summary: 'string' },
271
+ },
272
+ },
273
+ },
274
+ } satisfies Meta<typeof WidgetCalendar>;
275
+
276
+ export default meta;
277
+ // Use StoryObj<typeof WidgetCalendar> (not typeof meta) so render-only stories are
278
+ // not forced to provide required args — template components handle their own instances.
279
+ type Story = StoryObj<typeof WidgetCalendar>;
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Helper: attach a per-story description to docs
283
+ // ---------------------------------------------------------------------------
284
+
285
+ const withDescription = (story: Story, description: string): Story => ({
113
286
  ...story,
114
287
  parameters: {
115
288
  ...story.parameters,
116
289
  docs: {
117
290
  ...story.parameters?.docs,
118
- description: { story: description },
291
+ description: {
292
+ story: description,
293
+ },
119
294
  },
120
295
  },
121
296
  });
122
297
 
298
+ // ---------------------------------------------------------------------------
299
+ // Template components for composition and stateful stories.
300
+ // Named components avoid react-hooks lint issues — the react-hooks ESLint
301
+ // plugin is NOT configured in this project, so do NOT add eslint-disable.
302
+ // ---------------------------------------------------------------------------
303
+
123
304
  function FixedHeightWidgetContainer({
124
305
  children,
125
306
  height = 280,
@@ -141,101 +322,393 @@ function FixedHeightWidgetContainer({
141
322
  );
142
323
  }
143
324
 
144
- const DefaultTemplate = () => (
145
- <WidgetCalendar
146
- heading="Calendar"
147
- headingHref="/calendar"
148
- isFloating
149
- items={initialItems}
150
- onItemClick={fn()}
151
- />
325
+ const InteractiveTemplate = () => {
326
+ const [lastClickedId, setLastClickedId] = useState<string | null>(null);
327
+
328
+ const lastClickedLabel = lastClickedId
329
+ ? initialItems.find(item => item.id === lastClickedId)?.eventPrimaryDefinition
330
+ : null;
331
+
332
+ return (
333
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
334
+ <WidgetCalendar
335
+ heading="Calendar"
336
+ isFloating
337
+ items={initialItems}
338
+ onItemClick={setLastClickedId}
339
+ />
340
+ <span className="ds-text" style={{ color: 'var(--color-grey-600)' }}>
341
+ {lastClickedLabel
342
+ ? (
343
+ <>
344
+ Last clicked:
345
+ {' '}
346
+ {lastClickedLabel}
347
+ </>
348
+ )
349
+ : 'Click an event to see its id captured here.'}
350
+ </span>
351
+ </div>
352
+ );
353
+ };
354
+
355
+ const handleComposableItemClick = fn();
356
+
357
+ const ComposableTemplate = () => (
358
+ <Widget isFloating>
359
+ <Widget.Header>Calendar</Widget.Header>
360
+ <Widget.Body>
361
+ <ul className="ds-widget-calendar__list">
362
+ {initialItems.slice(0, 2).map(item => (
363
+ <WidgetCalendar.Item
364
+ key={item.id}
365
+ id={item.id}
366
+ eventType={item.eventType}
367
+ eventPrimaryTerm={item.eventPrimaryTerm}
368
+ eventPrimaryDefinition={item.eventPrimaryDefinition}
369
+ eventSecondaryTerm={item.eventSecondaryTerm}
370
+ eventDateTime={item.eventDateTime}
371
+ eventTimeLabel={item.eventTimeLabel}
372
+ eventLocation={item.eventLocation}
373
+ onItemClick={() => handleComposableItemClick(item.id)}
374
+ />
375
+ ))}
376
+ </ul>
377
+ </Widget.Body>
378
+ </Widget>
152
379
  );
153
380
 
154
- const EmptyTemplate = () => (
155
- <WidgetCalendar heading="Calendar" isFloating items={[]} />
381
+ // ---------------------------------------------------------------------------
382
+ // Stories
383
+ // ---------------------------------------------------------------------------
384
+
385
+ export const Default: Story = withDescription(
386
+ {
387
+ args: {
388
+ heading: 'Calendar',
389
+ headingHref: '/calendar',
390
+ isFloating: true,
391
+ items: initialItems,
392
+ onItemClick: fn(),
393
+ },
394
+ render: args => <WidgetCalendar {...args} />,
395
+ },
396
+ [
397
+ 'The interactive canvas — every prop is wired to the Controls panel below.',
398
+ 'A linked header, floating style, a realistic mix of primary (green) and secondary (grey) event types,',
399
+ 'and clickable rows. This is the only story where combining every prop together is the point.',
400
+ ].join(' '),
401
+ );
402
+
403
+ export const Interactive: Story = withDescription(
404
+ {
405
+ render: InteractiveTemplate,
406
+ parameters: {
407
+ controls: { disable: true },
408
+ docs: {
409
+ source: {
410
+ language: 'tsx',
411
+ code: `
412
+ import { useState } from 'react';
413
+ import { WidgetCalendar } from '@arbor-education/design-system.components';
414
+
415
+ function InteractiveCalendarExample() {
416
+ const [lastClickedId, setLastClickedId] = useState<string | null>(null);
417
+
418
+ return (
419
+ <>
420
+ <WidgetCalendar
421
+ heading="Calendar"
422
+ isFloating
423
+ items={items}
424
+ onItemClick={setLastClickedId}
425
+ />
426
+ <span>Last clicked id: {lastClickedId ?? 'none yet'}</span>
427
+ </>
428
+ );
429
+ }
430
+ export default InteractiveCalendarExample;
431
+ `.trim(),
432
+ },
433
+ },
434
+ },
435
+ },
436
+ [
437
+ 'Demonstrates `onItemClick(id)` behaviourally: clicking a row calls back with that item\'s `id`,',
438
+ 'tracked here in a named template component\'s `useState` and displayed below the widget as',
439
+ '"Last clicked: {label}". In a real consumer, use the id to navigate or open a detail view.',
440
+ ].join(' '),
156
441
  );
157
442
 
158
- const FixedHeightOverflowTemplate = () => (
159
- <FixedHeightWidgetContainer>
160
- <WidgetCalendar
161
- heading="Calendar"
162
- isFloating
163
- items={overflowItems}
164
- onItemClick={fn()}
165
- />
166
- </FixedHeightWidgetContainer>
443
+ export const PrimaryEventType: Story = withDescription(
444
+ {
445
+ args: {
446
+ heading: 'Calendar',
447
+ items: [
448
+ {
449
+ id: 'year-10-10g',
450
+ eventType: 'primary',
451
+ eventPrimaryTerm: 'Class',
452
+ eventPrimaryDefinition: 'Year 10: 10G',
453
+ ...classEventTime,
454
+ },
455
+ {
456
+ id: 'maths-year-10',
457
+ eventType: 'primary',
458
+ eventPrimaryTerm: 'Class',
459
+ eventPrimaryDefinition: 'Maths: Year 10: 10x/Ma3',
460
+ ...classEventTime,
461
+ },
462
+ ],
463
+ onItemClick: fn(),
464
+ },
465
+ render: args => <WidgetCalendar {...args} />,
466
+ parameters: { controls: { disable: true } },
467
+ },
468
+ [
469
+ 'Isolates `eventType="primary"` — the brand-green type bar. This is also the default when',
470
+ '`eventType` is omitted entirely.',
471
+ ].join(' '),
167
472
  );
168
473
 
169
- const meta = {
170
- title: 'Components/Widget/WidgetCalendar',
171
- component: WidgetCalendar,
172
- parameters: {
173
- layout: 'padded',
174
- docs: { page: WidgetCalendarDocsPage },
474
+ export const SecondaryEventType: Story = withDescription(
475
+ {
476
+ args: {
477
+ heading: 'Calendar',
478
+ items: [
479
+ {
480
+ id: 'class-generic',
481
+ eventType: 'secondary',
482
+ eventPrimaryTerm: 'Class',
483
+ eventPrimaryDefinition: 'Class',
484
+ ...classEventTime,
485
+ },
486
+ {
487
+ id: 'class-generic-2',
488
+ eventType: 'secondary',
489
+ eventPrimaryTerm: 'Class',
490
+ eventPrimaryDefinition: 'Class',
491
+ eventDateTime: '2026-06-08T10:15/2026-06-08T11:00',
492
+ eventTimeLabel: '10:15 – 11:00',
493
+ eventLocation: 'Hall',
494
+ eventSecondaryTerm: 'Time and location',
495
+ },
496
+ ],
497
+ onItemClick: fn(),
498
+ },
499
+ render: args => <WidgetCalendar {...args} />,
500
+ parameters: { controls: { disable: true } },
175
501
  },
176
- tags: ['autodocs'],
177
- args: {
178
- heading: 'Calendar',
179
- isFloating: true,
180
- items: initialItems,
181
- onItemClick: fn(),
502
+ 'Isolates `eventType="secondary"` — the neutral grey type bar, used for lower-priority or generic events.',
503
+ );
504
+
505
+ export const NonInteractiveItems: Story = withDescription(
506
+ {
507
+ args: {
508
+ heading: 'Calendar',
509
+ items: [
510
+ {
511
+ id: 'year-10-10g',
512
+ eventType: 'primary',
513
+ eventPrimaryTerm: 'Class',
514
+ eventPrimaryDefinition: 'Year 10: 10G',
515
+ ...classEventTime,
516
+ },
517
+ {
518
+ id: 'maths-year-10',
519
+ eventType: 'secondary',
520
+ eventPrimaryTerm: 'Class',
521
+ eventPrimaryDefinition: 'Maths: Year 10: 10x/Ma3',
522
+ ...classEventTime,
523
+ },
524
+ ],
525
+ },
526
+ render: args => <WidgetCalendar {...args} />,
527
+ parameters: { controls: { disable: true } },
182
528
  },
183
- } satisfies Meta<typeof WidgetCalendar>;
529
+ [
530
+ 'No `onItemClick` is passed. Rows render as plain, non-interactive `<li>` elements —',
531
+ 'no `role="button"`, no `tabIndex`, no hover chevron-to-arrow swap. The absence of interactivity',
532
+ 'is exactly what this story demonstrates.',
533
+ ].join(' '),
534
+ );
184
535
 
185
- export default meta;
536
+ export const WithoutLocation: Story = withDescription(
537
+ {
538
+ args: {
539
+ heading: 'Calendar',
540
+ items: [
541
+ {
542
+ id: 'staff-briefing',
543
+ eventType: 'primary',
544
+ eventPrimaryTerm: 'Meeting',
545
+ eventPrimaryDefinition: 'Staff briefing',
546
+ eventSecondaryTerm: 'Time',
547
+ eventDateTime: '2026-06-08T08:00/2026-06-08T08:15',
548
+ eventTimeLabel: '08:00 – 08:15',
549
+ },
550
+ ],
551
+ onItemClick: fn(),
552
+ },
553
+ render: args => <WidgetCalendar {...args} />,
554
+ parameters: { controls: { disable: true } },
555
+ },
556
+ [
557
+ '`eventLocation` is omitted and `eventSecondaryDefinition` is not set, so the secondary line',
558
+ 'renders as time-only — no trailing comma fragment where the location would otherwise appear.',
559
+ ].join(' '),
560
+ );
186
561
 
187
- type Story = StoryObj<typeof meta>;
562
+ export const CustomSecondaryContent: Story = withDescription(
563
+ {
564
+ args: {
565
+ heading: 'Calendar',
566
+ items: [
567
+ {
568
+ id: 'exam-window',
569
+ eventType: 'primary',
570
+ eventPrimaryTerm: 'Exam',
571
+ eventPrimaryDefinition: 'GCSE Maths: Paper 2',
572
+ eventSecondaryTerm: 'Details',
573
+ eventDateTime: '2026-06-08T09:00/2026-06-08T10:30',
574
+ eventTimeLabel: '09:00 – 10:30',
575
+ eventSecondaryDefinition: 'Silent conditions — no calculators permitted',
576
+ },
577
+ ],
578
+ onItemClick: fn(),
579
+ },
580
+ render: args => <WidgetCalendar {...args} />,
581
+ parameters: { controls: { disable: true } },
582
+ },
583
+ [
584
+ '`eventSecondaryDefinition` replaces the default time + location line entirely with custom content.',
585
+ 'Note that `eventLocation` is deliberately not passed alongside it here — the two are an all-or-nothing',
586
+ 'pair, and passing both would silently drop the location, which would be misleading to show in a story.',
587
+ ].join(' '),
588
+ );
188
589
 
189
- export const Default: Story = withDescription(
590
+ export const WithTooltip: Story = withDescription(
190
591
  {
191
- render: DefaultTemplate,
592
+ args: {
593
+ heading: 'Calendar',
594
+ tooltip: 'Shows your next 5 scheduled events',
595
+ items: [
596
+ {
597
+ id: 'year-10-10g',
598
+ eventType: 'primary',
599
+ eventPrimaryTerm: 'Class',
600
+ eventPrimaryDefinition: 'Year 10: 10G',
601
+ ...classEventTime,
602
+ },
603
+ ],
604
+ onItemClick: fn(),
605
+ },
606
+ render: args => <WidgetCalendar {...args} />,
192
607
  parameters: { controls: { disable: true } },
193
608
  },
194
- 'Linked header with primary (green) and secondary (grey) event type indicators. Rows are keyboard-focusable when `onItemClick` is provided.',
609
+ 'Adding `tooltip` shows supplementary context alongside the heading. A single event keeps the focus on the tooltip.',
195
610
  );
196
611
 
197
612
  export const Empty: Story = withDescription(
198
613
  {
199
- render: EmptyTemplate,
614
+ args: {
615
+ heading: 'Calendar',
616
+ items: [],
617
+ },
618
+ render: args => <WidgetCalendar {...args} />,
200
619
  parameters: { controls: { disable: true } },
201
620
  },
202
- 'Empty items array shows the default EmptyState inside the Widget body.',
621
+ 'An empty `items` array shows the default built-in EmptyState — a date icon, "Nothing scheduled", and "There are no upcoming events."',
203
622
  );
204
623
 
205
- export const FixedHeightOverflow: Story = withDescription(
624
+ export const CustomEmptyState: Story = withDescription(
206
625
  {
207
- render: FixedHeightOverflowTemplate,
626
+ args: {
627
+ heading: 'Calendar',
628
+ items: [],
629
+ emptyState: (
630
+ <div style={{ padding: 'var(--spacing-large)', textAlign: 'center' }}>
631
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-grey-600)' }}>
632
+ No events for the rest of term. Enjoy the break!
633
+ </p>
634
+ </div>
635
+ ),
636
+ },
637
+ render: args => <WidgetCalendar {...args} />,
208
638
  parameters: { controls: { disable: true } },
209
639
  },
210
- 'Parent container constrains height; the header stays pinned while the event list scrolls inside Widget.Body.',
640
+ 'Overrides the default EmptyState with custom content via the `emptyState` prop when the default copy does not fit the context.',
211
641
  );
212
642
 
213
- export const Composable: Story = withDescription(
643
+ export const FixedHeightOverflow: Story = withDescription(
214
644
  {
215
- render: () => (
216
- <Widget isFloating>
217
- <Widget.Header>Calendar</Widget.Header>
218
- <Widget.Body>
219
- <ul className="ds-widget-calendar__list">
220
- {initialItems.slice(0, 2).map(item => (
221
- <WidgetCalendar.Item
222
- key={item.id}
223
- id={item.id}
224
- eventType={item.eventType}
225
- eventPrimaryTerm={item.eventPrimaryTerm}
226
- eventPrimaryDefinition={item.eventPrimaryDefinition}
227
- eventSecondaryTerm={item.eventSecondaryTerm}
228
- eventDateTime={item.eventDateTime}
229
- eventTimeLabel={item.eventTimeLabel}
230
- eventLocation={item.eventLocation}
231
- onItemClick={fn()}
232
- />
233
- ))}
234
- </ul>
235
- </Widget.Body>
236
- </Widget>
645
+ args: {
646
+ heading: 'Calendar',
647
+ items: overflowItems,
648
+ onItemClick: fn(),
649
+ },
650
+ render: args => (
651
+ <FixedHeightWidgetContainer>
652
+ <WidgetCalendar {...args} />
653
+ </FixedHeightWidgetContainer>
237
654
  ),
238
655
  parameters: { controls: { disable: true } },
239
656
  },
240
- 'Compose manually with `WidgetCalendar.Item` inside `Widget.Body` when you need custom list logic beyond the `items` prop.',
657
+ [
658
+ 'A parent container with a fixed 280px height constrains the widget — the header stays pinned while',
659
+ 'the 14-item event list scrolls inside `Widget.Body`. The dashed border marks the constraining wrapper,',
660
+ 'not part of the widget itself.',
661
+ ].join(' '),
662
+ );
663
+
664
+ export const Composable: Story = withDescription(
665
+ {
666
+ render: ComposableTemplate,
667
+ parameters: {
668
+ controls: { disable: true },
669
+ docs: {
670
+ source: {
671
+ language: 'tsx',
672
+ code: `
673
+ import { Widget, WidgetCalendar } from '@arbor-education/design-system.components';
674
+
675
+ function ComposableCalendarExample() {
676
+ return (
677
+ <Widget isFloating>
678
+ <Widget.Header>Calendar</Widget.Header>
679
+ <Widget.Body>
680
+ <ul className="ds-widget-calendar__list">
681
+ {items.map(item => (
682
+ <WidgetCalendar.Item
683
+ key={item.id}
684
+ id={item.id}
685
+ eventType={item.eventType}
686
+ eventPrimaryTerm={item.eventPrimaryTerm}
687
+ eventPrimaryDefinition={item.eventPrimaryDefinition}
688
+ eventSecondaryTerm={item.eventSecondaryTerm}
689
+ eventDateTime={item.eventDateTime}
690
+ eventTimeLabel={item.eventTimeLabel}
691
+ eventLocation={item.eventLocation}
692
+ // WidgetCalendar.Item's onItemClick takes NO id argument, unlike
693
+ // WidgetCalendar's own onItemClick — close over the id yourself.
694
+ onItemClick={() => handleItemClick(item.id)}
695
+ />
696
+ ))}
697
+ </ul>
698
+ </Widget.Body>
699
+ </Widget>
700
+ );
701
+ }
702
+ export default ComposableCalendarExample;
703
+ `.trim(),
704
+ },
705
+ },
706
+ },
707
+ },
708
+ [
709
+ 'Composes `Widget` and `WidgetCalendar.Item` manually — bypassing the `items` prop entirely — to prove',
710
+ '`WidgetCalendar.Item` is genuine public API for custom list logic. Note the closure: `WidgetCalendar.Item`\'s',
711
+ 'own `onItemClick` takes no id argument, so each item must close over its own `id` (`() => handleClick(item.id)`),',
712
+ 'unlike the parent `WidgetCalendar`\'s `onItemClick(id)`.',
713
+ ].join(' '),
241
714
  );