@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
@@ -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;
@@ -15,17 +17,19 @@ type WidgetCalendarItemData = {
15
17
  eventLocation?: string;
16
18
  eventSecondaryDefinition?: React.ReactNode;
17
19
  buttonA11yText?: string;
20
+ tooltip?: string;
21
+ group?: string;
18
22
  };
19
23
 
20
- type WidgetCalendarProps = {
24
+ type WidgetCalendarProps = WidgetHeadingLinkProps & {
21
25
  heading: React.ReactNode;
22
26
  tooltip?: string;
23
- headingHref?: string;
24
27
  items: WidgetCalendarItemData[];
25
28
  isFloating?: boolean;
26
29
  onItemClick?: (id: string) => void;
27
30
  emptyState?: React.ReactNode;
28
31
  className?: string;
32
+ groupItems?: boolean;
29
33
  };
30
34
 
31
35
  const defaultEmptyState = (
@@ -42,42 +46,42 @@ export const WidgetCalendar = (props: WidgetCalendarProps): React.JSX.Element =>
42
46
  heading,
43
47
  tooltip,
44
48
  headingHref,
49
+ renderLink,
50
+ linkProps,
45
51
  items,
46
52
  isFloating = false,
47
53
  onItemClick,
48
54
  emptyState = defaultEmptyState,
49
55
  className,
56
+ groupItems,
50
57
  } = props;
51
58
 
52
59
  const isEmpty = items.length === 0;
53
60
 
54
- const body = (
55
- <>
56
- <Widget.Header>{heading}</Widget.Header>
57
- <Widget.Body>
58
- {!isEmpty && (
59
- <ul className="ds-widget-calendar__list">
60
- {items.map(item => (
61
- <WidgetCalendarItem
62
- key={item.id}
63
- id={item.id}
64
- eventType={item.eventType}
65
- eventPrimaryTerm={item.eventPrimaryTerm}
66
- eventPrimaryDefinition={item.eventPrimaryDefinition}
67
- eventSecondaryTerm={item.eventSecondaryTerm}
68
- eventDateTime={item.eventDateTime}
69
- eventTimeLabel={item.eventTimeLabel}
70
- eventLocation={item.eventLocation}
71
- eventSecondaryDefinition={item.eventSecondaryDefinition}
72
- buttonA11yText={item.buttonA11yText}
73
- onItemClick={onItemClick ? () => onItemClick(item.id) : undefined}
74
- />
75
- ))}
76
- </ul>
77
- )}
78
- </Widget.Body>
79
- </>
80
- );
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
+ }
81
85
 
82
86
  const widgetProps = {
83
87
  tooltip,
@@ -85,12 +89,54 @@ export const WidgetCalendar = (props: WidgetCalendarProps): React.JSX.Element =>
85
89
  isEmpty,
86
90
  emptyState,
87
91
  className,
88
- ...(headingHref ? { href: headingHref } : {}),
92
+ ...(headingHref ? { href: headingHref, renderLink, linkProps } : {}),
89
93
  } as Widget.Props;
90
94
 
91
95
  return (
92
96
  <Widget {...widgetProps}>
93
- {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>
94
140
  </Widget>
95
141
  );
96
142
  };
@@ -1,5 +1,6 @@
1
1
  import classNames from 'classnames';
2
2
  import { Icon } from 'Components/icon/Icon';
3
+ import { TooltipWrapper } from 'Components/tooltip/TooltipWrapper';
3
4
  import { ENTER_KEY, SPACE_KEY } from 'Utils/keyboardConstants';
4
5
 
5
6
  export type WidgetCalendarEventType = 'primary' | 'secondary';
@@ -17,6 +18,7 @@ export type WidgetCalendarItemProps = {
17
18
  buttonA11yText?: string;
18
19
  onItemClick?: () => void;
19
20
  className?: string;
21
+ tooltip?: string;
20
22
  };
21
23
 
22
24
  export const WidgetCalendarItem = (props: WidgetCalendarItemProps): React.JSX.Element => {
@@ -33,6 +35,7 @@ export const WidgetCalendarItem = (props: WidgetCalendarItemProps): React.JSX.El
33
35
  buttonA11yText,
34
36
  onItemClick,
35
37
  className,
38
+ tooltip,
36
39
  } = props;
37
40
 
38
41
  const isInteractive = Boolean(onItemClick);
@@ -61,7 +64,7 @@ export const WidgetCalendarItem = (props: WidgetCalendarItemProps): React.JSX.El
61
64
  }
62
65
  };
63
66
 
64
- return (
67
+ const item = (
65
68
  <li
66
69
  className={classNames(
67
70
  'ds-widget-calendar__item',
@@ -102,4 +105,14 @@ export const WidgetCalendarItem = (props: WidgetCalendarItemProps): React.JSX.El
102
105
  )}
103
106
  </li>
104
107
  );
108
+
109
+ if (tooltip) {
110
+ return (
111
+ <TooltipWrapper tooltipContent={tooltip}>
112
+ {item}
113
+ </TooltipWrapper>
114
+ );
115
+ }
116
+
117
+ return item;
105
118
  };
