@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.
Files changed (153) hide show
  1. package/.storybook/main.ts +23 -0
  2. package/.storybook/preview-head.html +15 -0
  3. package/.storybook/preview.ts +16 -0
  4. package/README.md +10 -0
  5. package/build.js +21 -0
  6. package/jest.config.ts +29 -0
  7. package/lib/Hooks/useAsync.d.ts +21 -0
  8. package/lib/Hooks/useAsync.js +53 -0
  9. package/lib/Icons/Generics/ArrowDown.d.ts +15 -0
  10. package/lib/Icons/Generics/ArrowDown.js +23 -0
  11. package/lib/Icons/Generics/ArrowRight.d.ts +15 -0
  12. package/lib/Icons/Generics/ArrowRight.js +23 -0
  13. package/lib/Icons/Generics/Back.d.ts +4 -0
  14. package/lib/Icons/Generics/Back.js +12 -0
  15. package/lib/Icons/Generics/Close.d.ts +15 -0
  16. package/lib/Icons/Generics/Close.js +23 -0
  17. package/lib/Icons/Generics/Empty.d.ts +4 -0
  18. package/lib/Icons/Generics/Empty.js +12 -0
  19. package/lib/Icons/Generics/Error.d.ts +4 -0
  20. package/lib/Icons/Generics/Error.js +12 -0
  21. package/lib/Icons/Generics/GenericIconMap.d.ts +14 -0
  22. package/lib/Icons/Generics/GenericIconMap.js +18 -0
  23. package/lib/Icons/Generics/ResourceSelect.d.ts +15 -0
  24. package/lib/Icons/Generics/ResourceSelect.js +28 -0
  25. package/lib/Icons/Generics/Retry.d.ts +4 -0
  26. package/lib/Icons/Generics/Retry.js +12 -0
  27. package/lib/Icons/Generics/Root.d.ts +15 -0
  28. package/lib/Icons/Generics/Root.js +23 -0
  29. package/lib/Icons/Generics/Selected.d.ts +15 -0
  30. package/lib/Icons/Generics/Selected.js +23 -0
  31. package/lib/Icons/Generics/index.d.ts +10 -0
  32. package/lib/Icons/Generics/index.js +27 -0
  33. package/lib/Icons/Icon.d.ts +50 -0
  34. package/lib/Icons/Icon.js +42 -0
  35. package/lib/Icons/MatrixResources/Audio.d.ts +15 -0
  36. package/lib/Icons/MatrixResources/Audio.js +28 -0
  37. package/lib/Icons/MatrixResources/Excel.d.ts +15 -0
  38. package/lib/Icons/MatrixResources/Excel.js +27 -0
  39. package/lib/Icons/MatrixResources/Folder.d.ts +15 -0
  40. package/lib/Icons/MatrixResources/Folder.js +24 -0
  41. package/lib/Icons/MatrixResources/GenericFile.d.ts +15 -0
  42. package/lib/Icons/MatrixResources/GenericFile.js +28 -0
  43. package/lib/Icons/MatrixResources/Image.d.ts +15 -0
  44. package/lib/Icons/MatrixResources/Image.js +26 -0
  45. package/lib/Icons/MatrixResources/MatrixResourceMap.d.ts +15 -0
  46. package/lib/Icons/MatrixResources/MatrixResourceMap.js +19 -0
  47. package/lib/Icons/MatrixResources/Page.d.ts +15 -0
  48. package/lib/Icons/MatrixResources/Page.js +30 -0
  49. package/lib/Icons/MatrixResources/Pdf.d.ts +15 -0
  50. package/lib/Icons/MatrixResources/Pdf.js +31 -0
  51. package/lib/Icons/MatrixResources/Powerpoint.d.ts +15 -0
  52. package/lib/Icons/MatrixResources/Powerpoint.js +28 -0
  53. package/lib/Icons/MatrixResources/Site.d.ts +15 -0
  54. package/lib/Icons/MatrixResources/Site.js +30 -0
  55. package/lib/Icons/MatrixResources/Video.d.ts +15 -0
  56. package/lib/Icons/MatrixResources/Video.js +24 -0
  57. package/lib/Icons/MatrixResources/Word.d.ts +17 -0
  58. package/lib/Icons/MatrixResources/Word.js +28 -0
  59. package/lib/Icons/MatrixResources/index.d.ts +11 -0
  60. package/lib/Icons/MatrixResources/index.js +29 -0
  61. package/lib/Modal/Modal.d.ts +10 -0
  62. package/lib/Modal/Modal.js +47 -0
  63. package/lib/Modal/ModalOpeningButton.d.ts +10 -0
  64. package/lib/Modal/ModalOpeningButton.js +14 -0
  65. package/lib/Modal/ModalTrigger.d.ts +9 -0
  66. package/lib/Modal/ModalTrigger.js +26 -0
  67. package/lib/PreviewPanel/PreviewModal.d.ts +11 -0
  68. package/lib/PreviewPanel/PreviewModal.js +79 -0
  69. package/lib/PreviewPanel/PreviewPanel.d.ts +13 -0
  70. package/lib/PreviewPanel/PreviewPanel.js +58 -0
  71. package/lib/PreviewPanel/PreviewPanelHOC.d.ts +6 -0
  72. package/lib/PreviewPanel/PreviewPanelHOC.js +16 -0
  73. package/lib/ResetButton/ResetButton.d.ts +5 -0
  74. package/lib/ResetButton/ResetButton.js +12 -0
  75. package/lib/ResourceItem/ResourceItem.d.ts +19 -0
  76. package/lib/ResourceItem/ResourceItem.js +29 -0
  77. package/lib/ResourceState/ResourceState.d.ts +7 -0
  78. package/lib/ResourceState/ResourceState.js +17 -0
  79. package/lib/Skeleton/List/SkeletonList.d.ts +6 -0
  80. package/lib/Skeleton/List/SkeletonList.js +13 -0
  81. package/lib/Skeleton/ListItem/SkeletonListItem.d.ts +1 -0
  82. package/lib/Skeleton/ListItem/SkeletonListItem.js +15 -0
  83. package/lib/Spinner/Spinner.d.ts +7 -0
  84. package/lib/Spinner/Spinner.js +13 -0
  85. package/lib/index.css +885 -0
  86. package/lib/index.d.ts +12 -0
  87. package/lib/index.js +28 -0
  88. package/package.json +76 -0
  89. package/postcss.config.js +11 -0
  90. package/src/Hooks/useAsync.spec.ts +106 -0
  91. package/src/Hooks/useAsync.ts +62 -0
  92. package/src/Icons/Generics/ArrowDown.tsx +27 -0
  93. package/src/Icons/Generics/ArrowRight.tsx +27 -0
  94. package/src/Icons/Generics/Back.tsx +13 -0
  95. package/src/Icons/Generics/Close.tsx +26 -0
  96. package/src/Icons/Generics/Empty.tsx +13 -0
  97. package/src/Icons/Generics/Error.tsx +13 -0
  98. package/src/Icons/Generics/GenericIconMap.ts +18 -0
  99. package/src/Icons/Generics/ResourceSelect.tsx +40 -0
  100. package/src/Icons/Generics/Retry.tsx +13 -0
  101. package/src/Icons/Generics/Root.tsx +24 -0
  102. package/src/Icons/Generics/Selected.tsx +27 -0
  103. package/src/Icons/Generics/index.tsx +11 -0
  104. package/src/Icons/Icon.spec.tsx +62 -0
  105. package/src/Icons/Icon.stories.tsx +110 -0
  106. package/src/Icons/Icon.tsx +54 -0
  107. package/src/Icons/MatrixResources/Audio.tsx +30 -0
  108. package/src/Icons/MatrixResources/Excel.tsx +29 -0
  109. package/src/Icons/MatrixResources/Folder.tsx +29 -0
  110. package/src/Icons/MatrixResources/GenericFile.tsx +34 -0
  111. package/src/Icons/MatrixResources/Image.tsx +36 -0
  112. package/src/Icons/MatrixResources/MatrixResourceMap.ts +19 -0
  113. package/src/Icons/MatrixResources/Page.tsx +33 -0
  114. package/src/Icons/MatrixResources/Pdf.tsx +34 -0
  115. package/src/Icons/MatrixResources/Powerpoint.tsx +34 -0
  116. package/src/Icons/MatrixResources/Site.tsx +37 -0
  117. package/src/Icons/MatrixResources/Video.tsx +27 -0
  118. package/src/Icons/MatrixResources/Word.tsx +30 -0
  119. package/src/Icons/MatrixResources/index.tsx +12 -0
  120. package/src/Modal/Modal.spec.tsx +269 -0
  121. package/src/Modal/Modal.tsx +55 -0
  122. package/src/Modal/ModalContainer.stories.tsx +33 -0
  123. package/src/Modal/ModalOpeningButton.tsx +20 -0
  124. package/src/Modal/ModalTrigger.tsx +54 -0
  125. package/src/PreviewPanel/PreviewModal.spec.tsx +164 -0
  126. package/src/PreviewPanel/PreviewModal.tsx +94 -0
  127. package/src/PreviewPanel/PreviewPanel.spec.tsx +162 -0
  128. package/src/PreviewPanel/PreviewPanel.stories.tsx +66 -0
  129. package/src/PreviewPanel/PreviewPanel.tsx +83 -0
  130. package/src/PreviewPanel/PreviewPanelHOC.spec.tsx +45 -0
  131. package/src/PreviewPanel/PreviewPanelHOC.tsx +17 -0
  132. package/src/ResetButton/ResetButton.spec.tsx +42 -0
  133. package/src/ResetButton/ResetButton.tsx +22 -0
  134. package/src/ResourceItem/ResourceItem.spec.tsx +65 -0
  135. package/src/ResourceItem/ResourceItem.tsx +90 -0
  136. package/src/ResourceState/ResourceState.spec.tsx +26 -0
  137. package/src/ResourceState/ResourceState.stories.tsx +24 -0
  138. package/src/ResourceState/ResourceState.tsx +31 -0
  139. package/src/Skeleton/List/SkeletonList.spec.tsx +18 -0
  140. package/src/Skeleton/List/SkeletonList.stories.tsx +15 -0
  141. package/src/Skeleton/List/SkeletonList.tsx +20 -0
  142. package/src/Skeleton/ListItem/SkeletonListItem.stories.tsx +15 -0
  143. package/src/Skeleton/ListItem/SkeletonListItem.tsx +14 -0
  144. package/src/Skeleton/_skeleton.scss +15 -0
  145. package/src/Spinner/Spinner.spec.tsx +18 -0
  146. package/src/Spinner/Spinner.stories.tsx +26 -0
  147. package/src/Spinner/Spinner.tsx +16 -0
  148. package/src/Spinner/_spinner.scss +14 -0
  149. package/src/index.scss +22 -0
  150. package/src/index.stories.tsx +26 -0
  151. package/src/index.ts +20 -0
  152. package/tailwind.config.cjs +89 -0
  153. 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;