uikit-react-public 0.39.1 → 0.40.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/dist/components/ErrorDialog/ErrorDialog.d.ts +26 -0
- package/dist/components/ErrorDialog/ErrorDialog.stories.d.ts +14 -0
- package/dist/components/ErrorDialog/__tests__/ErrorDialog.test.d.ts +1 -0
- package/dist/components/ErrorDialog/index.d.ts +2 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/index.js +5608 -5303
- package/lib/components/ErrorDialog/ErrorDialog.stories.tsx +85 -0
- package/lib/components/ErrorDialog/ErrorDialog.tsx +433 -0
- package/lib/components/ErrorDialog/__tests__/ErrorDialog.test.tsx +145 -0
- package/lib/components/ErrorDialog/index.tsx +2 -0
- package/lib/components/index.ts +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import ErrorDialog from './ErrorDialog';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/ErrorDialog',
|
|
6
|
+
component: ErrorDialog,
|
|
7
|
+
parameters: {
|
|
8
|
+
docs: {
|
|
9
|
+
story: 'Default',
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
} satisfies Meta<typeof ErrorDialog>;
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
|
|
16
|
+
type Story = StoryObj<typeof meta>;
|
|
17
|
+
|
|
18
|
+
export const Default: Story = {
|
|
19
|
+
args: {
|
|
20
|
+
open: true,
|
|
21
|
+
modal: true,
|
|
22
|
+
heading: 'Something went wrong',
|
|
23
|
+
errorCode: '1601',
|
|
24
|
+
referenceCode: 'UCLCID-20e935c3',
|
|
25
|
+
children:
|
|
26
|
+
'We couldn’t complete your request right now. Please try again in a moment.',
|
|
27
|
+
details: [
|
|
28
|
+
{
|
|
29
|
+
label: 'HTTP Status Code',
|
|
30
|
+
value: 'HTTP status code: 404 (Not Found)',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
label: 'Online Status',
|
|
34
|
+
value: 'Network connection: online',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
label: 'Username',
|
|
38
|
+
value: 'username: j.bloggs',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
label: 'Login Time',
|
|
42
|
+
value: 'login time: Fri Jan 16 2026 10:01:43 GMT+0000',
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
primaryButton: 'Primary',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const LongContent: Story = {
|
|
50
|
+
args: {
|
|
51
|
+
open: true,
|
|
52
|
+
modal: true,
|
|
53
|
+
heading: 'The file could not be uploaded',
|
|
54
|
+
errorCode: '1601',
|
|
55
|
+
referenceCode: 'UCLCID-20e935c3',
|
|
56
|
+
details: [
|
|
57
|
+
{
|
|
58
|
+
label: 'HTTP Status Code',
|
|
59
|
+
value:
|
|
60
|
+
'HTTP status code: 404 (Not Found)\nAPI request method: GET\nNetwork connection: online',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
label: 'User Details',
|
|
64
|
+
value:
|
|
65
|
+
'username: j.bloggs\nlogin time: Fri Jan 16 2026 10:01:43 GMT+0000\nonline status: online',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
label: 'Request Details',
|
|
69
|
+
value:
|
|
70
|
+
'path: /rooms/201/312\nrequest URL: https://timetable.mesh.ucl.ac.uk/online-timetable-api-proxy/v0.1/location',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
label: 'Browser Details',
|
|
74
|
+
value: 'browser: Chrome\nOS: macOS\ndevice type: desktop',
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
children: Array(20)
|
|
78
|
+
.fill(
|
|
79
|
+
'The upload service did not respond before the request timed out. Check the file and try again.'
|
|
80
|
+
)
|
|
81
|
+
.join(' '),
|
|
82
|
+
primaryButton: 'Primary',
|
|
83
|
+
footerDivider: true,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { ReactNode, useCallback, useId } from 'react';
|
|
2
|
+
import { css, cx } from '@emotion/css';
|
|
3
|
+
import useTheme from '../../theme/useTheme';
|
|
4
|
+
import BaseDialog, { BaseDialogProps } from '../Dialog/BaseDialog';
|
|
5
|
+
import Heading from '../Heading';
|
|
6
|
+
import Paragraph from '../Paragraph';
|
|
7
|
+
import Text from '../Text';
|
|
8
|
+
import Button, { ButtonProps } from '../Button';
|
|
9
|
+
import Icon from '../Icon';
|
|
10
|
+
import IconButton, { IconButtonProps } from '../IconButton';
|
|
11
|
+
import Accordion from '../Accordion';
|
|
12
|
+
|
|
13
|
+
export const NAME = 'ucl-uikit-error-dialog';
|
|
14
|
+
|
|
15
|
+
export interface ErrorDialogDetail {
|
|
16
|
+
label: string;
|
|
17
|
+
value: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ErrorDialogProps extends BaseDialogProps {
|
|
21
|
+
heading: ReactNode;
|
|
22
|
+
errorCode?: ReactNode;
|
|
23
|
+
referenceCode?: ReactNode;
|
|
24
|
+
details?: ErrorDialogDetail[];
|
|
25
|
+
primaryButton?: ReactNode;
|
|
26
|
+
secondaryButton?: ReactNode;
|
|
27
|
+
primaryButtonProps?: ButtonProps;
|
|
28
|
+
secondaryButtonProps?: ButtonProps;
|
|
29
|
+
closeButtonProps?: IconButtonProps;
|
|
30
|
+
closeButtonAriaLabel?: string;
|
|
31
|
+
footerDivider?: boolean;
|
|
32
|
+
onAction?: () => void;
|
|
33
|
+
onSecondaryAction?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ErrorDialog = ({
|
|
37
|
+
heading,
|
|
38
|
+
errorCode,
|
|
39
|
+
referenceCode,
|
|
40
|
+
details = [],
|
|
41
|
+
primaryButton = 'Close',
|
|
42
|
+
secondaryButton,
|
|
43
|
+
primaryButtonProps,
|
|
44
|
+
secondaryButtonProps,
|
|
45
|
+
closeButtonProps,
|
|
46
|
+
closeButtonAriaLabel = 'Close error dialog',
|
|
47
|
+
footerDivider = false,
|
|
48
|
+
onAction,
|
|
49
|
+
onSecondaryAction,
|
|
50
|
+
onClose,
|
|
51
|
+
size = 'medium',
|
|
52
|
+
modal = true,
|
|
53
|
+
role = 'alertdialog',
|
|
54
|
+
testId = NAME,
|
|
55
|
+
className,
|
|
56
|
+
children,
|
|
57
|
+
...props
|
|
58
|
+
}: ErrorDialogProps) => {
|
|
59
|
+
const [theme] = useTheme();
|
|
60
|
+
const dialogHeaderId = useId();
|
|
61
|
+
const dialogBodyId = useId();
|
|
62
|
+
const hasBody = children !== undefined && children !== null;
|
|
63
|
+
const hasErrorCode = errorCode !== undefined && errorCode !== null;
|
|
64
|
+
const hasReferenceCode =
|
|
65
|
+
referenceCode !== undefined && referenceCode !== null;
|
|
66
|
+
const hasFooterMetadata = hasErrorCode || hasReferenceCode;
|
|
67
|
+
const hasDetails = details.length > 0;
|
|
68
|
+
const hasPrimaryButton =
|
|
69
|
+
primaryButton !== undefined && primaryButton !== null;
|
|
70
|
+
const hasSecondaryButton =
|
|
71
|
+
secondaryButton !== undefined && secondaryButton !== null;
|
|
72
|
+
const hasFooterActions = hasPrimaryButton || hasSecondaryButton;
|
|
73
|
+
|
|
74
|
+
const handleCloseButtonClick = useCallback(
|
|
75
|
+
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
|
76
|
+
closeButtonProps?.onClick?.(ev);
|
|
77
|
+
|
|
78
|
+
if (!ev.defaultPrevented) {
|
|
79
|
+
onClose?.(ev);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
[closeButtonProps, onClose]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const handlePrimaryButtonClick = useCallback(
|
|
86
|
+
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
|
87
|
+
primaryButtonProps?.onClick?.(ev);
|
|
88
|
+
|
|
89
|
+
if (ev.defaultPrevented) return;
|
|
90
|
+
|
|
91
|
+
if (onAction) {
|
|
92
|
+
onAction();
|
|
93
|
+
} else {
|
|
94
|
+
onClose?.(ev);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
[onAction, onClose, primaryButtonProps]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const handleSecondaryButtonClick = useCallback(
|
|
101
|
+
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
|
102
|
+
secondaryButtonProps?.onClick?.(ev);
|
|
103
|
+
|
|
104
|
+
if (ev.defaultPrevented) return;
|
|
105
|
+
|
|
106
|
+
if (onSecondaryAction) {
|
|
107
|
+
onSecondaryAction();
|
|
108
|
+
} else {
|
|
109
|
+
onClose?.(ev);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
[onClose, onSecondaryAction, secondaryButtonProps]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const baseStyle = css`
|
|
116
|
+
box-shadow: ${theme.boxShadow.x2y4};
|
|
117
|
+
border-top: ${theme.padding.p4} solid ${theme.colour.border.critical};
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
overflow: hidden;
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
const style = cx(NAME, baseStyle, className);
|
|
124
|
+
|
|
125
|
+
const headerBaseStyle = css`
|
|
126
|
+
flex: 0 0 auto;
|
|
127
|
+
display: flex;
|
|
128
|
+
align-items: flex-start;
|
|
129
|
+
gap: ${theme.padding.p8};
|
|
130
|
+
padding: ${theme.padding.p24} ${theme.padding.p24} 0;
|
|
131
|
+
|
|
132
|
+
@media (min-width: ${theme.breakpoints.tablet}px) {
|
|
133
|
+
padding: ${theme.padding.p32} ${theme.padding.p32} 0;
|
|
134
|
+
}
|
|
135
|
+
`;
|
|
136
|
+
|
|
137
|
+
const headerStyle = cx(`${NAME}__header`, headerBaseStyle);
|
|
138
|
+
|
|
139
|
+
const headerContentBaseStyle = css`
|
|
140
|
+
flex: 1 1 auto;
|
|
141
|
+
min-width: 0;
|
|
142
|
+
display: flex;
|
|
143
|
+
flex-direction: column;
|
|
144
|
+
align-items: flex-start;
|
|
145
|
+
gap: ${theme.padding.p16};
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
const headerContentStyle = cx(
|
|
149
|
+
`${NAME}__header-content`,
|
|
150
|
+
headerContentBaseStyle
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const closeButtonBaseStyle = css`
|
|
154
|
+
flex: 0 0 auto;
|
|
155
|
+
color: ${theme.colour.icon.secondary};
|
|
156
|
+
`;
|
|
157
|
+
|
|
158
|
+
const closeButtonStyle = cx(
|
|
159
|
+
`${NAME}__close-button`,
|
|
160
|
+
closeButtonBaseStyle,
|
|
161
|
+
closeButtonProps?.className
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const iconBaseStyle = css`
|
|
165
|
+
color: ${theme.colour.icon.critical};
|
|
166
|
+
flex: 0 0 auto;
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
const iconStyle = cx(`${NAME}__icon`, iconBaseStyle);
|
|
170
|
+
|
|
171
|
+
const bodyBaseStyle = css`
|
|
172
|
+
flex: 1 1 auto;
|
|
173
|
+
min-height: 0;
|
|
174
|
+
overflow-y: auto;
|
|
175
|
+
padding: ${theme.padding.p16} ${theme.padding.p24};
|
|
176
|
+
|
|
177
|
+
@media (min-width: ${theme.breakpoints.tablet}px) {
|
|
178
|
+
padding: ${theme.padding.p16} ${theme.padding.p32};
|
|
179
|
+
}
|
|
180
|
+
`;
|
|
181
|
+
|
|
182
|
+
const bodyStyle = cx(`${NAME}__body`, bodyBaseStyle);
|
|
183
|
+
|
|
184
|
+
const bodyContentBaseStyle = css`
|
|
185
|
+
display: flex;
|
|
186
|
+
flex-direction: column;
|
|
187
|
+
gap: ${theme.padding.p8};
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
const bodyContentStyle = cx(`${NAME}__body-content`, bodyContentBaseStyle);
|
|
191
|
+
|
|
192
|
+
const detailsBaseStyle = css`
|
|
193
|
+
display: flex;
|
|
194
|
+
flex-direction: column;
|
|
195
|
+
gap: ${theme.padding.p8};
|
|
196
|
+
margin-top: ${theme.margin.m16};
|
|
197
|
+
`;
|
|
198
|
+
|
|
199
|
+
const detailsStyle = cx(`${NAME}__details`, detailsBaseStyle);
|
|
200
|
+
|
|
201
|
+
const detailAccordionBaseStyle = css`
|
|
202
|
+
width: 100%;
|
|
203
|
+
max-width: none;
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
const detailAccordionStyle = cx(`${NAME}__detail`, detailAccordionBaseStyle);
|
|
207
|
+
|
|
208
|
+
const detailValueBaseStyle = css`
|
|
209
|
+
white-space: pre-line;
|
|
210
|
+
`;
|
|
211
|
+
|
|
212
|
+
const detailValueStyle = cx(`${NAME}__detail-value`, detailValueBaseStyle);
|
|
213
|
+
|
|
214
|
+
const errorCodeBaseStyle = css`
|
|
215
|
+
color: ${theme.colour.text.default};
|
|
216
|
+
opacity: 0.9;
|
|
217
|
+
`;
|
|
218
|
+
|
|
219
|
+
const errorCodeStyle = cx(`${NAME}__error-code`, errorCodeBaseStyle);
|
|
220
|
+
|
|
221
|
+
const referenceCodeBaseStyle = css`
|
|
222
|
+
color: ${theme.colour.text.default};
|
|
223
|
+
opacity: 0.9;
|
|
224
|
+
`;
|
|
225
|
+
|
|
226
|
+
const referenceCodeStyle = cx(
|
|
227
|
+
`${NAME}__reference-code`,
|
|
228
|
+
referenceCodeBaseStyle
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const footerBaseStyle = css`
|
|
232
|
+
flex: 0 0 auto;
|
|
233
|
+
display: flex;
|
|
234
|
+
flex-direction: column-reverse;
|
|
235
|
+
gap: ${theme.padding.p12};
|
|
236
|
+
padding: ${theme.padding.p16} ${theme.padding.p24} ${theme.padding.p24};
|
|
237
|
+
|
|
238
|
+
@media (min-width: ${theme.breakpoints.tablet}px) {
|
|
239
|
+
flex-direction: row;
|
|
240
|
+
justify-content: space-between;
|
|
241
|
+
align-items: center;
|
|
242
|
+
padding: ${theme.padding.p16} ${theme.padding.p32} ${theme.padding.p32};
|
|
243
|
+
}
|
|
244
|
+
`;
|
|
245
|
+
|
|
246
|
+
const footerDividerStyle = css`
|
|
247
|
+
border-top: ${theme.border.b1} solid ${theme.colour.border.default};
|
|
248
|
+
`;
|
|
249
|
+
|
|
250
|
+
const footerStyle = cx(
|
|
251
|
+
`${NAME}__footer`,
|
|
252
|
+
footerBaseStyle,
|
|
253
|
+
footerDivider && footerDividerStyle
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const footerMetadataBaseStyle = css`
|
|
257
|
+
display: flex;
|
|
258
|
+
flex-direction: column;
|
|
259
|
+
gap: ${theme.padding.p4};
|
|
260
|
+
`;
|
|
261
|
+
|
|
262
|
+
const footerMetadataStyle = cx(
|
|
263
|
+
`${NAME}__footer-metadata`,
|
|
264
|
+
footerMetadataBaseStyle
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const footerActionsBaseStyle = css`
|
|
268
|
+
display: flex;
|
|
269
|
+
flex-direction: column-reverse;
|
|
270
|
+
gap: ${theme.padding.p12};
|
|
271
|
+
|
|
272
|
+
@media (min-width: ${theme.breakpoints.tablet}px) {
|
|
273
|
+
flex-direction: row;
|
|
274
|
+
justify-content: flex-end;
|
|
275
|
+
align-items: center;
|
|
276
|
+
}
|
|
277
|
+
`;
|
|
278
|
+
|
|
279
|
+
const footerActionsStyle = cx(
|
|
280
|
+
`${NAME}__footer-actions`,
|
|
281
|
+
footerActionsBaseStyle
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const buttonBaseStyle = css`
|
|
285
|
+
width: 100%;
|
|
286
|
+
|
|
287
|
+
@media (min-width: ${theme.breakpoints.tablet}px) {
|
|
288
|
+
width: auto;
|
|
289
|
+
}
|
|
290
|
+
`;
|
|
291
|
+
|
|
292
|
+
const primaryButtonStyle = cx(
|
|
293
|
+
`${NAME}__primary-button`,
|
|
294
|
+
buttonBaseStyle,
|
|
295
|
+
primaryButtonProps?.className
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const secondaryButtonStyle = cx(
|
|
299
|
+
`${NAME}__secondary-button`,
|
|
300
|
+
buttonBaseStyle,
|
|
301
|
+
secondaryButtonProps?.className
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<BaseDialog
|
|
306
|
+
size={size}
|
|
307
|
+
modal={modal}
|
|
308
|
+
role={role}
|
|
309
|
+
aria-labelledby={dialogHeaderId}
|
|
310
|
+
aria-describedby={dialogBodyId}
|
|
311
|
+
onClose={onClose}
|
|
312
|
+
testId={testId}
|
|
313
|
+
className={style}
|
|
314
|
+
skipCloseOnInitialFocus={true}
|
|
315
|
+
{...props}
|
|
316
|
+
>
|
|
317
|
+
<header className={headerStyle}>
|
|
318
|
+
<div className={headerContentStyle}>
|
|
319
|
+
<Icon.AlertCircle
|
|
320
|
+
size={32}
|
|
321
|
+
className={iconStyle}
|
|
322
|
+
role='presentation'
|
|
323
|
+
aria-hidden='true'
|
|
324
|
+
/>
|
|
325
|
+
<Heading
|
|
326
|
+
id={dialogHeaderId}
|
|
327
|
+
level='lg'
|
|
328
|
+
as='h3'
|
|
329
|
+
noMargins
|
|
330
|
+
>
|
|
331
|
+
{heading}
|
|
332
|
+
</Heading>
|
|
333
|
+
</div>
|
|
334
|
+
<IconButton
|
|
335
|
+
aria-label={closeButtonAriaLabel}
|
|
336
|
+
{...closeButtonProps}
|
|
337
|
+
className={closeButtonStyle}
|
|
338
|
+
onClick={handleCloseButtonClick}
|
|
339
|
+
>
|
|
340
|
+
<Icon.X
|
|
341
|
+
role='presentation'
|
|
342
|
+
aria-hidden='true'
|
|
343
|
+
/>
|
|
344
|
+
</IconButton>
|
|
345
|
+
</header>
|
|
346
|
+
<div
|
|
347
|
+
id={dialogBodyId}
|
|
348
|
+
className={bodyStyle}
|
|
349
|
+
role='document'
|
|
350
|
+
>
|
|
351
|
+
<div className={bodyContentStyle}>
|
|
352
|
+
{hasBody && (
|
|
353
|
+
<Paragraph
|
|
354
|
+
as='div'
|
|
355
|
+
level='md'
|
|
356
|
+
noMargins
|
|
357
|
+
>
|
|
358
|
+
{children}
|
|
359
|
+
</Paragraph>
|
|
360
|
+
)}
|
|
361
|
+
{hasDetails && (
|
|
362
|
+
<div className={detailsStyle}>
|
|
363
|
+
{details.map(({ label, value }, index) => (
|
|
364
|
+
<Accordion
|
|
365
|
+
key={`${label}-${index}`}
|
|
366
|
+
className={detailAccordionStyle}
|
|
367
|
+
>
|
|
368
|
+
<Accordion.Heading>{label}</Accordion.Heading>
|
|
369
|
+
<Accordion.Panel>
|
|
370
|
+
<span className={detailValueStyle}>{value}</span>
|
|
371
|
+
</Accordion.Panel>
|
|
372
|
+
</Accordion>
|
|
373
|
+
))}
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
{(hasFooterActions || hasFooterMetadata) && (
|
|
379
|
+
<footer
|
|
380
|
+
className={footerStyle}
|
|
381
|
+
aria-label='Error dialog actions'
|
|
382
|
+
>
|
|
383
|
+
{hasFooterMetadata && (
|
|
384
|
+
<div className={footerMetadataStyle}>
|
|
385
|
+
{hasErrorCode && (
|
|
386
|
+
<Text
|
|
387
|
+
className={errorCodeStyle}
|
|
388
|
+
level='md-semibold'
|
|
389
|
+
>
|
|
390
|
+
Error code: {errorCode}
|
|
391
|
+
</Text>
|
|
392
|
+
)}
|
|
393
|
+
{hasReferenceCode && (
|
|
394
|
+
<Text
|
|
395
|
+
className={referenceCodeStyle}
|
|
396
|
+
level='md-semibold'
|
|
397
|
+
>
|
|
398
|
+
Reference code: {referenceCode}
|
|
399
|
+
</Text>
|
|
400
|
+
)}
|
|
401
|
+
</div>
|
|
402
|
+
)}
|
|
403
|
+
{hasFooterActions && (
|
|
404
|
+
<div className={footerActionsStyle}>
|
|
405
|
+
{hasSecondaryButton && (
|
|
406
|
+
<Button
|
|
407
|
+
variant='secondary-destructive'
|
|
408
|
+
{...secondaryButtonProps}
|
|
409
|
+
className={secondaryButtonStyle}
|
|
410
|
+
onClick={handleSecondaryButtonClick}
|
|
411
|
+
>
|
|
412
|
+
{secondaryButton}
|
|
413
|
+
</Button>
|
|
414
|
+
)}
|
|
415
|
+
{hasPrimaryButton && (
|
|
416
|
+
<Button
|
|
417
|
+
variant='primary-destructive'
|
|
418
|
+
{...primaryButtonProps}
|
|
419
|
+
className={primaryButtonStyle}
|
|
420
|
+
onClick={handlePrimaryButtonClick}
|
|
421
|
+
>
|
|
422
|
+
{primaryButton}
|
|
423
|
+
</Button>
|
|
424
|
+
)}
|
|
425
|
+
</div>
|
|
426
|
+
)}
|
|
427
|
+
</footer>
|
|
428
|
+
)}
|
|
429
|
+
</BaseDialog>
|
|
430
|
+
);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
export default ErrorDialog;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { ThemeContextProvider } from '../../../theme/useTheme';
|
|
4
|
+
import ErrorDialog from '../ErrorDialog';
|
|
5
|
+
|
|
6
|
+
const originalShowModal = HTMLDialogElement.prototype.showModal;
|
|
7
|
+
const originalClose = HTMLDialogElement.prototype.close;
|
|
8
|
+
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
HTMLDialogElement.prototype.showModal = vi.fn(function (
|
|
11
|
+
this: HTMLDialogElement
|
|
12
|
+
) {
|
|
13
|
+
this.setAttribute('open', '');
|
|
14
|
+
});
|
|
15
|
+
HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
|
|
16
|
+
this.removeAttribute('open');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
HTMLDialogElement.prototype.showModal = originalShowModal;
|
|
22
|
+
HTMLDialogElement.prototype.close = originalClose;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const renderErrorDialog = (
|
|
26
|
+
props: Partial<React.ComponentProps<typeof ErrorDialog>> = {}
|
|
27
|
+
) =>
|
|
28
|
+
render(
|
|
29
|
+
<ThemeContextProvider>
|
|
30
|
+
<ErrorDialog
|
|
31
|
+
open
|
|
32
|
+
heading='Something went wrong'
|
|
33
|
+
errorCode='1601'
|
|
34
|
+
referenceCode='UCLCID-20e935c3'
|
|
35
|
+
onClose={() => {}}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
We could not complete this request.
|
|
39
|
+
</ErrorDialog>
|
|
40
|
+
</ThemeContextProvider>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
describe('ErrorDialog', () => {
|
|
44
|
+
test('renders an accessible alert dialog with heading, body, and error code', () => {
|
|
45
|
+
renderErrorDialog();
|
|
46
|
+
|
|
47
|
+
expect(
|
|
48
|
+
screen.getByRole('alertdialog', { name: 'Something went wrong' })
|
|
49
|
+
).toBeInTheDocument();
|
|
50
|
+
expect(
|
|
51
|
+
screen.getByText('We could not complete this request.')
|
|
52
|
+
).toBeInTheDocument();
|
|
53
|
+
expect(screen.getByText('Error code: 1601')).toBeInTheDocument();
|
|
54
|
+
expect(
|
|
55
|
+
screen.getByText('Reference code: UCLCID-20e935c3')
|
|
56
|
+
).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('renders reference code after error code in the footer metadata', () => {
|
|
60
|
+
renderErrorDialog();
|
|
61
|
+
|
|
62
|
+
const errorCode = screen.getByText('Error code: 1601');
|
|
63
|
+
const referenceCode = screen.getByText('Reference code: UCLCID-20e935c3');
|
|
64
|
+
|
|
65
|
+
expect(errorCode.compareDocumentPosition(referenceCode)).toBe(
|
|
66
|
+
Node.DOCUMENT_POSITION_FOLLOWING
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('renders detail accordions and preserves new lines in values', () => {
|
|
71
|
+
renderErrorDialog({
|
|
72
|
+
details: [
|
|
73
|
+
{
|
|
74
|
+
label: 'HTTP Status Code',
|
|
75
|
+
value: 'HTTP status code: 404 (Not Found)',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
label: 'User Details',
|
|
79
|
+
value:
|
|
80
|
+
'username: j.bloggs\nlogin time: Fri Jan 16 2026 10:01:43 GMT+0000',
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(
|
|
86
|
+
screen.queryByText('HTTP status code: 404 (Not Found)')
|
|
87
|
+
).not.toBeInTheDocument();
|
|
88
|
+
|
|
89
|
+
fireEvent.click(screen.getByRole('button', { name: 'HTTP Status Code' }));
|
|
90
|
+
expect(
|
|
91
|
+
screen.getByText('HTTP status code: 404 (Not Found)')
|
|
92
|
+
).toBeInTheDocument();
|
|
93
|
+
|
|
94
|
+
fireEvent.click(screen.getByRole('button', { name: 'User Details' }));
|
|
95
|
+
expect(
|
|
96
|
+
screen.getAllByText(
|
|
97
|
+
(_, element) =>
|
|
98
|
+
element?.textContent ===
|
|
99
|
+
'username: j.bloggs\nlogin time: Fri Jan 16 2026 10:01:43 GMT+0000'
|
|
100
|
+
)
|
|
101
|
+
).not.toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('calls onClose from the close icon and default primary action', () => {
|
|
105
|
+
const onClose = vi.fn();
|
|
106
|
+
|
|
107
|
+
renderErrorDialog({ onClose });
|
|
108
|
+
|
|
109
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close error dialog' }));
|
|
110
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
111
|
+
|
|
112
|
+
expect(onClose).toHaveBeenCalledTimes(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('calls action handlers for primary and secondary actions', () => {
|
|
116
|
+
const onAction = vi.fn();
|
|
117
|
+
const onSecondaryAction = vi.fn();
|
|
118
|
+
const onClose = vi.fn();
|
|
119
|
+
|
|
120
|
+
renderErrorDialog({
|
|
121
|
+
onAction,
|
|
122
|
+
onSecondaryAction,
|
|
123
|
+
onClose,
|
|
124
|
+
primaryButton: 'Try again',
|
|
125
|
+
secondaryButton: 'Cancel',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
|
129
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
130
|
+
|
|
131
|
+
expect(onAction).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(onSecondaryAction).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('renders footer actions for valid falsey ReactNode labels', () => {
|
|
137
|
+
renderErrorDialog({
|
|
138
|
+
primaryButton: 0,
|
|
139
|
+
secondaryButton: '',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(screen.getByRole('button', { name: '0' })).toBeInTheDocument();
|
|
143
|
+
expect(screen.getByRole('button', { name: '' })).toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
});
|
package/lib/components/index.ts
CHANGED
|
@@ -174,6 +174,9 @@ export type {
|
|
|
174
174
|
export { FeedbackDialog } from './FeedbackDialog';
|
|
175
175
|
export type { FeedbackDialogProps } from './FeedbackDialog';
|
|
176
176
|
|
|
177
|
+
export { default as ErrorDialog } from './ErrorDialog';
|
|
178
|
+
export type { ErrorDialogDetail, ErrorDialogProps } from './ErrorDialog';
|
|
179
|
+
|
|
177
180
|
export { default as Radio, LabelledRadio } from './Radio';
|
|
178
181
|
export type { RadioProps, LabelledRadioProps } from './Radio';
|
|
179
182
|
|