@arbor-education/design-system.components 4.0.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 (43) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/components/widget/types.d.ts +9 -0
  3. package/dist/components/widget/types.d.ts.map +1 -1
  4. package/dist/components/widgetCalendar/WidgetCalendar.d.ts +4 -2
  5. package/dist/components/widgetCalendar/WidgetCalendar.d.ts.map +1 -1
  6. package/dist/components/widgetCalendar/WidgetCalendar.js +30 -5
  7. package/dist/components/widgetCalendar/WidgetCalendar.js.map +1 -1
  8. package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts +18 -2
  9. package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts.map +1 -1
  10. package/dist/components/widgetCalendar/WidgetCalendar.stories.js +231 -2
  11. package/dist/components/widgetCalendar/WidgetCalendar.stories.js.map +1 -1
  12. package/dist/components/widgetCalendar/WidgetCalendar.test.js +131 -1
  13. package/dist/components/widgetCalendar/WidgetCalendar.test.js.map +1 -1
  14. package/dist/components/widgetFavourites/WidgetFavourites.d.ts.map +1 -1
  15. package/dist/components/widgetFavourites/WidgetFavourites.js +5 -4
  16. package/dist/components/widgetFavourites/WidgetFavourites.js.map +1 -1
  17. package/dist/components/widgetFavourites/WidgetFavourites.stories.d.ts +3 -0
  18. package/dist/components/widgetFavourites/WidgetFavourites.stories.d.ts.map +1 -1
  19. package/dist/components/widgetFavourites/WidgetFavourites.stories.js +157 -0
  20. package/dist/components/widgetFavourites/WidgetFavourites.stories.js.map +1 -1
  21. package/dist/components/widgetFavourites/WidgetFavourites.test.js +41 -1
  22. package/dist/components/widgetFavourites/WidgetFavourites.test.js.map +1 -1
  23. package/dist/components/widgetFavourites/WidgetFavouritesItem.d.ts.map +1 -1
  24. package/dist/components/widgetFavourites/WidgetFavouritesItem.js +2 -2
  25. package/dist/components/widgetFavourites/WidgetFavouritesItem.js.map +1 -1
  26. package/dist/components/widgetFavourites/types.d.ts +4 -2
  27. package/dist/components/widgetFavourites/types.d.ts.map +1 -1
  28. package/dist/index.css +16 -0
  29. package/dist/index.css.map +1 -1
  30. package/package.json +1 -1
  31. package/src/components/widget/types.ts +13 -0
  32. package/src/components/widgetCalendar/WidgetCalendar.stories.tsx +268 -2
  33. package/src/components/widgetCalendar/WidgetCalendar.test.tsx +242 -0
  34. package/src/components/widgetCalendar/WidgetCalendar.tsx +76 -32
  35. package/src/components/widgetCalendar/widgetCalendar.scss +9 -0
  36. package/src/components/widgetFavourites/WidgetFavourites.stories.tsx +217 -0
  37. package/src/components/widgetFavourites/WidgetFavourites.test.tsx +99 -0
  38. package/src/components/widgetFavourites/WidgetFavourites.tsx +6 -2
  39. package/src/components/widgetFavourites/WidgetFavouritesItem.tsx +2 -1
  40. package/src/components/widgetFavourites/types.ts +5 -2
  41. package/src/components/widgetFavourites/widgetFavourites.scss +7 -0
  42. package/src/tokens.scss +1 -0
  43. package/tokens/json/Arbor.json +4 -0
@@ -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
  });
@@ -1,8 +1,10 @@
1
1
  import { EmptyState } from 'Components/emptyState/EmptyState';
2
2
  import { Icon } from 'Components/icon/Icon';
3
3
  import { Widget } from 'Components/widget/Widget';
4
+ import type { WidgetHeadingLinkProps } from 'Components/widget/types';
4
5
  import type { WidgetCalendarEventType, WidgetCalendarItemProps } from './WidgetCalendarItem.js';
5
6
  import { WidgetCalendarItem } from './WidgetCalendarItem.js';
7
+ import { Fragment } from 'react/jsx-runtime';
6
8
 
