@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.
- package/CHANGELOG.md +10 -0
- 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/package.json +1 -1
- 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/tokens.scss +1 -0
- 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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
{
|
|
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,
|