@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
|
@@ -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,
|
|
@@ -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.ts
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
} from 'Components/filterBar/FilterBar';
|
|
29
29
|
export { Fieldset } from 'Components/formField/fieldset/Fieldset';
|
|
30
30
|
export { FormField } from 'Components/formField/FormField';
|
|
31
|
+
export type { CustomInputProps, CustomInputRenderProps, FormFieldSharedProps } from 'Components/formField/FormField';
|
|
31
32
|
export { CheckboxGroup } from 'Components/formField/inputs/checkbox/CheckboxGroup';
|
|
32
33
|
export { CheckboxInput } from 'Components/formField/inputs/checkbox/CheckboxInput';
|
|
33
34
|
export { ColourPickerDropdown } from 'Components/formField/inputs/colourPickerDropdown/ColourPickerDropdown';
|
package/src/tokens.scss
CHANGED
|
@@ -882,6 +882,7 @@
|
|
|
882
882
|
--widget-favourites-weight-border: var(--border-weight);
|
|
883
883
|
--widget-favourites-spacing-vertical: var(--spacing-small);
|
|
884
884
|
--widget-favourites-spacing-horizontal: var(--spacing-small);
|
|
885
|
+
--widget-favourites-max-height: 375px;
|
|
885
886
|
--widget-attachments-hover-color-icon: var(--color-grey-900);
|
|
886
887
|
--widget-attachments-hover-color-text: var(--color-grey-900);
|
|
887
888
|
--widget-attachments-hover-color-background: var(--color-grey-100);
|
package/tokens/json/Arbor.json
CHANGED