7
9
  type WidgetCalendarItemData = {
8
10
  id: string;
@@ -16,17 +18,18 @@ type WidgetCalendarItemData = {
16
18
  eventSecondaryDefinition?: React.ReactNode;
17
19
  buttonA11yText?: string;
18
20
  tooltip?: string;
21
+ group?: string;
19
22
  };
20
23
 
21
- type WidgetCalendarProps = {
24
+ type WidgetCalendarProps = WidgetHeadingLinkProps & {
22
25
  heading: React.ReactNode;
23
26
  tooltip?: string;
24
- headingHref?: string;
25
27
  items: WidgetCalendarItemData[];
26
28
  isFloating?: boolean;
27
29
  onItemClick?: (id: string) => void;
28
30
  emptyState?: React.ReactNode;
29
31
  className?: string;
32
+ groupItems?: boolean;
30
33
  };
31
34
 
32
35
  const defaultEmptyState = (
@@ -43,43 +46,42 @@ export const WidgetCalendar = (props: WidgetCalendarProps): React.JSX.Element =>
43
46
  heading,
44
47
  tooltip,
45
48
  headingHref,
49
+ renderLink,
50
+ linkProps,
46
51
  items,
47
52
  isFloating = false,
48
53
  onItemClick,
49
54
  emptyState = defaultEmptyState,
50
55
  className,
56
+ groupItems,
51
57
  } = props;
52
58
 
53
59
  const isEmpty = items.length === 0;
54
60
 
55
- const body = (
56
- <>
57
- <Widget.Header>{heading}</Widget.Header>
58
- <Widget.Body>
59
- {!isEmpty && (
60
- <ul className="ds-widget-calendar__list">
61
- {items.map(item => (
62
- <WidgetCalendarItem
63
- key={item.id}
64
- id={item.id}
65
- eventType={item.eventType}
66
- eventPrimaryTerm={item.eventPrimaryTerm}
67
- eventPrimaryDefinition={item.eventPrimaryDefinition}
68
- eventSecondaryTerm={item.eventSecondaryTerm}
69
- eventDateTime={item.eventDateTime}
70
- eventTimeLabel={item.eventTimeLabel}
71
- eventLocation={item.eventLocation}
72
- eventSecondaryDefinition={item.eventSecondaryDefinition}
73
- buttonA11yText={item.buttonA11yText}
74
- onItemClick={onItemClick ? () => onItemClick(item.id) : undefined}
75
- tooltip={item.tooltip}
76
- />
77
- ))}
78
- </ul>
79
- )}
80
- </Widget.Body>
81
- </>
82
- );
61
+ const groups = ['_ungrouped'];
62
+
63
+ const groupMap: Record<string, WidgetCalendarItemData[]> = {
64
+ _ungrouped: [],
65
+ };
66
+
67
+ if (groupItems) {
68
+ items.forEach((item) => {
69
+ if (item.group) {
70
+ if (!groups.includes(item.group)) {
71
+ groupMap[item.group] = [];
72
+ groups.push(item.group);
73
+ }
74
+
75
+ groupMap[item.group]?.push(item);
76
+ }
77
+ else {
78
+ groupMap._ungrouped!.push(item);
79
+ }
80
+ });
81
+ }
82
+ else {
83
+ groupMap._ungrouped = [...items];
84
+ }
83
85
 
84
86
  const widgetProps = {
85
87
  tooltip,
@@ -87,12 +89,54 @@ export const WidgetCalendar = (props: WidgetCalendarProps): React.JSX.Element =>
87
89
  isEmpty,
88
90
  emptyState,
89
91
  className,
90
- ...(headingHref ? { href: headingHref } : {}),
92
+ ...(headingHref ? { href: headingHref, renderLink, linkProps } : {}),
91
93
  } as Widget.Props;
92
94
 
93
95
  return (
94
96
  <Widget {...widgetProps}>
95
- {body}
97
+ <Widget.Header>{heading}</Widget.Header>
98
+ <Widget.Body>
99
+ {!isEmpty && (
100
+ groups.map((group) => {
101
+ const label = group;
102
+ const eventItems = groupMap[group]!;
103
+
104
+ return (
105
+ <Fragment key={`widgetCalendarGroup_${group}`}>
106
+ {label !== '_ungrouped' && (
107
+ <span
108
+ key={`groupLabel_${label}`}
109
+ className="ds-widget-calendar__group-heading"
110
+ >
111
+ {label}
112
+ </span>
113
+ )}
114
+ {eventItems.length > 0 && (
115
+ <ul key={label} className="ds-widget-calendar__list">
116
+ {eventItems.map(item => (
117
+ <WidgetCalendarItem
118
+ key={item.id}
119
+ id={item.id}
120
+ eventType={item.eventType}
121
+ eventPrimaryTerm={item.eventPrimaryTerm}
122
+ eventPrimaryDefinition={item.eventPrimaryDefinition}
123
+ eventSecondaryTerm={item.eventSecondaryTerm}
124
+ eventDateTime={item.eventDateTime}
125
+ eventTimeLabel={item.eventTimeLabel}
126
+ eventLocation={item.eventLocation}
127
+ eventSecondaryDefinition={item.eventSecondaryDefinition}
128
+ buttonA11yText={item.buttonA11yText}
129
+ onItemClick={onItemClick ? () => onItemClick(item.id) : undefined}
130
+ tooltip={item.tooltip}
131
+ />
132
+ ))}
133
+ </ul>
134
+ )}
135
+ </Fragment>
136
+ );
137
+ })
138
+ )}
139
+ </Widget.Body>
96
140
  </Widget>
97
141
  );
98
142
  };
@@ -122,3 +122,12 @@
122
122
  .ds-widget-calendar__item--interactive:hover .ds-widget-calendar__action {
123
123
  color: var(--widget-list-row-hover-color-icon-arrow);
124
124
  }
125
+
126
+ .ds-widget-calendar__group-heading {
127
+ color: var(--widget-calendar-color-text);
128
+ font-family: var(--type-body-bold-family);
129
+ font-size: var(--type-body-bold-size);
130
+ font-style: normal;
131
+ font-weight: var(--type-body-bold-weight);
132
+ line-height: 150%;
133
+ }
@@ -25,6 +25,12 @@ const initialItems: WidgetFavouritesItemData[] = [
25
25
  { id: 'calendar', label: 'School Calendar', href: '/calendar' },
26
26
  ];
27
27
 
28
+ const longListItems: WidgetFavouritesItemData[] = Array.from({ length: 20 }, (_, index) => ({
29
+ id: `favourite-${index + 1}`,
30
+ label: `Favourite page ${index + 1}`,
31
+ href: `/favourites/${index + 1}`,
32
+ }));
33
+
28
34
  const DESCRIPTION_INTRO = [
29
35
  '`WidgetFavourites` is a pre-composed dashboard widget for starred pages.',
30
36
  'It wraps [`Widget`](?path=/docs/components-widget--docs) with a linked header and a list of favourite rows —',
@@ -59,11 +65,22 @@ const DEVELOPER_NOTES = [
59
65
  '**Row link and remove are siblings.** The remove button is not nested inside the row anchor —',
60
66
  'same pattern as sidebar favourites. Click handlers call `event.stopPropagation()`.',
61
67
  '',
68
+ '**Height is bounded by a token.** `WidgetFavourites` caps itself at `--widget-favourites-max-height` (375px),',
69
+ 'so a long list scrolls inside the widget body instead of growing the page. Override the token on an ancestor',
70
+ 'to change the cap; no parent height is required.',
71
+ '',
62
72
  '**Empty list uses Widget empty state.** When `items` is empty, `WidgetFavourites` sets `isEmpty` on the',
63
73
  'underlying `Widget` and renders the default or custom `emptyState`.',
64
74
  '',
65
75
  '**Undo removal.** Wire removal in the parent and pass a foreground toast with `toastActions` for the recommended product flow.',
66
76
  '',
77
+ '**`renderLink` and `linkProps` apply to the heading link only.** Both are forwarded to `Widget` only when',
78
+ '`headingHref` is set, and the types reject either prop without `headingHref`.',
79
+ '',
80
+ '**Items accept `linkProps` for row click handling.** `items[].linkProps` (or `linkProps` on',
81
+ '`WidgetFavourites.Item`) is spread onto the row `<a>`. `href` and `className` are applied after the spread',
82
+ 'and cannot be overridden.',
83
+ '',
67
84
  '---',
68
85
  '',
69
86
  '### Accessibility',
@@ -300,6 +317,90 @@ const WithConfirmRemoveTemplate = () => {
300
317
  );
301
318
  };
302
319
 
320
+ // Modifier-clicks (cmd/ctrl/shift/alt) open new tabs or windows, so leave them to the browser.
321
+ const isModifiedClick = (event: React.MouseEvent<HTMLAnchorElement>) =>
322
+ event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
323
+
324
+ const LastClicked = ({ label, placeholder }: { label: string | null; placeholder: string }) => (
325
+ <span className="ds-text" style={{ color: 'var(--color-grey-600)' }}>
326
+ {label
327
+ ? (
328
+ <>
329
+ Last clicked:
330
+ {' '}
331
+ {label}
332
+ </>
333
+ )
334
+ : placeholder}
335
+ </span>
336
+ );
337
+
338
+ const handleHeadingLinkClick = fn().mockName('linkProps.onClick');
339
+ const handleItemLinkClick = fn().mockName('items[].linkProps.onClick');
340
+
341
+ const WithHeadingLinkPropsTemplate = () => {
342
+ const [lastClickedHref, setLastClickedHref] = useState<string | null>(null);
343
+
344
+ return (
345
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
346
+ <WidgetFavourites
347
+ heading={`Favourites (${initialItems.length})`}
348
+ tooltip="Your starred pages appear here"
349
+ headingHref="/favourites"
350
+ isFloating
351
+ items={initialItems}
352
+ linkProps={{
353
+ onClick: (event) => {
354
+ handleHeadingLinkClick(event);
355
+ if (isModifiedClick(event)) {
356
+ return;
357
+ }
358
+ event.preventDefault();
359
+ setLastClickedHref(event.currentTarget.getAttribute('href'));
360
+ },
361
+ }}
362
+ />
363
+ <LastClicked
364
+ label={lastClickedHref}
365
+ placeholder="Click the heading to see the handled navigation captured here."
366
+ />
367
+ </div>
368
+ );
369
+ };
370
+
371
+ const WithItemLinkPropsTemplate = () => {
372
+ const [lastClickedLabel, setLastClickedLabel] = useState<string | null>(null);
373
+
374
+ const items = initialItems.map((item): WidgetFavouritesItemData => ({
375
+ ...item,
376
+ linkProps: {
377
+ onClick: (event) => {
378
+ handleItemLinkClick(item.id, event);
379
+ if (isModifiedClick(event)) {
380
+ return;
381
+ }
382
+ event.preventDefault();
383
+ setLastClickedLabel(item.label);
384
+ },
385
+ },
386
+ }));
387
+
388
+ return (
389
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
390
+ <WidgetFavourites
391
+ heading={`Favourites (${items.length})`}
392
+ tooltip="Your starred pages appear here"
393
+ isFloating
394
+ items={items}
395
+ />
396
+ <LastClicked
397
+ label={lastClickedLabel}
398
+ placeholder="Click a favourite to see it captured here."
399
+ />
400
+ </div>
401
+ );
402
+ };
403
+
303
404
  const EmptyTemplate = () => (
304
405
  <WidgetFavourites
305
406
  heading="Favourites (0)"
@@ -320,6 +421,112 @@ export const Default: Story = withDescription(
320
421
  'Recommended product flow: remove a favourite, then undo via a foreground toast. Each row shows the remove button when `onRemoveItem` is wired.',
321
422
  );
322
423
 
424
+ export const WithHeadingLinkProps: Story = {
425
+ name: 'With heading linkProps',
426
+ ...withDescription(
427
+ {
428
+ render: WithHeadingLinkPropsTemplate,
429
+ parameters: {
430
+ controls: { disable: true },
431
+ docs: {
432
+ source: {
433
+ language: 'tsx',
434
+ code: `
435
+ import { WidgetFavourites } from '@arbor-education/design-system.components';
436
+
437
+ function FavouritesHeadingLinkPropsExample() {
438
+ return (
439
+ <WidgetFavourites
440
+ heading="Favourites"
441
+ headingHref="/favourites"
442
+ isFloating
443
+ items={items}
444
+ linkProps={{
445
+ onClick: (event) => {
446
+ // Leave modifier-clicks (new tab/window) to the browser.
447
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
448
+ return;
449
+ }
450
+ event.preventDefault();
451
+ navigate('/favourites');
452
+ },
453
+ }}
454
+ />
455
+ );
456
+ }
457
+ export default FavouritesHeadingLinkPropsExample;
458
+ `.trim(),
459
+ },
460
+ },
461
+ },
462
+ },
463
+ [
464
+ '**Demonstrates the `linkProps` prop** (forwarded to `Widget` when `headingHref` is set). `linkProps` is',
465
+ 'spread onto the heading `<a>`, so consumers can attach `onClick` for analytics or client-side navigation.',
466
+ 'Each click is logged to the Actions panel as `linkProps.onClick`. A plain click calls `preventDefault()`',
467
+ 'and shows the destination below the widget as "Last clicked: {href}"; modifier-clicks (cmd/ctrl/shift/alt)',
468
+ 'are left to the browser. For a custom link element (e.g. a router link) use `renderLink` instead, as in',
469
+ 'the `Widget` "With Render Link" story. For row clicks, see "With item linkProps".',
470
+ ].join(' '),
471
+ ),
472
+ };
473
+
474
+ export const WithItemLinkProps: Story = {
475
+ name: 'With item linkProps',
476
+ ...withDescription(
477
+ {
478
+ render: WithItemLinkPropsTemplate,
479
+ parameters: {
480
+ controls: { disable: true },
481
+ docs: {
482
+ source: {
483
+ language: 'tsx',
484
+ code: `
485
+ import { useState } from 'react';
486
+ import { WidgetFavourites } from '@arbor-education/design-system.components';
487
+ import type { WidgetFavouritesItemData } from '@arbor-education/design-system.components';
488
+
489
+ function FavouritesItemLinkPropsExample() {
490
+ const [lastClicked, setLastClicked] = useState<string | null>(null);
491
+
492
+ const items: WidgetFavouritesItemData[] = favourites.map(item => ({
493
+ ...item,
494
+ linkProps: {
495
+ onClick: (event) => {
496
+ // Leave modifier-clicks (new tab/window) to the browser.
497
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
498
+ return;
499
+ }
500
+ event.preventDefault();
501
+ setLastClicked(item.label);
502
+ navigate(item.href);
503
+ },
504
+ },
505
+ }));
506
+
507
+ return (
508
+ <>
509
+ <WidgetFavourites heading="Favourites" isFloating items={items} />
510
+ <span>Last clicked: {lastClicked ?? 'none yet'}</span>
511
+ </>
512
+ );
513
+ }
514
+ export default FavouritesItemLinkPropsExample;
515
+ `.trim(),
516
+ },
517
+ },
518
+ },
519
+ },
520
+ [
521
+ '**Demonstrates the `linkProps` field on `items`** (also accepted by `WidgetFavourites.Item`). Each item\'s',
522
+ '`linkProps` is spread onto its row `<a>`; `href` and `className` are applied after the spread and cannot',
523
+ 'be overridden. Each click is logged to the Actions panel as `items[].linkProps.onClick` with the item id.',
524
+ 'A plain click calls `preventDefault()` and shows the clicked favourite below the widget as',
525
+ '"Last clicked: {label}"; modifier-clicks (cmd/ctrl/shift/alt) are left to the browser.',
526
+ ].join(' '),
527
+ ),
528
+ };
529
+
323
530
  export const WithConfirmRemove: Story = withDescription(
324
531
  {
325
532
  render: WithConfirmRemoveTemplate,
@@ -330,6 +537,16 @@ export const WithConfirmRemove: Story = withDescription(
330
537
  'Alternative flow when removal must be confirmed before mutating state. The remove button opens a destructive modal.',
331
538
  );
332
539
 
540
+ export const LongList: Story = withDescription(
541
+ {
542
+ args: {
543
+ heading: `Favourites (${longListItems.length})`,
544
+ items: longListItems,
545
+ },
546
+ },
547
+ 'A long list stays within `--widget-favourites-max-height` (375px). The header stays fixed and only the list scrolls.',
548
+ );
549
+
333
550
  export const Empty: Story = withDescription(
334
551
  {
335
552
  render: EmptyTemplate,