@squiz/generic-browser-lib 1.35.1-alpha.34
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/main.ts +23 -0
- package/.storybook/preview-head.html +15 -0
- package/.storybook/preview.ts +16 -0
- package/README.md +10 -0
- package/build.js +21 -0
- package/jest.config.ts +29 -0
- package/lib/Hooks/useAsync.d.ts +21 -0
- package/lib/Hooks/useAsync.js +53 -0
- package/lib/Icons/Generics/ArrowDown.d.ts +15 -0
- package/lib/Icons/Generics/ArrowDown.js +23 -0
- package/lib/Icons/Generics/ArrowRight.d.ts +15 -0
- package/lib/Icons/Generics/ArrowRight.js +23 -0
- package/lib/Icons/Generics/Back.d.ts +4 -0
- package/lib/Icons/Generics/Back.js +12 -0
- package/lib/Icons/Generics/Close.d.ts +15 -0
- package/lib/Icons/Generics/Close.js +23 -0
- package/lib/Icons/Generics/Empty.d.ts +4 -0
- package/lib/Icons/Generics/Empty.js +12 -0
- package/lib/Icons/Generics/Error.d.ts +4 -0
- package/lib/Icons/Generics/Error.js +12 -0
- package/lib/Icons/Generics/GenericIconMap.d.ts +14 -0
- package/lib/Icons/Generics/GenericIconMap.js +18 -0
- package/lib/Icons/Generics/ResourceSelect.d.ts +15 -0
- package/lib/Icons/Generics/ResourceSelect.js +28 -0
- package/lib/Icons/Generics/Retry.d.ts +4 -0
- package/lib/Icons/Generics/Retry.js +12 -0
- package/lib/Icons/Generics/Root.d.ts +15 -0
- package/lib/Icons/Generics/Root.js +23 -0
- package/lib/Icons/Generics/Selected.d.ts +15 -0
- package/lib/Icons/Generics/Selected.js +23 -0
- package/lib/Icons/Generics/index.d.ts +10 -0
- package/lib/Icons/Generics/index.js +27 -0
- package/lib/Icons/Icon.d.ts +50 -0
- package/lib/Icons/Icon.js +42 -0
- package/lib/Icons/MatrixResources/Audio.d.ts +15 -0
- package/lib/Icons/MatrixResources/Audio.js +28 -0
- package/lib/Icons/MatrixResources/Excel.d.ts +15 -0
- package/lib/Icons/MatrixResources/Excel.js +27 -0
- package/lib/Icons/MatrixResources/Folder.d.ts +15 -0
- package/lib/Icons/MatrixResources/Folder.js +24 -0
- package/lib/Icons/MatrixResources/GenericFile.d.ts +15 -0
- package/lib/Icons/MatrixResources/GenericFile.js +28 -0
- package/lib/Icons/MatrixResources/Image.d.ts +15 -0
- package/lib/Icons/MatrixResources/Image.js +26 -0
- package/lib/Icons/MatrixResources/MatrixResourceMap.d.ts +15 -0
- package/lib/Icons/MatrixResources/MatrixResourceMap.js +19 -0
- package/lib/Icons/MatrixResources/Page.d.ts +15 -0
- package/lib/Icons/MatrixResources/Page.js +30 -0
- package/lib/Icons/MatrixResources/Pdf.d.ts +15 -0
- package/lib/Icons/MatrixResources/Pdf.js +31 -0
- package/lib/Icons/MatrixResources/Powerpoint.d.ts +15 -0
- package/lib/Icons/MatrixResources/Powerpoint.js +28 -0
- package/lib/Icons/MatrixResources/Site.d.ts +15 -0
- package/lib/Icons/MatrixResources/Site.js +30 -0
- package/lib/Icons/MatrixResources/Video.d.ts +15 -0
- package/lib/Icons/MatrixResources/Video.js +24 -0
- package/lib/Icons/MatrixResources/Word.d.ts +17 -0
- package/lib/Icons/MatrixResources/Word.js +28 -0
- package/lib/Icons/MatrixResources/index.d.ts +11 -0
- package/lib/Icons/MatrixResources/index.js +29 -0
- package/lib/Modal/Modal.d.ts +10 -0
- package/lib/Modal/Modal.js +47 -0
- package/lib/Modal/ModalOpeningButton.d.ts +10 -0
- package/lib/Modal/ModalOpeningButton.js +14 -0
- package/lib/Modal/ModalTrigger.d.ts +9 -0
- package/lib/Modal/ModalTrigger.js +26 -0
- package/lib/PreviewPanel/PreviewModal.d.ts +11 -0
- package/lib/PreviewPanel/PreviewModal.js +79 -0
- package/lib/PreviewPanel/PreviewPanel.d.ts +13 -0
- package/lib/PreviewPanel/PreviewPanel.js +58 -0
- package/lib/PreviewPanel/PreviewPanelHOC.d.ts +6 -0
- package/lib/PreviewPanel/PreviewPanelHOC.js +16 -0
- package/lib/ResetButton/ResetButton.d.ts +5 -0
- package/lib/ResetButton/ResetButton.js +12 -0
- package/lib/ResourceItem/ResourceItem.d.ts +19 -0
- package/lib/ResourceItem/ResourceItem.js +29 -0
- package/lib/ResourceState/ResourceState.d.ts +7 -0
- package/lib/ResourceState/ResourceState.js +17 -0
- package/lib/Skeleton/List/SkeletonList.d.ts +6 -0
- package/lib/Skeleton/List/SkeletonList.js +13 -0
- package/lib/Skeleton/ListItem/SkeletonListItem.d.ts +1 -0
- package/lib/Skeleton/ListItem/SkeletonListItem.js +15 -0
- package/lib/Spinner/Spinner.d.ts +7 -0
- package/lib/Spinner/Spinner.js +13 -0
- package/lib/index.css +885 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.js +28 -0
- package/package.json +76 -0
- package/postcss.config.js +11 -0
- package/src/Hooks/useAsync.spec.ts +106 -0
- package/src/Hooks/useAsync.ts +62 -0
- package/src/Icons/Generics/ArrowDown.tsx +27 -0
- package/src/Icons/Generics/ArrowRight.tsx +27 -0
- package/src/Icons/Generics/Back.tsx +13 -0
- package/src/Icons/Generics/Close.tsx +26 -0
- package/src/Icons/Generics/Empty.tsx +13 -0
- package/src/Icons/Generics/Error.tsx +13 -0
- package/src/Icons/Generics/GenericIconMap.ts +18 -0
- package/src/Icons/Generics/ResourceSelect.tsx +40 -0
- package/src/Icons/Generics/Retry.tsx +13 -0
- package/src/Icons/Generics/Root.tsx +24 -0
- package/src/Icons/Generics/Selected.tsx +27 -0
- package/src/Icons/Generics/index.tsx +11 -0
- package/src/Icons/Icon.spec.tsx +62 -0
- package/src/Icons/Icon.stories.tsx +110 -0
- package/src/Icons/Icon.tsx +54 -0
- package/src/Icons/MatrixResources/Audio.tsx +30 -0
- package/src/Icons/MatrixResources/Excel.tsx +29 -0
- package/src/Icons/MatrixResources/Folder.tsx +29 -0
- package/src/Icons/MatrixResources/GenericFile.tsx +34 -0
- package/src/Icons/MatrixResources/Image.tsx +36 -0
- package/src/Icons/MatrixResources/MatrixResourceMap.ts +19 -0
- package/src/Icons/MatrixResources/Page.tsx +33 -0
- package/src/Icons/MatrixResources/Pdf.tsx +34 -0
- package/src/Icons/MatrixResources/Powerpoint.tsx +34 -0
- package/src/Icons/MatrixResources/Site.tsx +37 -0
- package/src/Icons/MatrixResources/Video.tsx +27 -0
- package/src/Icons/MatrixResources/Word.tsx +30 -0
- package/src/Icons/MatrixResources/index.tsx +12 -0
- package/src/Modal/Modal.spec.tsx +269 -0
- package/src/Modal/Modal.tsx +55 -0
- package/src/Modal/ModalContainer.stories.tsx +33 -0
- package/src/Modal/ModalOpeningButton.tsx +20 -0
- package/src/Modal/ModalTrigger.tsx +54 -0
- package/src/PreviewPanel/PreviewModal.spec.tsx +164 -0
- package/src/PreviewPanel/PreviewModal.tsx +94 -0
- package/src/PreviewPanel/PreviewPanel.spec.tsx +162 -0
- package/src/PreviewPanel/PreviewPanel.stories.tsx +66 -0
- package/src/PreviewPanel/PreviewPanel.tsx +83 -0
- package/src/PreviewPanel/PreviewPanelHOC.spec.tsx +45 -0
- package/src/PreviewPanel/PreviewPanelHOC.tsx +17 -0
- package/src/ResetButton/ResetButton.spec.tsx +42 -0
- package/src/ResetButton/ResetButton.tsx +22 -0
- package/src/ResourceItem/ResourceItem.spec.tsx +65 -0
- package/src/ResourceItem/ResourceItem.tsx +90 -0
- package/src/ResourceState/ResourceState.spec.tsx +26 -0
- package/src/ResourceState/ResourceState.stories.tsx +24 -0
- package/src/ResourceState/ResourceState.tsx +31 -0
- package/src/Skeleton/List/SkeletonList.spec.tsx +18 -0
- package/src/Skeleton/List/SkeletonList.stories.tsx +15 -0
- package/src/Skeleton/List/SkeletonList.tsx +20 -0
- package/src/Skeleton/ListItem/SkeletonListItem.stories.tsx +15 -0
- package/src/Skeleton/ListItem/SkeletonListItem.tsx +14 -0
- package/src/Skeleton/_skeleton.scss +15 -0
- package/src/Spinner/Spinner.spec.tsx +18 -0
- package/src/Spinner/Spinner.stories.tsx +26 -0
- package/src/Spinner/Spinner.tsx +16 -0
- package/src/Spinner/_spinner.scss +14 -0
- package/src/index.scss +22 -0
- package/src/index.stories.tsx +26 -0
- package/src/index.ts +20 -0
- package/tailwind.config.cjs +89 -0
- package/tsconfig.json +22 -0
@@ -0,0 +1,269 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import { screen, render, waitFor } from '@testing-library/react';
|
3
|
+
import userEvent from '@testing-library/user-event';
|
4
|
+
|
5
|
+
import { ModalTrigger } from './ModalTrigger';
|
6
|
+
|
7
|
+
describe('Modal', () => {
|
8
|
+
it('Modal is closed by default', async () => {
|
9
|
+
render(
|
10
|
+
<div>
|
11
|
+
<ModalTrigger label={'Open testing modal'} showLabel>
|
12
|
+
{(onClose, titleProps) => (
|
13
|
+
<div data-testingid="modal">
|
14
|
+
<div {...titleProps}>Testing</div>
|
15
|
+
<button type="button" onClick={onClose}>
|
16
|
+
Close
|
17
|
+
</button>
|
18
|
+
</div>
|
19
|
+
)}
|
20
|
+
</ModalTrigger>
|
21
|
+
<div style={{ height: '150vh' }} />
|
22
|
+
</div>,
|
23
|
+
);
|
24
|
+
expect(screen.queryByTestId('modal')).toBeFalsy();
|
25
|
+
});
|
26
|
+
|
27
|
+
it('Modal opens when triggered', async () => {
|
28
|
+
render(
|
29
|
+
<div>
|
30
|
+
<ModalTrigger label={'Open testing modal'} showLabel>
|
31
|
+
{(onClose, titleProps) => (
|
32
|
+
<div data-testid="modal">
|
33
|
+
<div {...titleProps}>Testing</div>
|
34
|
+
<button type="button" onClick={onClose}>
|
35
|
+
Close
|
36
|
+
</button>
|
37
|
+
</div>
|
38
|
+
)}
|
39
|
+
</ModalTrigger>
|
40
|
+
<div style={{ height: '150vh' }} />
|
41
|
+
</div>,
|
42
|
+
);
|
43
|
+
|
44
|
+
const user = userEvent.setup();
|
45
|
+
user.click(screen.getByText('Open testing modal'));
|
46
|
+
|
47
|
+
await waitFor(() => {
|
48
|
+
expect(screen.queryByTestId('modal')).toBeTruthy();
|
49
|
+
});
|
50
|
+
});
|
51
|
+
|
52
|
+
it('Clicking outside the modal closes it', async () => {
|
53
|
+
render(
|
54
|
+
<div data-testid="outside">
|
55
|
+
<ModalTrigger label={'Open testing modal'} showLabel>
|
56
|
+
{(onClose, titleProps) => (
|
57
|
+
<div data-testid="modal">
|
58
|
+
<div {...titleProps}>Testing</div>
|
59
|
+
<button type="button" onClick={onClose}>
|
60
|
+
Close
|
61
|
+
</button>
|
62
|
+
</div>
|
63
|
+
)}
|
64
|
+
</ModalTrigger>
|
65
|
+
<div style={{ height: '150vh' }} />
|
66
|
+
</div>,
|
67
|
+
);
|
68
|
+
|
69
|
+
const user = userEvent.setup();
|
70
|
+
user.click(screen.getByText('Open testing modal'));
|
71
|
+
|
72
|
+
await waitFor(() => {
|
73
|
+
expect(screen.queryByTestId('modal')).toBeTruthy();
|
74
|
+
});
|
75
|
+
|
76
|
+
user.click(screen.getByTestId('outside'));
|
77
|
+
|
78
|
+
await waitFor(() => {
|
79
|
+
expect(screen.queryByTestId('modal')).toBeFalsy();
|
80
|
+
});
|
81
|
+
});
|
82
|
+
|
83
|
+
it('ESC closes modal', async () => {
|
84
|
+
render(
|
85
|
+
<div data-testid="outside">
|
86
|
+
<ModalTrigger label={'Open testing modal'} showLabel>
|
87
|
+
{(onClose, titleProps) => (
|
88
|
+
<div data-testid="modal">
|
89
|
+
<div {...titleProps}>Testing</div>
|
90
|
+
<button type="button" onClick={onClose}>
|
91
|
+
Close
|
92
|
+
</button>
|
93
|
+
</div>
|
94
|
+
)}
|
95
|
+
</ModalTrigger>
|
96
|
+
<div style={{ height: '150vh' }} />
|
97
|
+
</div>,
|
98
|
+
);
|
99
|
+
|
100
|
+
const user = userEvent.setup();
|
101
|
+
user.click(screen.getByText('Open testing modal'));
|
102
|
+
|
103
|
+
await waitFor(() => {
|
104
|
+
expect(screen.queryByTestId('modal')).toBeTruthy();
|
105
|
+
});
|
106
|
+
|
107
|
+
user.type(screen.getByTestId('modal'), '{escape}');
|
108
|
+
|
109
|
+
await waitFor(() => {
|
110
|
+
expect(screen.queryByTestId('modal')).toBeFalsy();
|
111
|
+
});
|
112
|
+
});
|
113
|
+
|
114
|
+
it('Invoking onClose function closes modal', async () => {
|
115
|
+
render(
|
116
|
+
<div>
|
117
|
+
<ModalTrigger label={'Open testing modal'} showLabel>
|
118
|
+
{(onClose, titleProps) => (
|
119
|
+
<div data-testid="modal">
|
120
|
+
<div {...titleProps}>Testing</div>
|
121
|
+
<button data-testid="closeButton" type="button" onClick={onClose}>
|
122
|
+
Close
|
123
|
+
</button>
|
124
|
+
</div>
|
125
|
+
)}
|
126
|
+
</ModalTrigger>
|
127
|
+
<div style={{ height: '150vh' }} />
|
128
|
+
</div>,
|
129
|
+
);
|
130
|
+
|
131
|
+
const user = userEvent.setup();
|
132
|
+
user.click(screen.getByText('Open testing modal'));
|
133
|
+
|
134
|
+
await waitFor(() => {
|
135
|
+
expect(screen.queryByTestId('modal')).toBeTruthy();
|
136
|
+
});
|
137
|
+
|
138
|
+
user.click(screen.getByTestId('closeButton'));
|
139
|
+
|
140
|
+
await waitFor(() => {
|
141
|
+
expect(screen.queryByTestId('modal')).toBeFalsy();
|
142
|
+
});
|
143
|
+
});
|
144
|
+
|
145
|
+
it('Modal does not open if modal trigger button is disabled', async () => {
|
146
|
+
render(
|
147
|
+
<div>
|
148
|
+
<ModalTrigger label={'Open testing modal'} showLabel isDisabled={true}>
|
149
|
+
{(onClose, titleProps) => (
|
150
|
+
<div data-testid="modal">
|
151
|
+
<div {...titleProps}>Testing</div>
|
152
|
+
<button type="button" onClick={onClose}>
|
153
|
+
Close
|
154
|
+
</button>
|
155
|
+
</div>
|
156
|
+
)}
|
157
|
+
</ModalTrigger>
|
158
|
+
<div style={{ height: '150vh' }} />
|
159
|
+
</div>,
|
160
|
+
);
|
161
|
+
|
162
|
+
const user = userEvent.setup();
|
163
|
+
user.click(screen.getByText('Open testing modal'));
|
164
|
+
|
165
|
+
await waitFor(() => {
|
166
|
+
expect(screen.queryByTestId('modal')).not.toBeTruthy();
|
167
|
+
});
|
168
|
+
});
|
169
|
+
|
170
|
+
it('Focus is trapped within modal', async () => {
|
171
|
+
render(
|
172
|
+
<div>
|
173
|
+
<button />
|
174
|
+
<ModalTrigger label={'Open testing modal'} showLabel>
|
175
|
+
{(onClose, titleProps) => (
|
176
|
+
<div data-testid="modal">
|
177
|
+
<div {...titleProps}>Testing</div>
|
178
|
+
<button data-testid="closeButton" type="button" onClick={onClose}>
|
179
|
+
Close
|
180
|
+
</button>
|
181
|
+
</div>
|
182
|
+
)}
|
183
|
+
</ModalTrigger>
|
184
|
+
<button />
|
185
|
+
<div style={{ height: '150vh' }} />
|
186
|
+
</div>,
|
187
|
+
);
|
188
|
+
|
189
|
+
const user = userEvent.setup();
|
190
|
+
user.click(screen.getByText('Open testing modal'));
|
191
|
+
|
192
|
+
await waitFor(() => {
|
193
|
+
expect(screen.queryByTestId('modal')).toBeTruthy();
|
194
|
+
});
|
195
|
+
|
196
|
+
user.type(screen.getByTestId('modal'), '{tab}');
|
197
|
+
user.type(screen.getByTestId('modal'), '{tab}');
|
198
|
+
|
199
|
+
await waitFor(() => {
|
200
|
+
expect(screen.queryByTestId('closeButton')).toHaveFocus();
|
201
|
+
});
|
202
|
+
});
|
203
|
+
|
204
|
+
it('Focus should start on the dialog', async () => {
|
205
|
+
render(
|
206
|
+
<div>
|
207
|
+
<button />
|
208
|
+
<ModalTrigger label={'Open testing modal'} showLabel>
|
209
|
+
{(onClose, titleProps) => (
|
210
|
+
<div data-testid="modal">
|
211
|
+
<div data-testid="title" {...titleProps}>
|
212
|
+
Testing
|
213
|
+
</div>
|
214
|
+
<button data-testid="closeButton" type="button" onClick={onClose}>
|
215
|
+
Close
|
216
|
+
</button>
|
217
|
+
</div>
|
218
|
+
)}
|
219
|
+
</ModalTrigger>
|
220
|
+
<button />
|
221
|
+
<div style={{ height: '150vh' }} />
|
222
|
+
</div>,
|
223
|
+
);
|
224
|
+
|
225
|
+
const user = userEvent.setup();
|
226
|
+
user.click(screen.getByText('Open testing modal'));
|
227
|
+
|
228
|
+
await waitFor(() => {
|
229
|
+
expect(screen.queryByTestId('modal')).toBeTruthy();
|
230
|
+
expect(screen.queryByRole('dialog')).toHaveFocus();
|
231
|
+
});
|
232
|
+
});
|
233
|
+
|
234
|
+
it('Title props provides id for dialog link', async () => {
|
235
|
+
let titleId: string | undefined = '';
|
236
|
+
render(
|
237
|
+
<div>
|
238
|
+
<button />
|
239
|
+
<ModalTrigger label={'Open testing modal'} showLabel>
|
240
|
+
{(onClose, titleProps) => {
|
241
|
+
titleId = titleProps.id;
|
242
|
+
|
243
|
+
return (
|
244
|
+
<div data-testid="modal">
|
245
|
+
<div data-testid="title" {...titleProps}>
|
246
|
+
Testing
|
247
|
+
</div>
|
248
|
+
<button data-testid="closeButton" type="button" onClick={onClose}>
|
249
|
+
Close
|
250
|
+
</button>
|
251
|
+
</div>
|
252
|
+
);
|
253
|
+
}}
|
254
|
+
</ModalTrigger>
|
255
|
+
<button />
|
256
|
+
<div style={{ height: '150vh' }} />
|
257
|
+
</div>,
|
258
|
+
);
|
259
|
+
|
260
|
+
const user = userEvent.setup();
|
261
|
+
user.click(screen.getByText('Open testing modal'));
|
262
|
+
|
263
|
+
await waitFor(() => {
|
264
|
+
expect(screen.queryByTestId('modal')).toBeTruthy();
|
265
|
+
expect(screen.queryByTestId('title')).toHaveAttribute('id'); // Title was provided an id by 'titleProps'
|
266
|
+
expect(screen.queryByRole('dialog')).toHaveAttribute('aria-labelledby', titleId); // 'titleProps' id is added to the dialog label
|
267
|
+
});
|
268
|
+
});
|
269
|
+
});
|
@@ -0,0 +1,55 @@
|
|
1
|
+
import React, { useRef } from 'react';
|
2
|
+
|
3
|
+
import { Overlay, useModalOverlay, useDialog } from 'react-aria';
|
4
|
+
import { OverlayTriggerState } from 'react-stately';
|
5
|
+
import { DOMAttributes, FocusableElement } from '@react-types/shared';
|
6
|
+
|
7
|
+
/*
|
8
|
+
This has to be a separate element otherwise the focus trap fails. Assuming this is because it needs
|
9
|
+
to fit inside the 'Overlay' as a form of context.
|
10
|
+
*/
|
11
|
+
function ModalContent({
|
12
|
+
children,
|
13
|
+
...props
|
14
|
+
}: {
|
15
|
+
children: (titleProps: DOMAttributes<FocusableElement>) => React.ReactElement;
|
16
|
+
}) {
|
17
|
+
const ref = useRef<HTMLDivElement>(null);
|
18
|
+
const { dialogProps, titleProps } = useDialog(props, ref);
|
19
|
+
|
20
|
+
return (
|
21
|
+
<div
|
22
|
+
{...dialogProps}
|
23
|
+
ref={ref}
|
24
|
+
className="z-50 relative bg-white rounded-lg h-screen lg:h-[calc(100vh-3.5rem)] w-screen max-w-screen-lg"
|
25
|
+
>
|
26
|
+
{children(titleProps)}
|
27
|
+
</div>
|
28
|
+
);
|
29
|
+
}
|
30
|
+
|
31
|
+
export type ModalProps = {
|
32
|
+
isDismissable?: boolean;
|
33
|
+
state: OverlayTriggerState;
|
34
|
+
overlayProps: DOMAttributes<FocusableElement>;
|
35
|
+
children: (titleProps: DOMAttributes<FocusableElement>) => React.ReactElement; // Returns the title props from the 'ModalContent'
|
36
|
+
};
|
37
|
+
export function Modal({ isDismissable, state, overlayProps, children, ...props }: ModalProps) {
|
38
|
+
const ref = useRef<HTMLDivElement>(null);
|
39
|
+
const { modalProps, underlayProps } = useModalOverlay({ isDismissable, ...props }, state, ref);
|
40
|
+
|
41
|
+
return (
|
42
|
+
<Overlay>
|
43
|
+
<div className="squiz-gb-scope">
|
44
|
+
<div
|
45
|
+
{...underlayProps}
|
46
|
+
className="h-full z-[9998] fixed inset-0 before:z-40 before:fixed before:inset-0 before:bg-black before:bg-opacity-25"
|
47
|
+
>
|
48
|
+
<div {...modalProps} ref={ref} className="h-full flex items-center justify-center">
|
49
|
+
<ModalContent {...overlayProps}>{(titleProps) => children(titleProps)}</ModalContent>
|
50
|
+
</div>
|
51
|
+
</div>
|
52
|
+
</div>
|
53
|
+
</Overlay>
|
54
|
+
);
|
55
|
+
}
|
@@ -0,0 +1,33 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import { StoryFn, Meta } from '@storybook/react';
|
3
|
+
|
4
|
+
import { ModalTrigger } from './ModalTrigger';
|
5
|
+
|
6
|
+
export default {
|
7
|
+
title: 'Modal',
|
8
|
+
component: ModalTrigger,
|
9
|
+
} as Meta<typeof ModalTrigger>;
|
10
|
+
|
11
|
+
const Template: StoryFn<typeof ModalTrigger> = ({ label }) => {
|
12
|
+
return (
|
13
|
+
<>
|
14
|
+
<ModalTrigger label={label} showLabel>
|
15
|
+
{(onClose, titleProps) => (
|
16
|
+
<div>
|
17
|
+
<div {...titleProps}>Testing</div>
|
18
|
+
<button type="button" onClick={onClose}>
|
19
|
+
Close
|
20
|
+
</button>
|
21
|
+
</div>
|
22
|
+
)}
|
23
|
+
</ModalTrigger>
|
24
|
+
<div style={{ height: '150vh' }} />
|
25
|
+
</>
|
26
|
+
);
|
27
|
+
};
|
28
|
+
|
29
|
+
export const Primary = Template.bind({});
|
30
|
+
|
31
|
+
Primary.args = {
|
32
|
+
label: 'Resource Browser Modal',
|
33
|
+
};
|
@@ -0,0 +1,20 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import { useButton, AriaButtonProps } from 'react-aria';
|
3
|
+
|
4
|
+
interface ModalOpeningButtonProps extends AriaButtonProps<'button'> {
|
5
|
+
className?: string;
|
6
|
+
children?: React.ReactNode;
|
7
|
+
isDisabled?: boolean;
|
8
|
+
title?: string;
|
9
|
+
}
|
10
|
+
|
11
|
+
export function ModalOpeningButton({ className, children, title, ...props }: ModalOpeningButtonProps) {
|
12
|
+
const ref = React.useRef<HTMLButtonElement>(null);
|
13
|
+
|
14
|
+
const { buttonProps } = useButton(props, ref);
|
15
|
+
return (
|
16
|
+
<button {...buttonProps} ref={ref} className={className} title={title}>
|
17
|
+
{children}
|
18
|
+
</button>
|
19
|
+
);
|
20
|
+
}
|
@@ -0,0 +1,54 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import { useOverlayTrigger } from 'react-aria';
|
3
|
+
import { useOverlayTriggerState } from 'react-stately';
|
4
|
+
import { DOMAttributes, FocusableElement } from '@react-types/shared';
|
5
|
+
import clsx from 'clsx';
|
6
|
+
|
7
|
+
import { Modal } from './Modal';
|
8
|
+
import { ModalOpeningButton } from './ModalOpeningButton';
|
9
|
+
|
10
|
+
export function ModalTrigger({
|
11
|
+
label,
|
12
|
+
showLabel,
|
13
|
+
icon,
|
14
|
+
isDisabled,
|
15
|
+
children,
|
16
|
+
...props
|
17
|
+
}: {
|
18
|
+
label: string;
|
19
|
+
showLabel?: boolean;
|
20
|
+
icon?: React.ReactNode;
|
21
|
+
isDisabled?: boolean;
|
22
|
+
children: (onClose: () => void, titleProps: DOMAttributes<FocusableElement>) => React.ReactElement; // Child needs to be a functions which generates the 'content' so we can pass the onClose function and title aria props
|
23
|
+
}) {
|
24
|
+
const state = useOverlayTriggerState(props);
|
25
|
+
const { triggerProps, overlayProps } = useOverlayTrigger({ type: 'dialog' }, state);
|
26
|
+
|
27
|
+
let ariaAttr: React.AriaAttributes = {};
|
28
|
+
if (!showLabel) {
|
29
|
+
ariaAttr = { ...ariaAttr, 'aria-label': label };
|
30
|
+
}
|
31
|
+
|
32
|
+
return (
|
33
|
+
<>
|
34
|
+
<ModalOpeningButton
|
35
|
+
type="button"
|
36
|
+
{...triggerProps}
|
37
|
+
{...ariaAttr}
|
38
|
+
isDisabled={isDisabled}
|
39
|
+
className={clsx(
|
40
|
+
'flex p-1 px-1.5 rounded mr-auto text-blue-300 hover:bg-blue-100 focus:bg-blue-100 focus:outline-none',
|
41
|
+
isDisabled && 'hover:bg-transparent cursor-not-allowed text-gray-600',
|
42
|
+
)}
|
43
|
+
>
|
44
|
+
{icon}
|
45
|
+
{showLabel && <span className="ml-1 text-sm font-semibold leading-4">{label}</span>}
|
46
|
+
</ModalOpeningButton>
|
47
|
+
{state.isOpen && (
|
48
|
+
<Modal isDismissable state={state} overlayProps={overlayProps}>
|
49
|
+
{(titleProps) => children(state.close, titleProps)}
|
50
|
+
</Modal>
|
51
|
+
)}
|
52
|
+
</>
|
53
|
+
);
|
54
|
+
}
|
@@ -0,0 +1,164 @@
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-empty-function */
|
2
|
+
import React from 'react';
|
3
|
+
import { screen, render, waitFor } from '@testing-library/react';
|
4
|
+
import userEvent from '@testing-library/user-event';
|
5
|
+
|
6
|
+
import { useOverlayTriggerState, OverlayTriggerState } from 'react-stately';
|
7
|
+
import { DOMAttributes, FocusableElement } from '@react-types/shared';
|
8
|
+
import { useOverlayTrigger } from 'react-aria';
|
9
|
+
|
10
|
+
import PreviewModal from './PreviewModal';
|
11
|
+
|
12
|
+
function PreviewModalTestWrapper({
|
13
|
+
constructFunction,
|
14
|
+
}: {
|
15
|
+
constructFunction: (state: OverlayTriggerState, overlayProps: DOMAttributes<FocusableElement>) => JSX.Element;
|
16
|
+
}) {
|
17
|
+
const previewModalState = useOverlayTriggerState({ isOpen: true });
|
18
|
+
const { overlayProps } = useOverlayTrigger({ type: 'dialog' }, previewModalState);
|
19
|
+
|
20
|
+
return constructFunction(previewModalState, overlayProps);
|
21
|
+
}
|
22
|
+
|
23
|
+
describe('PreviewModal', () => {
|
24
|
+
it('Modal is open by default', async () => {
|
25
|
+
const onClose = jest.fn();
|
26
|
+
|
27
|
+
render(
|
28
|
+
<PreviewModalTestWrapper
|
29
|
+
constructFunction={(state, overlayProps) => {
|
30
|
+
return (
|
31
|
+
<PreviewModal state={state} overlayProps={overlayProps} onClose={onClose}>
|
32
|
+
Modal Content
|
33
|
+
</PreviewModal>
|
34
|
+
);
|
35
|
+
}}
|
36
|
+
/>,
|
37
|
+
);
|
38
|
+
expect(screen.queryByRole('dialog')).toBeTruthy();
|
39
|
+
});
|
40
|
+
|
41
|
+
it('Clicking outside the modal calls onClose', async () => {
|
42
|
+
const onClose = jest.fn();
|
43
|
+
render(
|
44
|
+
<div data-testid="outside">
|
45
|
+
<PreviewModalTestWrapper
|
46
|
+
constructFunction={(state, overlayProps) => {
|
47
|
+
return (
|
48
|
+
<PreviewModal state={state} overlayProps={overlayProps} onClose={onClose}>
|
49
|
+
Modal Content
|
50
|
+
</PreviewModal>
|
51
|
+
);
|
52
|
+
}}
|
53
|
+
/>
|
54
|
+
,
|
55
|
+
<div style={{ height: '150vh' }} />
|
56
|
+
</div>,
|
57
|
+
);
|
58
|
+
|
59
|
+
const user = userEvent.setup();
|
60
|
+
|
61
|
+
// Modal Open
|
62
|
+
await waitFor(() => {
|
63
|
+
expect(screen.queryByRole('dialog')).toBeTruthy();
|
64
|
+
});
|
65
|
+
|
66
|
+
await user.click(screen.getByTestId('outside'));
|
67
|
+
|
68
|
+
// Modal Closed
|
69
|
+
await waitFor(() => {
|
70
|
+
expect(onClose).toHaveBeenCalled();
|
71
|
+
});
|
72
|
+
});
|
73
|
+
|
74
|
+
it('ESC closes modal', async () => {
|
75
|
+
const onClose = jest.fn();
|
76
|
+
render(
|
77
|
+
<div data-testid="outside">
|
78
|
+
<PreviewModalTestWrapper
|
79
|
+
constructFunction={(state, overlayProps) => {
|
80
|
+
return (
|
81
|
+
<PreviewModal state={state} overlayProps={overlayProps} onClose={onClose}>
|
82
|
+
Modal Content
|
83
|
+
</PreviewModal>
|
84
|
+
);
|
85
|
+
}}
|
86
|
+
/>
|
87
|
+
<div style={{ height: '150vh' }} />
|
88
|
+
</div>,
|
89
|
+
);
|
90
|
+
|
91
|
+
const user = userEvent.setup();
|
92
|
+
|
93
|
+
// Modal Open
|
94
|
+
await waitFor(() => {
|
95
|
+
expect(screen.queryByRole('dialog')).toBeTruthy();
|
96
|
+
});
|
97
|
+
|
98
|
+
user.type(screen.getByRole('dialog'), '{escape}');
|
99
|
+
|
100
|
+
// Modal Closed
|
101
|
+
await waitFor(() => {
|
102
|
+
expect(onClose).toHaveBeenCalled();
|
103
|
+
});
|
104
|
+
});
|
105
|
+
|
106
|
+
it('Focus is trapped within modal', async () => {
|
107
|
+
const onClose = jest.fn();
|
108
|
+
|
109
|
+
render(
|
110
|
+
<div>
|
111
|
+
<button />
|
112
|
+
<PreviewModalTestWrapper
|
113
|
+
constructFunction={(state, overlayProps) => {
|
114
|
+
return (
|
115
|
+
<PreviewModal state={state} overlayProps={overlayProps} onClose={onClose}>
|
116
|
+
Modal Content
|
117
|
+
</PreviewModal>
|
118
|
+
);
|
119
|
+
}}
|
120
|
+
/>
|
121
|
+
<button />
|
122
|
+
<div style={{ height: '150vh' }} />
|
123
|
+
</div>,
|
124
|
+
);
|
125
|
+
|
126
|
+
const user = userEvent.setup();
|
127
|
+
|
128
|
+
await waitFor(() => {
|
129
|
+
expect(screen.queryByRole('dialog')).toBeTruthy();
|
130
|
+
});
|
131
|
+
|
132
|
+
user.type(screen.getByRole('dialog'), '{tab}');
|
133
|
+
user.type(screen.getByRole('dialog'), '{tab}');
|
134
|
+
|
135
|
+
await waitFor(() => {
|
136
|
+
expect(screen.queryByRole('button', { name: 'Close details' })).toHaveFocus();
|
137
|
+
});
|
138
|
+
});
|
139
|
+
|
140
|
+
it('Focus should start on the dialog', async () => {
|
141
|
+
const onClose = jest.fn();
|
142
|
+
|
143
|
+
render(
|
144
|
+
<div>
|
145
|
+
<button />
|
146
|
+
<PreviewModalTestWrapper
|
147
|
+
constructFunction={(state, overlayProps) => {
|
148
|
+
return (
|
149
|
+
<PreviewModal state={state} overlayProps={overlayProps} onClose={onClose}>
|
150
|
+
Modal Content
|
151
|
+
</PreviewModal>
|
152
|
+
);
|
153
|
+
}}
|
154
|
+
/>
|
155
|
+
<button />
|
156
|
+
<div style={{ height: '150vh' }} />
|
157
|
+
</div>,
|
158
|
+
);
|
159
|
+
|
160
|
+
await waitFor(() => {
|
161
|
+
expect(screen.queryByRole('dialog')).toHaveFocus();
|
162
|
+
});
|
163
|
+
});
|
164
|
+
});
|
@@ -0,0 +1,94 @@
|
|
1
|
+
import React, { ReactNode, useRef, useEffect } from 'react';
|
2
|
+
import { Overlay, useModalOverlay, useDialog, useKeyboard } from 'react-aria';
|
3
|
+
import { OverlayTriggerState } from 'react-stately';
|
4
|
+
import { DOMAttributes, FocusableElement } from '@react-types/shared';
|
5
|
+
|
6
|
+
import { Icon, IconOptions } from '../Icons/Icon';
|
7
|
+
|
8
|
+
/*
|
9
|
+
This has to be a separate element otherwise the focus trap fails. Assuming this is because it needs
|
10
|
+
to fit inside the 'Overlay' as a form of context.
|
11
|
+
*/
|
12
|
+
function PreviewModalContent({ children, onClose, ...props }: { children: ReactNode; onClose: () => void }) {
|
13
|
+
const ref = useRef<HTMLDivElement>(null);
|
14
|
+
const { dialogProps, titleProps } = useDialog(props, ref);
|
15
|
+
|
16
|
+
return (
|
17
|
+
<div {...dialogProps} ref={ref} className="h-full flex flex-col">
|
18
|
+
<h3 {...titleProps} className="sr-only">
|
19
|
+
Resource Details
|
20
|
+
</h3>
|
21
|
+
<button type="button" aria-label="Close details" onClick={onClose} className="absolute top-3 right-3">
|
22
|
+
<Icon icon={'close' as IconOptions} className="" />
|
23
|
+
</button>
|
24
|
+
{children}
|
25
|
+
</div>
|
26
|
+
);
|
27
|
+
}
|
28
|
+
|
29
|
+
export type PreviewModalProps = {
|
30
|
+
state: OverlayTriggerState;
|
31
|
+
overlayProps: DOMAttributes<FocusableElement>;
|
32
|
+
children?: ReactNode;
|
33
|
+
onClose: () => void;
|
34
|
+
};
|
35
|
+
|
36
|
+
// Accept a ref for the top level modal
|
37
|
+
function PreviewModal({ state, overlayProps, children, onClose, ...props }: PreviewModalProps) {
|
38
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
39
|
+
|
40
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
41
|
+
const { modalProps, underlayProps } = useModalOverlay(
|
42
|
+
{ isKeyboardDismissDisabled: true, ...props },
|
43
|
+
state,
|
44
|
+
overlayRef,
|
45
|
+
);
|
46
|
+
|
47
|
+
// Need to handle the ESC escape hatch ourselves as we need to run onClose to deselect the current node
|
48
|
+
const { keyboardProps } = useKeyboard({
|
49
|
+
onKeyDown: (e) => {
|
50
|
+
if (e.key === 'Escape') {
|
51
|
+
onClose();
|
52
|
+
} else {
|
53
|
+
// Need to do this so TAB get handled up higher and cycles though the focus trap
|
54
|
+
e.continuePropagation();
|
55
|
+
}
|
56
|
+
},
|
57
|
+
});
|
58
|
+
|
59
|
+
useEffect(() => {
|
60
|
+
// Check if the click event has happened outside the modal
|
61
|
+
function handleClickOutside(event: MouseEvent) {
|
62
|
+
if (modalRef.current && !modalRef.current.contains(event?.target as Node)) {
|
63
|
+
onClose();
|
64
|
+
}
|
65
|
+
}
|
66
|
+
// Bind the event listener
|
67
|
+
document.addEventListener('mousedown', handleClickOutside);
|
68
|
+
return () => {
|
69
|
+
// Unbind the event listener on clean up
|
70
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
71
|
+
};
|
72
|
+
}, [modalRef]);
|
73
|
+
|
74
|
+
return (
|
75
|
+
<Overlay>
|
76
|
+
<div className="squiz-gb-scope">
|
77
|
+
<div
|
78
|
+
ref={modalRef}
|
79
|
+
{...underlayProps}
|
80
|
+
{...keyboardProps}
|
81
|
+
className="fixed z-[9999] overflow-y-scroll bottom-0 w-full h-[50vh] bg-white border-t border-gray-300"
|
82
|
+
>
|
83
|
+
<div ref={overlayRef} {...modalProps} className="h-full">
|
84
|
+
<PreviewModalContent {...overlayProps} onClose={onClose}>
|
85
|
+
{children}
|
86
|
+
</PreviewModalContent>
|
87
|
+
</div>
|
88
|
+
</div>
|
89
|
+
</div>
|
90
|
+
</Overlay>
|
91
|
+
);
|
92
|
+
}
|
93
|
+
|
94
|
+
export default PreviewModal;
|