@arbor-education/design-system.components 4.0.0-react19-alpha.0 → 4.1.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 (141) hide show
  1. package/.github/workflows/pr-review-approve.yaml +2 -1
  2. package/CHANGELOG.md +28 -0
  3. package/dist/components/formField/FormField.d.ts +4 -0
  4. package/dist/components/formField/FormField.d.ts.map +1 -1
  5. package/dist/components/formField/FormField.js +2 -1
  6. package/dist/components/formField/FormField.js.map +1 -1
  7. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  8. package/dist/components/formField/FormField.stories.js +10 -7
  9. package/dist/components/formField/FormField.stories.js.map +1 -1
  10. package/dist/components/formField/FormField.test.js +14 -0
  11. package/dist/components/formField/FormField.test.js.map +1 -1
  12. package/dist/components/formField/inputs/singleFile/SingleFileInput.d.ts +14 -0
  13. package/dist/components/formField/inputs/singleFile/SingleFileInput.d.ts.map +1 -0
  14. package/dist/components/formField/inputs/singleFile/SingleFileInput.js +35 -0
  15. package/dist/components/formField/inputs/singleFile/SingleFileInput.js.map +1 -0
  16. package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.d.ts +170 -0
  17. package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.d.ts.map +1 -0
  18. package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.js +242 -0
  19. package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.js.map +1 -0
  20. package/dist/components/formField/inputs/singleFile/SingleFileInput.test.d.ts +2 -0
  21. package/dist/components/formField/inputs/singleFile/SingleFileInput.test.d.ts.map +1 -0
  22. package/dist/components/formField/inputs/singleFile/SingleFileInput.test.js +135 -0
  23. package/dist/components/formField/inputs/singleFile/SingleFileInput.test.js.map +1 -0
  24. package/dist/components/widget/types.d.ts +9 -0
  25. package/dist/components/widget/types.d.ts.map +1 -1
  26. package/dist/components/widgetCalendar/WidgetCalendar.d.ts +5 -2
  27. package/dist/components/widgetCalendar/WidgetCalendar.d.ts.map +1 -1
  28. package/dist/components/widgetCalendar/WidgetCalendar.js +30 -5
  29. package/dist/components/widgetCalendar/WidgetCalendar.js.map +1 -1
  30. package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts +20 -2
  31. package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts.map +1 -1
  32. package/dist/components/widgetCalendar/WidgetCalendar.stories.js +269 -2
  33. package/dist/components/widgetCalendar/WidgetCalendar.stories.js.map +1 -1
  34. package/dist/components/widgetCalendar/WidgetCalendar.test.js +179 -2
  35. package/dist/components/widgetCalendar/WidgetCalendar.test.js.map +1 -1
  36. package/dist/components/widgetCalendar/WidgetCalendarItem.d.ts +1 -0
  37. package/dist/components/widgetCalendar/WidgetCalendarItem.d.ts.map +1 -1
  38. package/dist/components/widgetCalendar/WidgetCalendarItem.js +7 -2
  39. package/dist/components/widgetCalendar/WidgetCalendarItem.js.map +1 -1
  40. package/dist/components/widgetFavourites/WidgetFavourites.d.ts.map +1 -1
  41. package/dist/components/widgetFavourites/WidgetFavourites.js +5 -4
  42. package/dist/components/widgetFavourites/WidgetFavourites.js.map +1 -1
  43. package/dist/components/widgetFavourites/WidgetFavourites.stories.d.ts +3 -0
  44. package/dist/components/widgetFavourites/WidgetFavourites.stories.d.ts.map +1 -1
  45. package/dist/components/widgetFavourites/WidgetFavourites.stories.js +157 -0
  46. package/dist/components/widgetFavourites/WidgetFavourites.stories.js.map +1 -1
  47. package/dist/components/widgetFavourites/WidgetFavourites.test.js +41 -1
  48. package/dist/components/widgetFavourites/WidgetFavourites.test.js.map +1 -1
  49. package/dist/components/widgetFavourites/WidgetFavouritesItem.d.ts.map +1 -1
  50. package/dist/components/widgetFavourites/WidgetFavouritesItem.js +2 -2
  51. package/dist/components/widgetFavourites/WidgetFavouritesItem.js.map +1 -1
  52. package/dist/components/widgetFavourites/types.d.ts +4 -2
  53. package/dist/components/widgetFavourites/types.d.ts.map +1 -1
  54. package/dist/index.css +34 -0
  55. package/dist/index.css.map +1 -1
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +1 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/packageManifest.test.d.ts +2 -0
  61. package/dist/packageManifest.test.d.ts.map +1 -0
  62. package/dist/packageManifest.test.js +22 -0
  63. package/dist/packageManifest.test.js.map +1 -0
  64. package/package.json +1 -1
  65. package/src/components/formField/FormField.stories.tsx +10 -7
  66. package/src/components/formField/FormField.test.tsx +35 -0
  67. package/src/components/formField/FormField.tsx +5 -0
  68. package/src/components/formField/inputs/singleFile/SingleFileInput.stories.tsx +339 -0
  69. package/src/components/formField/inputs/singleFile/SingleFileInput.test.tsx +181 -0
  70. package/src/components/formField/inputs/singleFile/SingleFileInput.tsx +101 -0
  71. package/src/components/formField/inputs/singleFile/singleFileInput.scss +17 -0
  72. package/src/components/widget/types.ts +13 -0
  73. package/src/components/widgetCalendar/WidgetCalendar.stories.tsx +310 -2
  74. package/src/components/widgetCalendar/WidgetCalendar.test.tsx +315 -1
  75. package/src/components/widgetCalendar/WidgetCalendar.tsx +77 -31
  76. package/src/components/widgetCalendar/WidgetCalendarItem.tsx +14 -1
  77. package/src/components/widgetCalendar/widgetCalendar.scss +9 -0
  78. package/src/components/widgetFavourites/WidgetFavourites.stories.tsx +217 -0
  79. package/src/components/widgetFavourites/WidgetFavourites.test.tsx +99 -0
  80. package/src/components/widgetFavourites/WidgetFavourites.tsx +6 -2
  81. package/src/components/widgetFavourites/WidgetFavouritesItem.tsx +2 -1
  82. package/src/components/widgetFavourites/types.ts +5 -2
  83. package/src/components/widgetFavourites/widgetFavourites.scss +7 -0
  84. package/src/index.scss +1 -0
  85. package/src/index.ts +1 -0
  86. package/src/packageManifest.test.ts +43 -0
  87. package/src/tokens.scss +1 -0
  88. package/tokens/json/Arbor.json +4 -0
  89. package/.changeset/empty-clocks-jog.md +0 -5
  90. package/dist/components/combobox/useElementWidth.d.ts +0 -2
  91. package/dist/components/combobox/useElementWidth.d.ts.map +0 -1
  92. package/dist/components/combobox/useElementWidth.js +0 -31
  93. package/dist/components/combobox/useElementWidth.js.map +0 -1
  94. package/dist/components/combobox/useVisibleChips.d.ts +0 -21
  95. package/dist/components/combobox/useVisibleChips.d.ts.map +0 -1
  96. package/dist/components/combobox/useVisibleChips.js +0 -59
  97. package/dist/components/combobox/useVisibleChips.js.map +0 -1
  98. package/dist/components/combobox/useVisibleChips.test.d.ts +0 -2
  99. package/dist/components/combobox/useVisibleChips.test.d.ts.map +0 -1
  100. package/dist/components/combobox/useVisibleChips.test.js +0 -81
  101. package/dist/components/combobox/useVisibleChips.test.js.map +0 -1
  102. package/dist/components/icoText/IcoText.d.ts +0 -37
  103. package/dist/components/icoText/IcoText.d.ts.map +0 -1
  104. package/dist/components/icoText/IcoText.js +0 -29
  105. package/dist/components/icoText/IcoText.js.map +0 -1
  106. package/dist/components/icoText/IcoText.stories.d.ts +0 -60
  107. package/dist/components/icoText/IcoText.stories.d.ts.map +0 -1
  108. package/dist/components/icoText/IcoText.stories.js +0 -319
  109. package/dist/components/icoText/IcoText.stories.js.map +0 -1
  110. package/dist/components/icoText/IcoText.test.d.ts +0 -2
  111. package/dist/components/icoText/IcoText.test.d.ts.map +0 -1
  112. package/dist/components/icoText/IcoText.test.js +0 -27
  113. package/dist/components/icoText/IcoText.test.js.map +0 -1
  114. package/dist/components/tree-row/TreeRow.d.ts +0 -32
  115. package/dist/components/tree-row/TreeRow.d.ts.map +0 -1
  116. package/dist/components/tree-row/TreeRow.js +0 -19
  117. package/dist/components/tree-row/TreeRow.js.map +0 -1
  118. package/dist/components/tree-row/TreeRow.stories.d.ts +0 -13
  119. package/dist/components/tree-row/TreeRow.stories.d.ts.map +0 -1
  120. package/dist/components/tree-row/TreeRow.stories.js +0 -774
  121. package/dist/components/tree-row/TreeRow.stories.js.map +0 -1
  122. package/dist/components/tree-row/TreeRow.test.d.ts +0 -2
  123. package/dist/components/tree-row/TreeRow.test.d.ts.map +0 -1
  124. package/dist/components/tree-row/TreeRow.test.js +0 -262
  125. package/dist/components/tree-row/TreeRow.test.js.map +0 -1
  126. package/dist/components/tree-row/TreeRowSection.d.ts +0 -12
  127. package/dist/components/tree-row/TreeRowSection.d.ts.map +0 -1
  128. package/dist/components/tree-row/TreeRowSection.js +0 -20
  129. package/dist/components/tree-row/TreeRowSection.js.map +0 -1
  130. package/dist/utils/PopupParentContext.d.ts +0 -3
  131. package/dist/utils/PopupParentContext.d.ts.map +0 -1
  132. package/dist/utils/PopupParentContext.js +0 -6
  133. package/dist/utils/PopupParentContext.js.map +0 -1
  134. package/dist/utils/PopupParentContext.stories.d.ts +0 -17
  135. package/dist/utils/PopupParentContext.stories.d.ts.map +0 -1
  136. package/dist/utils/PopupParentContext.stories.js +0 -266
  137. package/dist/utils/PopupParentContext.stories.js.map +0 -1
  138. package/dist/utils/getDefaultPopupParent.d.ts +0 -2
  139. package/dist/utils/getDefaultPopupParent.d.ts.map +0 -1
  140. package/dist/utils/getDefaultPopupParent.js +0 -19
  141. package/dist/utils/getDefaultPopupParent.js.map +0 -1
