@arbor-education/design-system.components 4.2.0 → 4.4.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/.storybook/preview.ts +10 -0
- package/CHANGELOG.md +16 -0
- package/dist/components/slideover/Slideover.d.ts +7 -2
- package/dist/components/slideover/Slideover.d.ts.map +1 -1
- package/dist/components/slideover/Slideover.js +31 -4
- package/dist/components/slideover/Slideover.js.map +1 -1
- package/dist/components/slideover/Slideover.test.js +116 -2
- package/dist/components/slideover/Slideover.test.js.map +1 -1
- package/dist/components/slideoverManager/SlideoverManager.stories.d.ts +3 -0
- package/dist/components/slideoverManager/SlideoverManager.stories.d.ts.map +1 -1
- package/dist/components/slideoverManager/SlideoverManager.stories.js +74 -4
- package/dist/components/slideoverManager/SlideoverManager.stories.js.map +1 -1
- package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts +1 -0
- package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts.map +1 -1
- package/dist/components/widgetCalendar/WidgetCalendar.stories.js +77 -4
- package/dist/components/widgetCalendar/WidgetCalendar.stories.js.map +1 -1
- package/dist/components/widgetCalendar/WidgetCalendar.test.js +31 -0
- package/dist/components/widgetCalendar/WidgetCalendar.test.js.map +1 -1
- package/dist/components/widgetCalendar/WidgetCalendarItem.d.ts +1 -1
- package/dist/components/widgetCalendar/WidgetCalendarItem.d.ts.map +1 -1
- package/dist/components/widgetCalendar/WidgetCalendarItem.js +1 -3
- package/dist/components/widgetCalendar/WidgetCalendarItem.js.map +1 -1
- package/dist/index.css +36 -1
- package/dist/index.css.map +1 -1
- package/dist/utils/SlideoverUtils.d.ts +3 -1
- package/dist/utils/SlideoverUtils.d.ts.map +1 -1
- package/dist/utils/SlideoverUtils.js.map +1 -1
- package/package.json +1 -1
- package/src/components/slideover/Slideover.test.tsx +152 -2
- package/src/components/slideover/Slideover.tsx +56 -9
- package/src/components/slideover/slideover.scss +8 -1
- package/src/components/slideoverManager/SlideoverManager.stories.tsx +147 -3
- package/src/components/widgetCalendar/WidgetCalendar.stories.tsx +81 -4
- package/src/components/widgetCalendar/WidgetCalendar.test.tsx +48 -0
- package/src/components/widgetCalendar/WidgetCalendarItem.tsx +10 -4
- package/src/components/widgetCalendar/widgetCalendar.scss +24 -0
- package/src/docs/Contributing.mdx +2 -0
- package/src/tokens.scss +6 -0
- package/src/utils/SlideoverUtils.ts +4 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { expect, test, describe, vi } from 'vitest';
|
|
2
|
-
import { render, screen, fireEvent } from '@testing-library/react';
|
|
1
|
+
import { expect, test, describe, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { render, screen, fireEvent, createEvent, act } from '@testing-library/react';
|
|
3
3
|
import { Slideover } from './Slideover.js';
|
|
4
4
|
import '@testing-library/jest-dom/vitest';
|
|
5
5
|
import { SlideoverUtils } from 'Utils/SlideoverUtils';
|
|
@@ -10,7 +10,17 @@ vi.mock('Utils/SlideoverUtils', () => ({
|
|
|
10
10
|
},
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
|
+
const fireTransitionEnd = (element: Element, propertyName: string) => {
|
|
14
|
+
const event = createEvent.transitionEnd(element);
|
|
15
|
+
Object.defineProperty(event, 'propertyName', { value: propertyName });
|
|
16
|
+
fireEvent(element, event);
|
|
17
|
+
};
|
|
18
|
+
|
|
13
19
|
describe('Slideover', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.mocked(SlideoverUtils.removeSlideover).mockClear();
|
|
22
|
+
});
|
|
23
|
+
|
|
14
24
|
test('renders the title and children', () => {
|
|
15
25
|
render(<Slideover title="My Test Slideover"><div>My Content</div></Slideover>);
|
|
16
26
|
expect(screen.getByText('My Test Slideover')).toBeInTheDocument();
|
|
@@ -33,4 +43,144 @@ describe('Slideover', () => {
|
|
|
33
43
|
fireEvent.click(backButton);
|
|
34
44
|
expect(SlideoverUtils.removeSlideover).toHaveBeenCalled();
|
|
35
45
|
});
|
|
46
|
+
|
|
47
|
+
describe('onBack', () => {
|
|
48
|
+
test('calls onBack instead of removeSlideover when provided', () => {
|
|
49
|
+
const onBack = vi.fn();
|
|
50
|
+
render(<Slideover onBack={onBack} />);
|
|
51
|
+
|
|
52
|
+
fireEvent.click(screen.getByRole('button', { name: /Back/i }));
|
|
53
|
+
|
|
54
|
+
expect(onBack).toHaveBeenCalledTimes(1);
|
|
55
|
+
expect(SlideoverUtils.removeSlideover).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('backButtonText', () => {
|
|
60
|
+
test('labels the back button with backButtonText', () => {
|
|
61
|
+
render(<Slideover backButtonText="Yn ôl" />);
|
|
62
|
+
expect(screen.getByRole('button', { name: 'Yn ôl' })).toBeInTheDocument();
|
|
63
|
+
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('defaults the label to Back', () => {
|
|
67
|
+
render(<Slideover />);
|
|
68
|
+
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('headerActions', () => {
|
|
73
|
+
test('renders header actions in the header', () => {
|
|
74
|
+
render(<Slideover title="Student" headerActions={<button type="button">Edit</button>} />);
|
|
75
|
+
const header = screen.getByRole('heading', { name: 'Student' }).parentElement;
|
|
76
|
+
expect(header).toContainElement(screen.getByRole('button', { name: 'Edit' }));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('renders no header actions container when headerActions is not provided', () => {
|
|
80
|
+
const { container } = render(<Slideover />);
|
|
81
|
+
expect(container.querySelector('.ds-slideover__header-actions')).not.toBeInTheDocument();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('closing', () => {
|
|
86
|
+
test('is not marked as closing by default', () => {
|
|
87
|
+
const { container } = render(<Slideover />);
|
|
88
|
+
expect(container.querySelector('.ds-slideover')).not.toHaveClass('ds-slideover--closing');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('is marked as closing when isClosing is set', () => {
|
|
92
|
+
const { container } = render(<Slideover isClosing />);
|
|
93
|
+
expect(container.querySelector('.ds-slideover')).toHaveClass('ds-slideover--closing');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('calls onCloseTransitionEnd when the closing slide-out finishes', () => {
|
|
97
|
+
const onCloseTransitionEnd = vi.fn();
|
|
98
|
+
const { container } = render(<Slideover isClosing onCloseTransitionEnd={onCloseTransitionEnd} />);
|
|
99
|
+
|
|
100
|
+
fireTransitionEnd(container.querySelector('.ds-slideover')!, 'right');
|
|
101
|
+
|
|
102
|
+
expect(onCloseTransitionEnd).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('does not call onCloseTransitionEnd when the enter slide-in finishes', () => {
|
|
106
|
+
const onCloseTransitionEnd = vi.fn();
|
|
107
|
+
const { container } = render(<Slideover onCloseTransitionEnd={onCloseTransitionEnd} />);
|
|
108
|
+
|
|
109
|
+
fireTransitionEnd(container.querySelector('.ds-slideover')!, 'right');
|
|
110
|
+
|
|
111
|
+
expect(onCloseTransitionEnd).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('when the slide-out transition never runs', () => {
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
vi.useFakeTimers();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
vi.useRealTimers();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('still calls onCloseTransitionEnd once the transition time has passed', () => {
|
|
124
|
+
const onCloseTransitionEnd = vi.fn();
|
|
125
|
+
render(<Slideover isClosing onCloseTransitionEnd={onCloseTransitionEnd} />);
|
|
126
|
+
|
|
127
|
+
act(() => {
|
|
128
|
+
vi.advanceTimersByTime(299);
|
|
129
|
+
});
|
|
130
|
+
expect(onCloseTransitionEnd).not.toHaveBeenCalled();
|
|
131
|
+
|
|
132
|
+
act(() => {
|
|
133
|
+
vi.advanceTimersByTime(200);
|
|
134
|
+
});
|
|
135
|
+
expect(onCloseTransitionEnd).toHaveBeenCalledTimes(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('calls onCloseTransitionEnd once when the transition does finish', () => {
|
|
139
|
+
const onCloseTransitionEnd = vi.fn();
|
|
140
|
+
const { container } = render(<Slideover isClosing onCloseTransitionEnd={onCloseTransitionEnd} />);
|
|
141
|
+
|
|
142
|
+
fireTransitionEnd(container.querySelector('.ds-slideover')!, 'right');
|
|
143
|
+
act(() => {
|
|
144
|
+
vi.advanceTimersByTime(1000);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(onCloseTransitionEnd).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('does not call onCloseTransitionEnd if closing is cancelled first', () => {
|
|
151
|
+
const onCloseTransitionEnd = vi.fn();
|
|
152
|
+
const { rerender } = render(<Slideover isClosing onCloseTransitionEnd={onCloseTransitionEnd} />);
|
|
153
|
+
|
|
154
|
+
rerender(<Slideover onCloseTransitionEnd={onCloseTransitionEnd} />);
|
|
155
|
+
act(() => {
|
|
156
|
+
vi.advanceTimersByTime(1000);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(onCloseTransitionEnd).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('ignores transitions of other properties and of child elements', () => {
|
|
164
|
+
const onCloseTransitionEnd = vi.fn();
|
|
165
|
+
const { container } = render(
|
|
166
|
+
<Slideover isClosing onCloseTransitionEnd={onCloseTransitionEnd}>
|
|
167
|
+
<div data-testid="child" />
|
|
168
|
+
</Slideover>,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
fireTransitionEnd(container.querySelector('.ds-slideover')!, 'opacity');
|
|
172
|
+
fireTransitionEnd(screen.getByTestId('child'), 'right');
|
|
173
|
+
|
|
174
|
+
expect(onCloseTransitionEnd).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('accessibility attributes', () => {
|
|
179
|
+
test('forwards role and aria attributes to the panel', () => {
|
|
180
|
+
render(<Slideover title="Student" role="dialog" aria-label="Student details" aria-modal />);
|
|
181
|
+
const panel = screen.getByRole('dialog', { name: 'Student details' });
|
|
182
|
+
expect(panel).toHaveAttribute('aria-modal', 'true');
|
|
183
|
+
expect(panel).toHaveClass('ds-slideover');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
36
186
|
});
|
|
@@ -3,18 +3,26 @@ import { Button } from 'Components/button/Button';
|
|
|
3
3
|
import { Heading } from 'Components/heading/Heading';
|
|
4
4
|
import type { IconName } from 'Components/icon/allowedIcons';
|
|
5
5
|
import { Icon } from 'Components/icon/Icon';
|
|
6
|
-
import { useState, type ReactNode } from 'react';
|
|
6
|
+
import { useEffect, useRef, useState, type HTMLAttributes, type ReactNode, type TransitionEvent } from 'react';
|
|
7
7
|
import { useComponentDidMount } from 'Utils/hooks/useComponentDidMount';
|
|
8
8
|
import { SlideoverUtils } from 'Utils/SlideoverUtils';
|
|
9
9
|
|
|
10
|
+
// just over the 300ms transition in slideover.scss
|
|
11
|
+
const CLOSE_TRANSITION_FALLBACK_MS = 400;
|
|
12
|
+
|
|
10
13
|
export type SlideoverProps = {
|
|
11
14
|
title?: string;
|
|
12
15
|
children?: ReactNode;
|
|
13
16
|
footerContents?: ReactNode;
|
|
14
17
|
headerIcon?: IconName;
|
|
18
|
+
headerActions?: ReactNode;
|
|
15
19
|
centerHeaderText?: boolean;
|
|
16
20
|
hideBackButton?: boolean;
|
|
17
|
-
|
|
21
|
+
backButtonText?: string;
|
|
22
|
+
onBack?: () => void;
|
|
23
|
+
isClosing?: boolean;
|
|
24
|
+
onCloseTransitionEnd?: () => void;
|
|
25
|
+
} & Omit<HTMLAttributes<HTMLElement>, 'title' | 'children' | 'className' | 'onTransitionEnd'>;
|
|
18
26
|
|
|
19
27
|
export const Slideover = (props: SlideoverProps) => {
|
|
20
28
|
const {
|
|
@@ -22,8 +30,14 @@ export const Slideover = (props: SlideoverProps) => {
|
|
|
22
30
|
children,
|
|
23
31
|
footerContents,
|
|
24
32
|
headerIcon,
|
|
33
|
+
headerActions,
|
|
25
34
|
centerHeaderText = true,
|
|
26
35
|
hideBackButton,
|
|
36
|
+
backButtonText = 'Back',
|
|
37
|
+
onBack = SlideoverUtils.removeSlideover,
|
|
38
|
+
isClosing = false,
|
|
39
|
+
onCloseTransitionEnd,
|
|
40
|
+
...rest
|
|
27
41
|
} = props;
|
|
28
42
|
|
|
29
43
|
const [isFirstRender, setIsFirstRender] = useState(true);
|
|
@@ -32,19 +46,51 @@ export const Slideover = (props: SlideoverProps) => {
|
|
|
32
46
|
setIsFirstRender(false);
|
|
33
47
|
});
|
|
34
48
|
|
|
49
|
+
const hasFinishedClosingRef = useRef(false);
|
|
50
|
+
const onCloseTransitionEndRef = useRef(onCloseTransitionEnd);
|
|
51
|
+
onCloseTransitionEndRef.current = onCloseTransitionEnd;
|
|
52
|
+
|
|
53
|
+
const finishClosing = () => {
|
|
54
|
+
if (hasFinishedClosingRef.current) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
hasFinishedClosingRef.current = true;
|
|
58
|
+
onCloseTransitionEndRef.current?.();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// transitionend does not fire if the slide-out never runs, so also finish on a timer
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!isClosing) {
|
|
64
|
+
hasFinishedClosingRef.current = false;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const timeout = setTimeout(finishClosing, CLOSE_TRANSITION_FALLBACK_MS);
|
|
68
|
+
return () => clearTimeout(timeout);
|
|
69
|
+
}, [isClosing]);
|
|
70
|
+
|
|
71
|
+
const handleTransitionEnd = (event: TransitionEvent<HTMLElement>) => {
|
|
72
|
+
if (isClosing && event.target === event.currentTarget && event.propertyName === 'right') {
|
|
73
|
+
finishClosing();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
35
77
|
return (
|
|
36
|
-
<aside
|
|
37
|
-
{
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
78
|
+
<aside
|
|
79
|
+
{...rest}
|
|
80
|
+
className={classNames('ds-slideover',
|
|
81
|
+
{
|
|
82
|
+
'ds-slideover--initial': isFirstRender,
|
|
83
|
+
'ds-slideover--closing': isClosing,
|
|
84
|
+
},
|
|
85
|
+
)}
|
|
86
|
+
onTransitionEnd={handleTransitionEnd}
|
|
41
87
|
>
|
|
42
88
|
<div className={classNames('ds-slideover__header', { 'ds-slideover__header--center': centerHeaderText })}>
|
|
43
89
|
{
|
|
44
90
|
!hideBackButton && (
|
|
45
|
-
<Button variant="tertiary" onClick={
|
|
91
|
+
<Button variant="tertiary" onClick={() => onBack()}>
|
|
46
92
|
<Icon name="chevrons-left" />
|
|
47
|
-
|
|
93
|
+
{backButtonText}
|
|
48
94
|
</Button>
|
|
49
95
|
)
|
|
50
96
|
}
|
|
@@ -52,6 +98,7 @@ export const Slideover = (props: SlideoverProps) => {
|
|
|
52
98
|
{title}
|
|
53
99
|
{headerIcon && <Icon name={headerIcon} />}
|
|
54
100
|
</Heading>
|
|
101
|
+
{headerActions && <div className="ds-slideover__header-actions">{headerActions}</div>}
|
|
55
102
|
</div>
|
|
56
103
|
|
|
57
104
|
<div className="ds-slideover__contents">{children}</div>
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
align-items: stretch;
|
|
10
10
|
transition: right 300ms ease;
|
|
11
11
|
|
|
12
|
-
&--initial
|
|
12
|
+
&--initial,
|
|
13
|
+
&--closing {
|
|
13
14
|
right: -580px;
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -34,6 +35,12 @@
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
&__header-actions {
|
|
39
|
+
display: flex;
|
|
40
|
+
align-items: center;
|
|
41
|
+
gap: var(--slideover-header-spacing-padding);
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
&__contents {
|
|
38
45
|
flex-grow: 1;
|
|
39
46
|
overflow-y: scroll;
|
|
@@ -11,6 +11,10 @@ import { SlideoverUtils } from 'Utils/SlideoverUtils';
|
|
|
11
11
|
import { Button } from 'Components/button/Button';
|
|
12
12
|
import { FormField } from 'Components/formField/FormField';
|
|
13
13
|
import { Section } from 'Components/section/Section';
|
|
14
|
+
import { Slideover } from 'Components/slideover/Slideover';
|
|
15
|
+
import { Icon } from 'Components/icon/Icon';
|
|
16
|
+
import { useState } from 'react';
|
|
17
|
+
import { fn } from 'storybook/test';
|
|
14
18
|
|
|
15
19
|
// ---------------------------------------------------------------------------
|
|
16
20
|
// Docs page content
|
|
@@ -49,8 +53,15 @@ const USAGE_GUIDANCE = [
|
|
|
49
53
|
'| `children` | `ReactNode` | — | Main scrollable content |',
|
|
50
54
|
'| `footerContents` | `ReactNode` | — | Sticky footer — use for primary actions (Save / Cancel) |',
|
|
51
55
|
'| `headerIcon` | `IconName` | — | Icon rendered next to the title |',
|
|
56
|
+
'| `headerActions` | `ReactNode` | — | Actions rendered at the end of the header, e.g. an Edit button |',
|
|
52
57
|
'| `centerHeaderText` | `boolean` | `true` | Whether the header text is centred |',
|
|
53
58
|
'| `hideBackButton` | `boolean` | — | Hides the Back chevron button in the header |',
|
|
59
|
+
'| `backButtonText` | `string` | `\'Back\'` | Label for the Back button — pass a translated string |',
|
|
60
|
+
'| `onBack` | `() => void` | `SlideoverUtils.removeSlideover` | Replaces what the Back button does, e.g. to confirm unsaved changes before closing |',
|
|
61
|
+
'| `isClosing` | `boolean` | `false` | Plays the slide-out transition. Keep the panel mounted until `onCloseTransitionEnd` fires |',
|
|
62
|
+
'| `onCloseTransitionEnd` | `() => void` | — | Called once the slide-out transition started by `isClosing` has finished |',
|
|
63
|
+
'',
|
|
64
|
+
'Any other HTML attribute (`role`, `aria-label`, `aria-modal`, `id`, …) is forwarded to the panel\'s `<aside>` element.',
|
|
54
65
|
'',
|
|
55
66
|
'---',
|
|
56
67
|
'',
|
|
@@ -118,9 +129,22 @@ const DEVELOPER_NOTES = [
|
|
|
118
129
|
'',
|
|
119
130
|
'### Accessibility',
|
|
120
131
|
'',
|
|
121
|
-
'- The overlay dims the background
|
|
122
|
-
'- The
|
|
123
|
-
'-
|
|
132
|
+
'- The overlay dims the background. It does **not** trap focus, move focus into the panel on open, or close on Escape — consumers that need modal behaviour must provide it.',
|
|
133
|
+
'- The panel renders as an `<aside>`. Pass `role="dialog"`, `aria-modal` and an `aria-label` when the slideover is used as a dialog.',
|
|
134
|
+
'- The Back button is keyboard accessible and labelled. Pass `backButtonText` to translate it.',
|
|
135
|
+
'- Ensure the `title` prop is always set — it is the visible heading for the panel.',
|
|
136
|
+
].join('\n');
|
|
137
|
+
|
|
138
|
+
const CUSTOM_BEHAVIOUR_NOTES = [
|
|
139
|
+
'### Intercepting Back',
|
|
140
|
+
'',
|
|
141
|
+
'By default the Back button pops the top panel. Pass `onBack` to run your own logic first — for example, asking the user to confirm before unsaved changes are lost — and call `SlideoverUtils.removeSlideover()` yourself when it is safe to close.',
|
|
142
|
+
'',
|
|
143
|
+
'### Animating a panel out',
|
|
144
|
+
'',
|
|
145
|
+
'Panels slide in on mount. To slide one out, set `isClosing` and remove it from your stack in `onCloseTransitionEnd`. This is for consumers rendering `Slideover` from their own stack; `SlideoverUtils.addSlideover` does not accept these props, because `SlideoverManager` cannot update a panel once it is added.',
|
|
146
|
+
'',
|
|
147
|
+
'`onCloseTransitionEnd` fires when the slide-out finishes, or after a short fallback delay if no transition runs (for example when the panel is hidden or motion is reduced), so a closing panel is never left mounted.',
|
|
124
148
|
].join('\n');
|
|
125
149
|
|
|
126
150
|
const RELATED_COMPONENTS = [
|
|
@@ -143,6 +167,7 @@ function SlideoverManagerDocsPage() {
|
|
|
143
167
|
<Markdown>{USAGE_GUIDANCE}</Markdown>
|
|
144
168
|
<DocHeading>Developer notes</DocHeading>
|
|
145
169
|
<Markdown>{DEVELOPER_NOTES}</Markdown>
|
|
170
|
+
<Markdown>{CUSTOM_BEHAVIOUR_NOTES}</Markdown>
|
|
146
171
|
<DocHeading>Examples</DocHeading>
|
|
147
172
|
<Stories title="" />
|
|
148
173
|
<Markdown>{RELATED_COMPONENTS}</Markdown>
|
|
@@ -384,6 +409,89 @@ const HideBackButtonTemplate = () => (
|
|
|
384
409
|
</PageBackground>
|
|
385
410
|
);
|
|
386
411
|
|
|
412
|
+
const ConfirmOnBackTemplate = () => (
|
|
413
|
+
<PageBackground>
|
|
414
|
+
<SlideoverManager slideovers={[]} />
|
|
415
|
+
<Button
|
|
416
|
+
onClick={() =>
|
|
417
|
+
SlideoverUtils.addSlideover({
|
|
418
|
+
title: 'Edit Pupil Details',
|
|
419
|
+
onBack: () => {
|
|
420
|
+
if (window.confirm('You have unsaved changes. Leave without saving?')) {
|
|
421
|
+
SlideoverUtils.removeSlideover();
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
children: (
|
|
425
|
+
<FormField
|
|
426
|
+
id="pupil-name-confirm"
|
|
427
|
+
label="Full name"
|
|
428
|
+
inputType="text"
|
|
429
|
+
inputProps={{ defaultValue: 'Alex Johnson' }}
|
|
430
|
+
/>
|
|
431
|
+
),
|
|
432
|
+
})}
|
|
433
|
+
>
|
|
434
|
+
Edit pupil details
|
|
435
|
+
</Button>
|
|
436
|
+
</PageBackground>
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
const handleEditClick = fn().mockName('headerActions Edit onClick');
|
|
440
|
+
|
|
441
|
+
const HeaderActionsTemplate = () => (
|
|
442
|
+
<PageBackground>
|
|
443
|
+
<SlideoverManager slideovers={[]} />
|
|
444
|
+
<Button
|
|
445
|
+
onClick={() =>
|
|
446
|
+
SlideoverUtils.addSlideover({
|
|
447
|
+
title: 'Pupil: Alex Johnson',
|
|
448
|
+
headerActions: (
|
|
449
|
+
<Button variant="secondary" size="S" onClick={handleEditClick}>
|
|
450
|
+
<Icon name="pencil" />
|
|
451
|
+
Edit
|
|
452
|
+
</Button>
|
|
453
|
+
),
|
|
454
|
+
children: <p>Year 5 · Registration group 5B</p>,
|
|
455
|
+
})}
|
|
456
|
+
>
|
|
457
|
+
View pupil record
|
|
458
|
+
</Button>
|
|
459
|
+
</PageBackground>
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
const ClosingTransitionTemplate = () => {
|
|
463
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
464
|
+
const [isClosing, setIsClosing] = useState(false);
|
|
465
|
+
|
|
466
|
+
return (
|
|
467
|
+
<PageBackground>
|
|
468
|
+
<Button
|
|
469
|
+
onClick={() => {
|
|
470
|
+
setIsClosing(false);
|
|
471
|
+
setIsOpen(true);
|
|
472
|
+
}}
|
|
473
|
+
>
|
|
474
|
+
Open slideover
|
|
475
|
+
</Button>
|
|
476
|
+
{isOpen && (
|
|
477
|
+
<div className="ds-slideover-manager ds-slideover-manager--active">
|
|
478
|
+
<Slideover
|
|
479
|
+
title="Pupil Record"
|
|
480
|
+
onBack={() => setIsClosing(true)}
|
|
481
|
+
isClosing={isClosing}
|
|
482
|
+
onCloseTransitionEnd={() => setIsOpen(false)}
|
|
483
|
+
role="dialog"
|
|
484
|
+
aria-modal
|
|
485
|
+
aria-label="Pupil Record"
|
|
486
|
+
>
|
|
487
|
+
<p>Press Back to watch the panel slide out before it is removed.</p>
|
|
488
|
+
</Slideover>
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
</PageBackground>
|
|
492
|
+
);
|
|
493
|
+
};
|
|
494
|
+
|
|
387
495
|
// ---------------------------------------------------------------------------
|
|
388
496
|
// Stories
|
|
389
497
|
// ---------------------------------------------------------------------------
|
|
@@ -602,3 +710,39 @@ export default RemovePupilButton;
|
|
|
602
710
|
'Without a Back button, the user must interact with the footer actions — reducing accidental dismissal.',
|
|
603
711
|
].join(' '),
|
|
604
712
|
);
|
|
713
|
+
|
|
714
|
+
export const ConfirmOnBack: Story = withDescription(
|
|
715
|
+
{
|
|
716
|
+
render: ConfirmOnBackTemplate,
|
|
717
|
+
parameters: { controls: { disable: true } },
|
|
718
|
+
},
|
|
719
|
+
[
|
|
720
|
+
'Pass `onBack` to replace what the Back button does.',
|
|
721
|
+
'Here it asks for confirmation before closing, the pattern for protecting unsaved changes.',
|
|
722
|
+
'Call `SlideoverUtils.removeSlideover()` yourself once it is safe to close.',
|
|
723
|
+
].join(' '),
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
export const HeaderActions: Story = withDescription(
|
|
727
|
+
{
|
|
728
|
+
render: HeaderActionsTemplate,
|
|
729
|
+
parameters: { controls: { disable: true } },
|
|
730
|
+
},
|
|
731
|
+
[
|
|
732
|
+
'Use `headerActions` to place actions such as Edit at the end of the header.',
|
|
733
|
+
'Clicking Edit here logs to the Actions panel.',
|
|
734
|
+
'Pass `backButtonText` to replace the Back label, for example with a translated string.',
|
|
735
|
+
].join(' '),
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
export const ClosingTransition: Story = withDescription(
|
|
739
|
+
{
|
|
740
|
+
render: ClosingTransitionTemplate,
|
|
741
|
+
parameters: { controls: { disable: true } },
|
|
742
|
+
},
|
|
743
|
+
[
|
|
744
|
+
'Set `isClosing` to slide a panel out, and unmount it in `onCloseTransitionEnd`.',
|
|
745
|
+
'This example renders `Slideover` from its own state rather than through `SlideoverManager`,',
|
|
746
|
+
'and forwards `role`, `aria-modal` and `aria-label` to use the panel as a dialog.',
|
|
747
|
+
].join(' '),
|
|
748
|
+
);
|
|
@@ -166,7 +166,7 @@ const groupedItems: WidgetCalendar.ItemData[] = [
|
|
|
166
166
|
|
|
167
167
|
const DESCRIPTION_INTRO = [
|
|
168
168
|
'`WidgetCalendar` is a pre-composed dashboard widget for date/time-anchored calendar events.',
|
|
169
|
-
'It wraps [`Widget`](?path=/docs/components-widget--docs) with an event list,
|
|
169
|
+
'It wraps [`Widget`](?path=/docs/components-widget--docs) with an event list, an eight-value event type indicator, and',
|
|
170
170
|
'optional heading navigation. Each item renders a semantic `<time>` element from an ISO `eventDateTime` and a',
|
|
171
171
|
'human-readable `eventTimeLabel`.',
|
|
172
172
|
].join('\n');
|
|
@@ -214,9 +214,12 @@ const DEVELOPER_NOTES = [
|
|
|
214
214
|
'fall into the ungrouped bucket, which renders above all groups to prevent ambiguity about which group',
|
|
215
215
|
'an item belongs to. Groups appear in the order their labels are first declared.',
|
|
216
216
|
'',
|
|
217
|
-
'
|
|
218
|
-
'
|
|
219
|
-
'
|
|
217
|
+
'**`eventType` selects one of eight type-bar colours.** The accepted values are `primary` (the default),',
|
|
218
|
+
'`secondary`, `destructive`, `caution`, `info`, `warning`, `green`, and `purple`, matching the eight index',
|
|
219
|
+
'colours in the design. The colour is chosen by the school per event and carries no meaning of its own —',
|
|
220
|
+
'`WidgetCalendar` attaches no semantics to the value, so the same colour can represent different event',
|
|
221
|
+
'categories in different schools. Pass the value the data source supplies rather than inferring one from',
|
|
222
|
+
'the event content.',
|
|
220
223
|
'',
|
|
221
224
|
'**No loading, disabled, or error state exists on this component.** Handle those states in the consumer',
|
|
222
225
|
'(e.g. render a Skeleton in place of `WidgetCalendar` while data loads). `isLoading` and `loadingLabel` are',
|
|
@@ -688,6 +691,80 @@ export const SecondaryEventType: Story = withDescription(
|
|
|
688
691
|
'Isolates `eventType="secondary"` — the neutral grey type bar, used for lower-priority or generic events.',
|
|
689
692
|
);
|
|
690
693
|
|
|
694
|
+
export const AllEventTypes: Story = withDescription(
|
|
695
|
+
{
|
|
696
|
+
args: {
|
|
697
|
+
heading: 'Calendar',
|
|
698
|
+
items: [
|
|
699
|
+
{
|
|
700
|
+
id: 'type-primary',
|
|
701
|
+
eventType: 'primary',
|
|
702
|
+
eventPrimaryTerm: 'Class',
|
|
703
|
+
eventPrimaryDefinition: 'Year 10: 10G',
|
|
704
|
+
...classEventTime,
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
id: 'type-secondary',
|
|
708
|
+
eventType: 'secondary',
|
|
709
|
+
eventPrimaryTerm: 'Class',
|
|
710
|
+
eventPrimaryDefinition: 'Year 8: 8B',
|
|
711
|
+
...classEventTime,
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
id: 'type-destructive',
|
|
715
|
+
eventType: 'destructive',
|
|
716
|
+
eventPrimaryTerm: 'Exam',
|
|
717
|
+
eventPrimaryDefinition: 'GCSE Maths: Paper 1',
|
|
718
|
+
...classEventTime,
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
id: 'type-caution',
|
|
722
|
+
eventType: 'caution',
|
|
723
|
+
eventPrimaryTerm: 'Trip',
|
|
724
|
+
eventPrimaryDefinition: 'Geography field trip',
|
|
725
|
+
...classEventTime,
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
id: 'type-info',
|
|
729
|
+
eventType: 'info',
|
|
730
|
+
eventPrimaryTerm: 'Meeting',
|
|
731
|
+
eventPrimaryDefinition: 'Parents\' evening',
|
|
732
|
+
...classEventTime,
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
id: 'type-warning',
|
|
736
|
+
eventType: 'warning',
|
|
737
|
+
eventPrimaryTerm: 'Deadline',
|
|
738
|
+
eventPrimaryDefinition: 'Reports due to head of year',
|
|
739
|
+
...classEventTime,
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
id: 'type-green',
|
|
743
|
+
eventType: 'green',
|
|
744
|
+
eventPrimaryTerm: 'Club',
|
|
745
|
+
eventPrimaryDefinition: 'Football: Year 9',
|
|
746
|
+
...classEventTime,
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
id: 'type-purple',
|
|
750
|
+
eventType: 'purple',
|
|
751
|
+
eventPrimaryTerm: 'Assembly',
|
|
752
|
+
eventPrimaryDefinition: 'Whole-school assembly',
|
|
753
|
+
...classEventTime,
|
|
754
|
+
},
|
|
755
|
+
],
|
|
756
|
+
onItemClick: fn(),
|
|
757
|
+
},
|
|
758
|
+
render: args => <WidgetCalendar {...args} />,
|
|
759
|
+
parameters: { controls: { disable: true } },
|
|
760
|
+
},
|
|
761
|
+
[
|
|
762
|
+
'All eight `eventType` values side by side, so the full set of type-bar colours can be compared in one view.',
|
|
763
|
+
'The colour is chosen by the school per event — `WidgetCalendar` attaches no semantics to any of the eight',
|
|
764
|
+
'values. See the Developer notes above for the full list.',
|
|
765
|
+
].join(' '),
|
|
766
|
+
);
|
|
767
|
+
|
|
691
768
|
export const NonInteractiveItems: Story = withDescription(
|
|
692
769
|
{
|
|
693
770
|
args: {
|
|
@@ -60,6 +60,54 @@ describe('WidgetCalendar', () => {
|
|
|
60
60
|
expect(container.querySelector('.ds-widget-calendar__item--secondary')).toBeInTheDocument();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
const eventTypes: WidgetCalendar.EventType[] = [
|
|
64
|
+
'primary',
|
|
65
|
+
'secondary',
|
|
66
|
+
'destructive',
|
|
67
|
+
'caution',
|
|
68
|
+
'info',
|
|
69
|
+
'warning',
|
|
70
|
+
'green',
|
|
71
|
+
'purple',
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
test.each(eventTypes)('applies the %s modifier class and no other event type class', (eventType) => {
|
|
75
|
+
const { container } = render(
|
|
76
|
+
<WidgetCalendar
|
|
77
|
+
heading="Calendar"
|
|
78
|
+
items={[{ ...mockItems[0]!, eventType }]}
|
|
79
|
+
/>,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const item = container.querySelector('.ds-widget-calendar__item');
|
|
83
|
+
|
|
84
|
+
expect(item).toHaveClass(`ds-widget-calendar__item--${eventType}`);
|
|
85
|
+
|
|
86
|
+
eventTypes
|
|
87
|
+
.filter(otherType => otherType !== eventType)
|
|
88
|
+
.forEach((otherType) => {
|
|
89
|
+
expect(item).not.toHaveClass(`ds-widget-calendar__item--${otherType}`);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('defaults to the primary modifier class when eventType is omitted', () => {
|
|
94
|
+
const { container } = render(
|
|
95
|
+
<WidgetCalendar
|
|
96
|
+
heading="Calendar"
|
|
97
|
+
items={[{
|
|
98
|
+
id: 'no-type',
|
|
99
|
+
eventPrimaryTerm: 'Class',
|
|
100
|
+
eventPrimaryDefinition: 'Year 10: 10G',
|
|
101
|
+
eventSecondaryTerm: 'Time and location',
|
|
102
|
+
eventDateTime: '2026-06-08T08:45/2026-06-08T09:00',
|
|
103
|
+
eventTimeLabel: '08:45 – 09:00',
|
|
104
|
+
}]}
|
|
105
|
+
/>,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(container.querySelector('.ds-widget-calendar__item')).toHaveClass('ds-widget-calendar__item--primary');
|
|
109
|
+
});
|
|
110
|
+
|
|
63
111
|
test('calls onItemClick with item id', async () => {
|
|
64
112
|
const user = userEvent.setup();
|
|
65
113
|
const onItemClick = vi.fn();
|
|
@@ -3,7 +3,15 @@ import { Icon } from 'Components/icon/Icon';
|
|
|
3
3
|
import { TooltipWrapper } from 'Components/tooltip/TooltipWrapper';
|
|
4
4
|
import { ENTER_KEY, SPACE_KEY } from 'Utils/keyboardConstants';
|
|
5
5
|
|
|
6
|
-
export type WidgetCalendarEventType
|
|
6
|
+
export type WidgetCalendarEventType
|
|
7
|
+
= 'primary'
|
|
8
|
+
| 'secondary'
|
|
9
|
+
| 'destructive'
|
|
10
|
+
| 'caution'
|
|
11
|
+
| 'info'
|
|
12
|
+
| 'warning'
|
|
13
|
+
| 'green'
|
|
14
|
+
| 'purple';
|
|
7
15
|
|
|
8
16
|
export type WidgetCalendarItemProps = {
|
|
9
17
|
id: string;
|
|
@@ -68,9 +76,7 @@ export const WidgetCalendarItem = (props: WidgetCalendarItemProps): React.JSX.El
|
|
|
68
76
|
<li
|
|
69
77
|
className={classNames(
|
|
70
78
|
'ds-widget-calendar__item',
|
|
71
|
-
eventType
|
|
72
|
-
? 'ds-widget-calendar__item--secondary'
|
|
73
|
-
: 'ds-widget-calendar__item--primary',
|
|
79
|
+
`ds-widget-calendar__item--${eventType}`,
|
|
74
80
|
isInteractive && 'ds-widget-calendar__item--interactive',
|
|
75
81
|
className,
|
|
76
82
|
)}
|