@@ -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,
@@ -63,6 +63,14 @@ describe('WidgetFavourites', () => {
63
63
  expect(container.querySelector('.ds-widget--floating')).toBeInTheDocument();
64
64
  });
65
65
 
66
+ test('applies the bounded root class alongside a consumer className', () => {
67
+ const { container } = render(
68
+ <WidgetFavourites heading="Favourites" items={mockItems} className="consumer-class" />,
69
+ );
70
+
71
+ expect(container.querySelector('.ds-widget')).toHaveClass('ds-widget-favourites', 'consumer-class');
72
+ });
73
+
66
74
  test('renders linked widget header when headingHref is provided', () => {
67
75
  render(
68
76
  <WidgetFavourites
@@ -75,6 +83,97 @@ describe('WidgetFavourites', () => {
75
83
  expect(screen.getByRole('link', { name: 'Favourites (2)' })).toHaveAttribute('href', '/favourites');
76
84
  });
77
85
 
86
+ test('forwards linkProps to the linked header', () => {
87
+ const onClick = vi.fn();
88
+
89
+ render(
90
+ <WidgetFavourites
91
+ heading="Favourites (2)"
92
+ headingHref="/favourites"
93
+ linkProps={{ onClick, target: '_blank' }}
94
+ items={mockItems}
95
+ />,
96
+ );
97
+
98
+ const link = screen.getByRole('link', { name: 'Favourites (2)' });
99
+ fireEvent.click(link);
100
+
101
+ expect(onClick).toHaveBeenCalledTimes(1);
102
+ expect(link).toHaveAttribute('target', '_blank');
103
+ });
104
+
105
+ test('renders the linked header through renderLink', () => {
106
+ render(
107
+ <WidgetFavourites
108
+ heading="Favourites (2)"
109
+ headingHref="/favourites"
110
+ renderLink={({ href, className, children }) => (
111
+ <a href={href} className={className} data-router-link="true">
112
+ {children}
113
+ </a>
114
+ )}
115
+ items={mockItems}
116
+ />,
117
+ );
118
+
119
+ expect(screen.getByRole('link', { name: 'Favourites (2)' })).toHaveAttribute('data-router-link', 'true');
120
+ });
121
+
122
+ test('rejects heading link props without headingHref at the type level', () => {
123
+ render(
124
+ <>
125
+ {/* @ts-expect-error linkProps requires headingHref */}
126
+ <WidgetFavourites heading="Favourites" items={mockItems} linkProps={{ onClick: vi.fn() }} />
127
+ {/* @ts-expect-error renderLink requires headingHref */}
128
+ <WidgetFavourites heading="Favourites" items={mockItems} renderLink={() => <a />} />
129
+ </>,
130
+ );
131
+
132
+ expect(screen.queryByRole('link', { name: 'Favourites' })).not.toBeInTheDocument();
133
+ });
134
+
135
+ test('applies item linkProps to the row link', () => {
136
+ const onClick = vi.fn();
137
+
138
+ render(
139
+ <WidgetFavourites
140
+ heading="Favourites"
141
+ items={[{
142
+ id: 'daily',
143
+ label: 'Daily Attendance',
144
+ href: '/attendance/daily',
145
+ linkProps: { onClick, target: '_blank' },
146
+ }]}
147
+ />,
148
+ );
149
+
150
+ const link = screen.getByRole('link', { name: 'Daily Attendance' });
151
+ fireEvent.click(link);
152
+
153
+ expect(onClick).toHaveBeenCalledTimes(1);
154
+ expect(link).toHaveAttribute('target', '_blank');
155
+ expect(link).toHaveAttribute('href', '/attendance/daily');
156
+ });
157
+
158
+ test('WidgetFavourites.Item applies linkProps to the row link', () => {
159
+ const onClick = vi.fn();
160
+
161
+ render(
162
+ <ul>
163
+ <WidgetFavourites.Item
164
+ id="daily"
165
+ label="Daily Attendance"
166
+ href="/attendance/daily"
167
+ linkProps={{ onClick }}
168
+ />
169
+ </ul>,
170
+ );
171
+
172
+ fireEvent.click(screen.getByRole('link', { name: 'Daily Attendance' }));
173
+
174
+ expect(onClick).toHaveBeenCalledTimes(1);
175
+ });
176
+
78
177
  test('uses default remove label on remove button', () => {
79
178
  render(
80
179
  <WidgetFavourites heading="Favourites" items={mockItems} onRemoveItem={vi.fn()} />,
@@ -1,3 +1,4 @@
1
+ import classNames from 'classnames';
1
2
  import { EmptyState } from 'Components/emptyState/EmptyState';
2
3
  import { Widget } from 'Components/widget/Widget';
3
4
  import type { Widget as WidgetComponent } from 'Components/widget/Widget';
@@ -18,6 +19,8 @@ export const WidgetFavourites = (props: WidgetFavouritesProps): React.JSX.Elemen
18
19
  tooltip,
19
20
  items,
20
21
  headingHref,
22
+ renderLink,
23
+ linkProps,
21
24
  isFloating = false,
22
25
  onRemoveItem,
23
26
  emptyState = defaultEmptyState,
@@ -31,7 +34,7 @@ export const WidgetFavourites = (props: WidgetFavouritesProps): React.JSX.Elemen
31
34
  isFloating,
32
35
  isEmpty,
33
36
  emptyState,
34
- className,
37
+ className: classNames('ds-widget-favourites', className),
35
38
  };
36
39
 
37
40
  const body = (
@@ -47,6 +50,7 @@ export const WidgetFavourites = (props: WidgetFavouritesProps): React.JSX.Elemen
47
50
  label={item.label}
48
51
  href={item.href}
49
52
  removeLabel={item.removeLabel}
53
+ linkProps={item.linkProps}
50
54
  onRemove={onRemoveItem ? () => onRemoveItem(item.id) : undefined}
51
55
  />
52
56
  ))}
@@ -60,7 +64,7 @@ export const WidgetFavourites = (props: WidgetFavouritesProps): React.JSX.Elemen
60
64
  <Widget
61
65
  {...({
62
66
  ...widgetCommonProps,
63
- ...(headingHref ? { href: headingHref } : {}),
67
+ ...(headingHref ? { href: headingHref, renderLink, linkProps } : {}),
64
68
  } as WidgetComponent.Props)}
65
69
  >
66
70
  {body}
@@ -11,6 +11,7 @@ export const WidgetFavouritesItem = (props: WidgetFavouritesItemProps): React.JS
11
11
  label,
12
12
  href,
13
13
  removeLabel = FAVOURITE_TOOLTIP.remove,
14
+ linkProps,
14
15
  onRemove,
15
16
  className,
16
17
  } = props;
@@ -20,7 +21,7 @@ export const WidgetFavouritesItem = (props: WidgetFavouritesItemProps): React.JS
20
21
  className={classNames('ds-widget-favourites__item', className)}
21
22
  data-testid={`widget-favourites-item-${id}`}
22
23
  >
23
- <a href={href} className={linkClassName}>
24
+ <a {...linkProps} href={href} className={linkClassName}>
24
25
  {label}
25
26
  </a>
26
27
  {onRemove && (
@@ -1,15 +1,17 @@
1
+ import type { WidgetHeadingLinkProps, WidgetLinkedProps } from 'Components/widget/types';
2
+
1
3
  export type WidgetFavouritesItemData = {
2
4
  id: string;
3
5
  label: string;
4
6
  href: string;
5
7
  removeLabel?: string;
8
+ linkProps?: WidgetLinkedProps['linkProps'];
6
9
  };
7
10
 
8
- export type WidgetFavouritesProps = {
11
+ export type WidgetFavouritesProps = WidgetHeadingLinkProps & {
9
12
  heading: React.ReactNode;
10
13
  tooltip?: string;
11
14
  items: WidgetFavouritesItemData[];
12
- headingHref?: string;
13
15
  isFloating?: boolean;
14
16
  onRemoveItem?: (id: string) => void;
15
17
  emptyState?: React.ReactNode;
@@ -21,6 +23,7 @@ export type WidgetFavouritesItemProps = {
21
23
  label: string;
22
24
  href: string;
23
25
  removeLabel?: string;
26
+ linkProps?: WidgetLinkedProps['linkProps'];
24
27
  onRemove?: () => void;
25
28
  className?: string;
26
29
  };
@@ -1,3 +1,10 @@
1
+ // Grid resolves its row against max-height, giving the content a definite height so only the body scrolls.
2
+ .ds-widget.ds-widget-favourites {
3
+ display: grid;
4
+ grid-template-rows: minmax(0, 1fr);
5
+ max-height: var(--widget-favourites-max-height);
6
+ }
7
+
1
8
  .ds-widget-favourites__list {
2
9
  list-style: none;
3
10
  margin: 0;
package/src/index.scss CHANGED
@@ -17,6 +17,7 @@
17
17
  @use "components/formField/label/label.scss";
18
18
  @use "components/formField/inputs/number/numberInput.scss";
19
19
  @use "components/formField/inputs/time/timeInput.scss";
20
+ @use "components/formField/inputs/singleFile/singleFileInput.scss";
20
21
  @use "components/formField/inputs/radio/radioButtonInput.scss";
21
22
  @use "components/formField/inputs/checkbox/checkboxInput.scss";
22
23
  @use "components/formField/inputs/selectDropdown/selectDropdown";