@@ -93,6 +93,73 @@ const overflowItems: WidgetCalendar.ItemData[] = [
93
93
  ...initialItems.map((item, index) => ({ ...item, id: `${item.id}-overflow-${index}` })),
94
94
  ];
95
95
 
96
+ const groupedItems: WidgetCalendar.ItemData[] = [
97
+ {
98
+ id: 'staff-briefing-grouped',
99
+ eventType: 'primary',
100
+ eventPrimaryTerm: 'Meeting',
101
+ eventPrimaryDefinition: 'Staff briefing',
102
+ eventSecondaryTerm: 'Time',
103
+ eventDateTime: '2026-06-08T08:00/2026-06-08T08:15',
104
+ eventTimeLabel: '08:00 – 08:15',
105
+ group: 'Monday 8 June',
106
+ },
107
+ {
108
+ id: 'year-10-10g-grouped',
109
+ eventType: 'primary',
110
+ eventPrimaryTerm: 'Class',
111
+ eventPrimaryDefinition: 'Year 10: 10G',
112
+ eventDateTime: '2026-06-08T08:45/2026-06-08T09:00',
113
+ eventTimeLabel: '08:45 – 09:00',
114
+ eventLocation: 'Classroom 102',
115
+ eventSecondaryTerm: 'Time and location',
116
+ group: 'Monday 8 June',
117
+ },
118
+ {
119
+ id: 'maths-year-10-grouped',
120
+ eventType: 'primary',
121
+ eventPrimaryTerm: 'Class',
122
+ eventPrimaryDefinition: 'Maths: Year 10: 10x/Ma3',
123
+ eventDateTime: '2026-06-08T11:15/2026-06-08T12:00',
124
+ eventTimeLabel: '11:15 – 12:00',
125
+ eventLocation: 'Classroom 102',
126
+ eventSecondaryTerm: 'Time and location',
127
+ group: 'Monday 8 June',
128
+ },
129
+ {
130
+ id: 'assembly-grouped',
131
+ eventType: 'secondary',
132
+ eventPrimaryTerm: 'Event',
133
+ eventPrimaryDefinition: 'Whole-school assembly',
134
+ eventDateTime: '2026-06-09T09:00/2026-06-09T09:30',
135
+ eventTimeLabel: '09:00 – 09:30',
136
+ eventLocation: 'Hall',
137
+ eventSecondaryTerm: 'Time and location',
138
+ group: 'Tuesday 9 June',
139
+ },
140
+ {
141
+ id: 'parents-evening-grouped',
142
+ eventType: 'primary',
143
+ eventPrimaryTerm: 'Meeting',
144
+ eventPrimaryDefinition: 'Parents’ evening — Year 9',
145
+ eventDateTime: '2026-06-09T16:00/2026-06-09T19:00',
146
+ eventTimeLabel: '16:00 – 19:00',
147
+ eventLocation: 'Main hall',
148
+ eventSecondaryTerm: 'Time and location',
149
+ group: 'Tuesday 9 June',
150
+ },
151
+ {
152
+ id: 'detention-roster-ungrouped',
153
+ eventType: 'secondary',
154
+ eventPrimaryTerm: 'Event',
155
+ eventPrimaryDefinition: 'Detention roster review',
156
+ eventDateTime: '2026-06-10T15:00/2026-06-10T15:30',
157
+ eventTimeLabel: '15:00 – 15:30',
158
+ eventSecondaryTerm: 'Time',
159
+ // Deliberately no group — exercises the ungrouped-item fallback path
160
+ },
161
+ ];
162
+
96
163
  // ---------------------------------------------------------------------------
