@arbor-education/design-system.components 4.0.0 → 4.2.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.
- package/.pre-commit-config.yaml +4 -0
- package/CHANGELOG.md +16 -0
- package/dist/components/formField/FormField.d.ts +15 -0
- package/dist/components/formField/FormField.d.ts.map +1 -1
- package/dist/components/formField/FormField.js +2 -1
- package/dist/components/formField/FormField.js.map +1 -1
- package/dist/components/formField/FormField.stories.d.ts +1 -0
- package/dist/components/formField/FormField.stories.d.ts.map +1 -1
- package/dist/components/formField/FormField.stories.js +71 -5
- package/dist/components/formField/FormField.stories.js.map +1 -1
- package/dist/components/formField/FormField.test.js +19 -1
- package/dist/components/formField/FormField.test.js.map +1 -1
- package/dist/components/formField/label/Label.js +2 -2
- package/dist/components/formField/label/Label.js.map +1 -1
- package/dist/components/widget/types.d.ts +9 -0
- package/dist/components/widget/types.d.ts.map +1 -1
- package/dist/components/widgetCalendar/WidgetCalendar.d.ts +4 -2
- package/dist/components/widgetCalendar/WidgetCalendar.d.ts.map +1 -1
- package/dist/components/widgetCalendar/WidgetCalendar.js +30 -5
- package/dist/components/widgetCalendar/WidgetCalendar.js.map +1 -1
- package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts +18 -2
- package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts.map +1 -1
- package/dist/components/widgetCalendar/WidgetCalendar.stories.js +231 -2
- package/dist/components/widgetCalendar/WidgetCalendar.stories.js.map +1 -1
- package/dist/components/widgetCalendar/WidgetCalendar.test.js +131 -1
- package/dist/components/widgetCalendar/WidgetCalendar.test.js.map +1 -1
- package/dist/components/widgetFavourites/WidgetFavourites.d.ts.map +1 -1
- package/dist/components/widgetFavourites/WidgetFavourites.js +5 -4
- package/dist/components/widgetFavourites/WidgetFavourites.js.map +1 -1
- package/dist/components/widgetFavourites/WidgetFavourites.stories.d.ts +3 -0
- package/dist/components/widgetFavourites/WidgetFavourites.stories.d.ts.map +1 -1
- package/dist/components/widgetFavourites/WidgetFavourites.stories.js +157 -0
- package/dist/components/widgetFavourites/WidgetFavourites.stories.js.map +1 -1
- package/dist/components/widgetFavourites/WidgetFavourites.test.js +41 -1
- package/dist/components/widgetFavourites/WidgetFavourites.test.js.map +1 -1
- package/dist/components/widgetFavourites/WidgetFavouritesItem.d.ts.map +1 -1
- package/dist/components/widgetFavourites/WidgetFavouritesItem.js +2 -2
- package/dist/components/widgetFavourites/WidgetFavouritesItem.js.map +1 -1
- package/dist/components/widgetFavourites/types.d.ts +4 -2
- package/dist/components/widgetFavourites/types.d.ts.map +1 -1
- package/dist/index.css +16 -0
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/formField/FormField.stories.tsx +100 -5
- package/src/components/formField/FormField.test.tsx +36 -1
- package/src/components/formField/FormField.tsx +21 -2
- package/src/components/formField/label/Label.tsx +2 -2
- package/src/components/widget/types.ts +13 -0
- package/src/components/widgetCalendar/WidgetCalendar.stories.tsx +268 -2
- package/src/components/widgetCalendar/WidgetCalendar.test.tsx +242 -0
- package/src/components/widgetCalendar/WidgetCalendar.tsx +76 -32
- package/src/components/widgetCalendar/widgetCalendar.scss +9 -0
- package/src/components/widgetFavourites/WidgetFavourites.stories.tsx +217 -0
- package/src/components/widgetFavourites/WidgetFavourites.test.tsx +99 -0
- package/src/components/widgetFavourites/WidgetFavourites.tsx +6 -2
- package/src/components/widgetFavourites/WidgetFavouritesItem.tsx +2 -1
- package/src/components/widgetFavourites/types.ts +5 -2
- package/src/components/widgetFavourites/widgetFavourites.scss +7 -0
- package/src/index.ts +1 -0
- package/src/tokens.scss +1 -0
- package/tokens/json/Arbor.json +4 -0
|
@@ -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`, `buttonA11yText`,
|
|
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: {
|
|
@@ -754,3 +940,83 @@ export default ComposableCalendarExample;
|
|
|
754
940
|
'unlike the parent `WidgetCalendar`\'s `onItemClick(id)`.',
|
|
755
941
|
].join(' '),
|
|
756
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
|
+
);
|
|
@@ -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={[]} />,
|
|
@@ -206,4 +255,197 @@ describe('WidgetCalendar', () => {
|
|
|
206
255
|
expect(onItemClick).toHaveBeenCalledWith('year-10');
|
|
207
256
|
});
|
|
208
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
|
+
});
|
|
209
451
|
});
|