97
164
  // Docs page content
98
165
  // ---------------------------------------------------------------------------
@@ -140,12 +207,23 @@ const DEVELOPER_NOTES = [
140
207
  'default time + location composition — `eventLocation` becomes dead data with no warning. Never pass both',
141
208
  'in the same item.',
142
209
  '',
210
+ '**`group` is an arbitrary display label, not a date.** It is the pre-formatted text rendered as the',
211
+ 'group subheading when `groupItems` groups items (e.g. `"Monday 8 June"` — but it could equally be a',
212
+ 'location or an event type). The component performs no formatting of its own — all label formatting and',
213
+ 'sorting is delegated to the consumer, so pass the exact label you want displayed. Items without a `group`',
214
+ 'fall into the ungrouped bucket, which renders above all groups to prevent ambiguity about which group',
215
+ 'an item belongs to. Groups appear in the order their labels are first declared.',
216
+ '',
143
217
  '**The type-bar colour is semantic, not decorative.** `eventType="primary"` renders the brand-green bar',
144
218
  '(`--widget-calendar-type-primary-color`), `"secondary"` renders neutral grey',
145
219
  '(`--widget-calendar-type-secondary-color`). Choose deliberately — don\'t treat it as a purely visual toggle.',
146
220
  '',
147
221
  '**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).',
222
+ '(e.g. render a Skeleton in place of `WidgetCalendar` while data loads). `isLoading` and `loadingLabel` are',
223
+ 'deliberately not forwarded to `Widget`; consumers render loading content via `emptyState`.',
224
+ '',
225
+ '**`renderLink` and `linkProps` apply to the heading link only.** Both are forwarded to `Widget` only when',
226
+ '`headingHref` is set, and the types reject either prop without `headingHref`.',
149
227
  '',
150
228
  '---',
151
229
  '',
@@ -227,7 +305,10 @@ const meta = {
227
305
  description: [
228
306
  'Array of `WidgetCalendar.ItemData` objects rendered as event rows. Each item requires `id`,',
229
307
  '`eventPrimaryTerm`, `eventPrimaryDefinition`, `eventSecondaryTerm`, `eventDateTime`, and `eventTimeLabel`.',
230
- '`eventType`, `eventLocation`, `eventSecondaryDefinition`, and `buttonA11yText` are optional.',
308
+ '`eventType`, `eventLocation`, `eventSecondaryDefinition`, `buttonA11yText`, `tooltip`, and',
309
+ '`group` are optional. `group` is an arbitrary pre-formatted display label (e.g. `"Monday 8 June"`,',
310
+ 'a location, or an event type) used to group items under a shared subheading — the consumer owns',
311
+ 'all label formatting.',
231
312
  ].join(' '),
232
313
  table: {
233
314
  type: { summary: 'WidgetCalendar.ItemData[]' },
@@ -270,6 +351,19 @@ const meta = {
270
351
  type: { summary: 'string' },
271
352
  },
272
353
  },
354
+ groupItems: {
355
+ control: 'boolean',
356
+ description: [
357
+ 'When `true`, items are bucketed by their `group` display label and each group is',
358
+ 'rendered under a single subheading. Items with no `group` render first, above all',
359
+ 'grouped items, and groups appear in the order their labels are first declared. Defaults to `false`,',
360
+ 'so grouping is opt-in — omit the prop and `group` values are ignored entirely.',
361
+ ].join(' '),
362
+ table: {
363
+ type: { summary: 'boolean' },
364
+ defaultValue: { summary: 'false' },
365
+ },
366
+ },
273
367
  },
274
368
  } satisfies Meta<typeof WidgetCalendar>;
275
369
 
@@ -352,6 +446,48 @@ const InteractiveTemplate = () => {
352
446
  );
353
447
  };
354
448
 
449
+ // Modifier-clicks (cmd/ctrl/shift/alt) open new tabs or windows, so leave them to the browser.
450
+ const isModifiedClick = (event: React.MouseEvent<HTMLAnchorElement>) =>
451
+ event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
452
+
453
+ const handleHeadingLinkClick = fn().mockName('linkProps.onClick');
454
+
455
+ const WithHeadingLinkPropsTemplate = () => {
456
+ const [lastClickedHref, setLastClickedHref] = useState<string | null>(null);
457
+
458
+ return (
459
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
460
+ <WidgetCalendar
461
+ heading="Calendar"
462
+ headingHref="/calendar"
463
+ isFloating
464
+ items={initialItems.slice(0, 3)}
465
+ linkProps={{
466
+ onClick: (event) => {
467
+ handleHeadingLinkClick(event);
468
+ if (isModifiedClick(event)) {
469
+ return;
470
+ }
471
+ event.preventDefault();
472
+ setLastClickedHref(event.currentTarget.getAttribute('href'));
473
+ },
474
+ }}
475
+ />
476
+ <span className="ds-text" style={{ color: 'var(--color-grey-600)' }}>
477
+ {lastClickedHref
478
+ ? (
479
+ <>
480
+ Last clicked:
481
+ {' '}
482
+ {lastClickedHref}
483
+ </>
484
+ )
485
+ : 'Click the heading to see the handled navigation captured here.'}
486
+ </span>
487
+ </div>
488
+ );
489
+ };
490
+
355
491
  const handleComposableItemClick = fn();
356
492
 
357
493
  const ComposableTemplate = () => (
@@ -440,6 +576,56 @@ export default InteractiveCalendarExample;
440
576
  ].join(' '),
441
577
  );
442
578
 
579
+ export const WithHeadingLinkProps: Story = {
580
+ name: 'With heading linkProps',
581
+ ...withDescription(
582
+ {
583
+ render: WithHeadingLinkPropsTemplate,
584
+ parameters: {
585
+ controls: { disable: true },
586
+ docs: {
587
+ source: {
588
+ language: 'tsx',
589
+ code: `
590
+ import { WidgetCalendar } from '@arbor-education/design-system.components';
591
+
592
+ function CalendarHeadingLinkPropsExample() {
593
+ return (
594
+ <WidgetCalendar
595
+ heading="Calendar"
596
+ headingHref="/calendar"
597
+ isFloating
598
+ items={items}
599
+ linkProps={{
600
+ onClick: (event) => {
601
+ // Leave modifier-clicks (new tab/window) to the browser.
602
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
603
+ return;
604
+ }
605
+ event.preventDefault();
606
+ navigate('/calendar');
607
+ },
608
+ }}
609
+ />
610
+ );
611
+ }
612
+ export default CalendarHeadingLinkPropsExample;
613
+ `.trim(),
614
+ },
615
+ },
616
+ },
617
+ },
618
+ [
619
+ '**Demonstrates the `linkProps` prop** (forwarded to `Widget` when `headingHref` is set). `linkProps` is',
620
+ 'spread onto the heading `<a>`, so consumers can attach `onClick` for analytics or client-side navigation.',
621
+ 'Each click is logged to the Actions panel as `linkProps.onClick`. A plain click calls `preventDefault()`',
622
+ 'and shows the destination below the widget as "Last clicked: {href}"; modifier-clicks (cmd/ctrl/shift/alt)',
623
+ 'are left to the browser. For a custom link element (e.g. a router link) use `renderLink` instead, as in',
624
+ 'the `Widget` "With Render Link" story.',
625
+ ].join(' '),
626
+ ),
627
+ };
628
+
443
629
  export const PrimaryEventType: Story = withDescription(
444
630
  {
445
631
  args: {
@@ -609,6 +795,48 @@ export const WithTooltip: Story = withDescription(
609
795
  'Adding `tooltip` shows supplementary context alongside the heading. A single event keeps the focus on the tooltip.',
610
796
  );
611
797
 
798
+ export const WithItemTooltips: Story = withDescription(
799
+ {
800
+ args: {
801
+ heading: 'Calendar',
802
+ items: [
803
+ {
804
+ id: 'year-10-10g',
805
+ eventType: 'primary',
806
+ eventPrimaryTerm: 'Class',
807
+ eventPrimaryDefinition: 'Year 10: 10G',
808
+ tooltip: 'Room change — this lesson has moved from Classroom 204',
809
+ ...classEventTime,
810
+ },
811
+ {
812
+ id: 'class-generic',
813
+ eventType: 'secondary',
814
+ eventPrimaryTerm: 'Class',
815
+ eventPrimaryDefinition: 'Class',
816
+ ...classEventTime,
817
+ },
818
+ {
819
+ id: 'maths-year-10',
820
+ eventType: 'primary',
821
+ eventPrimaryTerm: 'Class',
822
+ eventPrimaryDefinition: 'Maths: Year 10: 10x/Ma3',
823
+ tooltip: 'Cover teacher: Mrs Stanhope',
824
+ ...classEventTime,
825
+ },
826
+ ],
827
+ onItemClick: fn(),
828
+ },
829
+ render: args => <WidgetCalendar {...args} />,
830
+ parameters: { controls: { disable: true } },
831
+ },
832
+ [
833
+ 'Per-item `tooltip` text, set on `ItemData`, shown on hover or focus of the row. Use it for',
834
+ 'short supplementary context that would clutter the row itself — a room change, a cover teacher.',
835
+ 'Items without a `tooltip` render exactly as before. Tooltips work on both interactive and',
836
+ 'non-interactive rows, and keyboard interaction (Enter/Space) is unaffected.',
837
+ ].join(' '),
838
+ );
839
+
612
840
  export const Empty: Story = withDescription(
613
841
  {
614
842
  args: {
@@ -712,3 +940,83 @@ export default ComposableCalendarExample;
712
940
  'unlike the parent `WidgetCalendar`\'s `onItemClick(id)`.',
713
941
  ].join(' '),
714
942
  );
943
+
944
+ export const Grouped: Story = withDescription(
945
+ {
946
+ args: {
947
+ heading: 'Calendar',
948
+ groupItems: true,
949
+ items: groupedItems,
950
+ onItemClick: fn(),
951
+ },
952
+ render: args => <WidgetCalendar {...args} />,
953
+ parameters: {
954
+ controls: { disable: true },
955
+ docs: {
956
+ source: {
957
+ language: 'tsx',
958
+ code: `
959
+ import { WidgetCalendar } from '@arbor-education/design-system.components';
960
+
961
+ function GroupedExample() {
962
+ return (
963
+ <WidgetCalendar
964
+ heading="Calendar"
965
+ groupItems
966
+ items={items}
967
+ />
968
+ );
969
+ }
970
+ export default GroupedExample;
971
+ `.trim(),
972
+ },
973
+ },
974
+ },
975
+ },
976
+ [
977
+ '`groupItems` is set and every grouped item carries a `group` display label',
978
+ '(`"Monday 8 June"`, `"Tuesday 9 June"` — the labels here happen to be dates, but any label works,',
979
+ 'such as a location or event type). Each distinct label renders once, as a subheading above the',
980
+ 'items that share it, and groups appear in the order their labels are first declared. The ungrouped',
981
+ 'item has no `group`, so it renders first — above every grouped item — to prevent ambiguity',
982
+ 'about which group it belongs to.',
983
+ ].join(' '),
984
+ );
985
+
986
+ export const Ungrouped: Story = withDescription(
987
+ {
988
+ args: {
989
+ heading: 'Calendar',
990
+ items: groupedItems,
991
+ onItemClick: fn(),
992
+ },
993
+ render: args => <WidgetCalendar {...args} />,
994
+ parameters: {
995
+ controls: { disable: true },
996
+ docs: {
997
+ source: {
998
+ language: 'tsx',
999
+ code: `
1000
+ import { WidgetCalendar } from '@arbor-education/design-system.components';
1001
+
1002
+ function UngroupedExample() {
1003
+ return (
1004
+ <WidgetCalendar
1005
+ heading="Calendar"
1006
+ items={items}
1007
+ />
1008
+ );
1009
+ }
1010
+ export default UngroupedExample;
1011
+ `.trim(),
1012
+ },
1013
+ },
1014
+ },
1015
+ },
1016
+ [
1017
+ '`groupItems` is omitted, so it defaults to `false` and the list renders flat — the',
1018
+ '`group` values on these items are ignored entirely and no subheadings appear.',
1019
+ 'Compare against the Grouped story: the two use the same fixture data, so any visible',
1020
+ 'difference is the grouping alone.',
1021
+ ].join(' '),
1022
+ );
@@ -1,5 +1,5 @@
1
1
  import '@testing-library/jest-dom/vitest';
2
- import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { describe, expect, test, vi } from 'vitest';
5
5
  import { WidgetCalendar } from './WidgetCalendar.js';
@@ -127,6 +127,55 @@ describe('WidgetCalendar', () => {
127
127
  expect(screen.getByRole('link', { name: 'Calendar' })).toHaveAttribute('href', '/calendar');
128
128
  });
129
129
 
130
+ test('forwards linkProps to the linked header', () => {
131
+ const onClick = vi.fn();
132
+
133
+ render(
134
+ <WidgetCalendar
135
+ heading="Calendar"
136
+ headingHref="/calendar"
137
+ linkProps={{ onClick, target: '_blank' }}
138
+ items={mockItems}
139
+ />,
140
+ );
141
+
142
+ const link = screen.getByRole('link', { name: 'Calendar' });
143
+ fireEvent.click(link);
144
+
145
+ expect(onClick).toHaveBeenCalledTimes(1);
146
+ expect(link).toHaveAttribute('target', '_blank');
147
+ });
148
+
149
+ test('rejects heading link props without headingHref at the type level', () => {
150
+ render(
151
+ <>
152
+ {/* @ts-expect-error linkProps requires headingHref */}
153
+ <WidgetCalendar heading="Calendar" items={mockItems} linkProps={{ onClick: vi.fn() }} />
154
+ {/* @ts-expect-error renderLink requires headingHref */}
155
+ <WidgetCalendar heading="Calendar" items={mockItems} renderLink={() => <a />} />
156
+ </>,
157
+ );
158
+
159
+ expect(screen.queryByRole('link', { name: 'Calendar' })).not.toBeInTheDocument();
160
+ });
161
+
162
+ test('renders the linked header through renderLink', () => {
163
+ render(
164
+ <WidgetCalendar
165
+ heading="Calendar"
166
+ headingHref="/calendar"
167
+ renderLink={({ href, className, children }) => (
168
+ <a href={href} className={className} data-router-link="true">
169
+ {children}
170
+ </a>
171
+ )}
172
+ items={mockItems}
173
+ />,
174
+ );
175
+
176
+ expect(screen.getByRole('link', { name: 'Calendar' })).toHaveAttribute('data-router-link', 'true');
177
+ });
178
+
130
179
  test('renders empty state when items is empty', () => {
131
180
  render(
132
181
  <WidgetCalendar heading="Calendar" items={[]} />,
@@ -134,4 +183,269 @@ describe('WidgetCalendar', () => {
134
183
 
135
184
  expect(screen.getByRole('heading', { name: 'Nothing scheduled' })).toBeInTheDocument();
136
185
  });
186
+
187
+ describe('item tooltips', () => {
188
+ const tooltipItems: WidgetCalendar.ItemData[] = [
189
+ {
190
+ id: 'year-10',
191
+ eventType: 'primary',
192
+ eventPrimaryTerm: 'Class',
193
+ eventPrimaryDefinition: 'Year 10: 10G',
194
+ eventSecondaryTerm: 'Time and location',
195
+ eventDateTime: '2026-06-08T08:45/2026-06-08T09:00',
196
+ eventTimeLabel: '08:45 – 09:00',
197
+ eventLocation: 'Classroom 102',
198
+ tooltip: 'Double maths with Mr Cullen',
199
+ },
200
+ {
201
+ id: 'class-label',
202
+ eventType: 'secondary',
203
+ eventPrimaryTerm: 'Class',
204
+ eventPrimaryDefinition: 'Class',
205
+ eventSecondaryTerm: 'Time and location',
206
+ eventDateTime: '2026-06-08T10:00/2026-06-08T10:45',
207
+ eventTimeLabel: '10:00 – 10:45',
208
+ eventLocation: 'Hall',
209
+ },
210
+ ];
211
+
212
+ test('shows tooltip text when hovering an item with a tooltip', async () => {
213
+ render(
214
+ <WidgetCalendar heading="Calendar" items={tooltipItems} />,
215
+ );
216
+
217
+ await userEvent.hover(screen.getByText('Year 10: 10G'));
218
+
219
+ await waitFor(() => {
220
+ expect(screen.getAllByText('Double maths with Mr Cullen').length).toBeGreaterThan(0);
221
+ });
222
+ });
223
+
224
+ test('does not show tooltip text until the item is hovered', () => {
225
+ render(
226
+ <WidgetCalendar heading="Calendar" items={tooltipItems} />,
227
+ );
228
+
229
+ expect(screen.queryByText('Double maths with Mr Cullen')).not.toBeInTheDocument();
230
+ });
231
+
232
+ test('renders items without a tooltip unchanged', () => {
233
+ render(
234
+ <WidgetCalendar heading="Calendar" items={tooltipItems} />,
235
+ );
236
+
237
+ expect(screen.getByText('10:00 – 10:45')).toBeInTheDocument();
238
+ });
239
+
240
+ test('tooltip does not break item activation on Enter key', () => {
241
+ const onItemClick = vi.fn();
242
+
243
+ render(
244
+ <WidgetCalendar
245
+ heading="Calendar"
246
+ items={tooltipItems}
247
+ onItemClick={onItemClick}
248
+ />,
249
+ );
250
+
251
+ const item = screen.getByRole('button', { name: 'Open Year 10: 10G' });
252
+
253
+ fireEvent.keyDown(item, { key: 'Enter' });
254
+
255
+ expect(onItemClick).toHaveBeenCalledWith('year-10');
256
+ });
257
+ });
258
+
259
+ describe('grouping', () => {
260
+ const groupedItem = (
261
+ id: string,
262
+ eventPrimaryDefinition: string,
263
+ group?: string,
264
+ ): WidgetCalendar.ItemData => ({
265
+ id,
266
+ eventType: 'primary',
267
+ eventPrimaryTerm: 'Class',
268
+ eventPrimaryDefinition,
269
+ eventSecondaryTerm: 'Time and location',
270
+ eventDateTime: '2026-06-08T08:45/2026-06-08T09:00',
271
+ eventTimeLabel: '08:45 – 09:00',
272
+ ...(group ? { group } : {}),
273
+ });
274
+
275
+ const GROUP_HEADING_SELECTOR = '.ds-widget-calendar__group-heading';
276
+
277
+ const getGroupLabels = (container: HTMLElement): (string | null)[] => Array.from(
278
+ container.querySelectorAll(GROUP_HEADING_SELECTOR),
279
+ ).map(label => label.textContent);
280
+
281
+ const getRenderedGroups = (container: HTMLElement) => Array.from(
282
+ container.querySelectorAll('ul.ds-widget-calendar__list'),
283
+ )
284
+ .map(list => ({
285
+ label: list.previousElementSibling?.matches(GROUP_HEADING_SELECTOR)
286
+ ? list.previousElementSibling.textContent
287
+ : null,
288
+ items: Array.from(list.querySelectorAll('.ds-widget-calendar__primary-definition'))
289
+ .map(item => item.textContent),
290
+ }))
291
+ .filter(group => group.items.length > 0);
292
+
293
+ test('renders ungrouped items before all grouped items', () => {
294
+ const { container } = render(
295
+ <WidgetCalendar
296
+ heading="Calendar"
297
+ groupItems
298
+ items={[
299
+ groupedItem('year-10', 'Year 10: 10G', 'Monday 8 June'),
300
+ groupedItem('detention-roster', 'Detention roster review'),
301
+ groupedItem('assembly', 'Whole-school assembly', 'Tuesday 9 June'),
302
+ ]}
303
+ />,
304
+ );
305
+
306
+ const lists = Array.from(container.querySelectorAll('ul.ds-widget-calendar__list'));
307
+ const ungroupedList = lists.find(list => list.textContent?.includes('Detention roster review'));
308
+
309
+ expect(ungroupedList).toBeDefined();
310
+ expect(lists.indexOf(ungroupedList!)).toBe(0);
311
+ expect(ungroupedList!.previousElementSibling).toBeNull();
312
+ });
313
+
314
+ test('renders group labels exactly as provided by the consumer', () => {
315
+ render(
316
+ <WidgetCalendar
317
+ heading="Calendar"
318
+ groupItems
319
+ items={[
320
+ groupedItem('year-10', 'Year 10: 10G', 'Monday 8 June'),
321
+ groupedItem('assembly', 'Whole-school assembly', 'Tuesday 9 June'),
322
+ ]}
323
+ />,
324
+ );
325
+
326
+ expect(screen.getByText('Monday 8 June')).toBeInTheDocument();
327
+ expect(screen.getByText('Tuesday 9 June')).toBeInTheDocument();
328
+ });
329
+
330
+ test('renders each distinct group label once, with all matching items under it', () => {
331
+ render(
332
+ <WidgetCalendar
333
+ heading="Calendar"
334
+ groupItems
335
+ items={[
336
+ groupedItem('year-10', 'Year 10: 10G', 'Monday 8 June'),
337
+ groupedItem('assembly', 'Whole-school assembly', 'Tuesday 9 June'),
338
+ groupedItem('maths-year-10', 'Maths: Year 10: 10x/Ma3', 'Monday 8 June'),
339
+ ]}
340
+ />,
341
+ );
342
+
343
+ const mondayList = screen.getByText('Monday 8 June').nextElementSibling;
344
+
345
+ expect(screen.getAllByText('Monday 8 June')).toHaveLength(1);
346
+ expect(mondayList).toContainElement(screen.getByText('Year 10: 10G'));
347
+ expect(mondayList).toContainElement(screen.getByText('Maths: Year 10: 10x/Ma3'));
348
+ expect(mondayList).not.toContainElement(screen.getByText('Whole-school assembly'));
349
+ });
350
+
351
+ test('renders groups in first-declared label order when items are interspersed', () => {
352
+ const { container } = render(
353
+ <WidgetCalendar
354
+ heading="Calendar"
355
+ groupItems
356
+ items={[
357
+ groupedItem('year-10', 'Year 10: 10G', 'Mon 8 Jun'),
358
+ groupedItem('staff-briefing', 'Staff briefing', 'Tue 9 Jun'),
359
+ groupedItem('maths-year-10', 'Maths: Year 10: 10x/Ma3', 'Mon 8 Jun'),
360
+ groupedItem('assembly', 'Whole-school assembly', 'Wed 10 Jun'),
361
+ groupedItem('parents-evening', 'Parents’ evening — Year 9', 'Tue 9 Jun'),
362
+ groupedItem('detention-roster', 'Detention roster review', 'Thu 11 Jun'),
363
+ ]}
364
+ />,
365
+ );
366
+
367
+ expect(getGroupLabels(container)).toEqual(['Mon 8 Jun', 'Tue 9 Jun', 'Wed 10 Jun', 'Thu 11 Jun']);
368
+ });
369
+
370
+ test('respects declaration order when group labels are integer-like strings', () => {
371
+ const { container } = render(
372
+ <WidgetCalendar
373
+ heading="Calendar"
374
+ groupItems
375
+ items={[
376
+ groupedItem('year-10', 'Year 10: 10G', '10'),
377
+ groupedItem('year-2', 'Year 2: 2B', '2'),
378
+ groupedItem('year-1', 'Year 1: 1A', '1'),
379
+ ]}
380
+ />,
381
+ );
382
+
383
+ expect(getGroupLabels(container)).toEqual(['10', '2', '1']);
384
+ });
385
+
386
+ test('respects declaration order when integer-like and non-numeric group labels are mixed', () => {
387
+ const { container } = render(
388
+ <WidgetCalendar
389
+ heading="Calendar"
390
+ groupItems
391
+ items={[
392
+ groupedItem('hall', 'Assembly', 'Main hall'),
393
+ groupedItem('room-3', 'Maths: 10x/Ma3', '3'),
394
+ groupedItem('gym', 'PE: Year 9', 'Sports hall'),
395
+ groupedItem('room-1', 'Science: 10y/Sc1', '1'),
396
+ groupedItem('room-2', 'English: 10z/En2', '2'),
397
+ ]}
398
+ />,
399
+ );
400
+
401
+ expect(getGroupLabels(container)).toEqual(['Main hall', '3', 'Sports hall', '1', '2']);
402
+ });
403
+
404
+ test('keeps the ungrouped bucket first and item order intact when integer-like labels are interspersed', () => {
405
+ const { container } = render(
406
+ <WidgetCalendar
407
+ heading="Calendar"
408
+ groupItems
409
+ items={[
410
+ groupedItem('room-2-first', 'English: 10z/En2', '2'),
411
+ groupedItem('room-1-first', 'Science: 10y/Sc1', '1'),
412
+ groupedItem('no-room-first', 'Staff briefing'),
413
+ groupedItem('room-2-second', 'Maths: 10x/Ma3', '2'),
414
+ groupedItem('hall', 'Assembly', 'Main hall'),
415
+ groupedItem('no-room-second', 'Detention roster review'),
416
+ groupedItem('room-1-second', 'History: 10w/Hi4', '1'),
417
+ ]}
418
+ />,
419
+ );
420
+
421
+ expect(getRenderedGroups(container)).toEqual([
422
+ { label: null, items: ['Staff briefing', 'Detention roster review'] },
423
+ { label: '2', items: ['English: 10z/En2', 'Maths: 10x/Ma3'] },
424
+ { label: '1', items: ['Science: 10y/Sc1', 'History: 10w/Hi4'] },
425
+ { label: 'Main hall', items: ['Assembly'] },
426
+ ]);
427
+ });
428
+
429
+ test('drops group values when groupItems is not passed (defaults to false)', () => {
430
+ const { container } = render(
431
+ <WidgetCalendar
432
+ heading="Calendar"
433
+ items={[
434
+ groupedItem('year-10', 'Year 10: 10G', 'Monday 8 June'),
435
+ groupedItem('detention-roster', 'Detention roster review'),
436
+ groupedItem('assembly', 'Whole-school assembly', 'Tuesday 9 June'),
437
+ ]}
438
+ />,
439
+ );
440
+
441
+ expect(screen.queryByText('Monday 8 June')).not.toBeInTheDocument();
442
+ expect(screen.queryByText('Tuesday 9 June')).not.toBeInTheDocument();
443
+
444
+ const flatList = screen.getByText('Year 10: 10G').closest('ul');
445
+
446
+ expect(flatList).toContainElement(screen.getByText('Detention roster review'));
447
+ expect(flatList).toContainElement(screen.getByText('Whole-school assembly'));
448
+ expect(container.querySelector(GROUP_HEADING_SELECTOR)).toBeNull();
449
+ });
450
+ });
137
451
  });