uikit-react-public 0.30.0 → 0.30.1
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/Dialog/BaseDialog.d.ts +1 -1
- package/dist/components/Dialog/Dialog.d.ts +1 -1
- package/dist/components/Dialog/Dialog.stories.d.ts +1 -1
- package/dist/index.js +1284 -1268
- package/dist/utils/announce.d.ts +2 -1
- package/lib/components/Dialog/BaseDialog.tsx +180 -161
- package/lib/components/Dialog/Dialog.tsx +15 -11
- package/lib/utils/__tests__/announce.test.ts +53 -0
- package/lib/utils/announce.ts +33 -10
- package/package.json +1 -1
package/dist/utils/announce.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Announces a message to screen readers.
|
|
3
|
+
*
|
|
3
4
|
*/
|
|
4
|
-
declare const announce: (message: string, force?: boolean) => void;
|
|
5
|
+
declare const announce: (message: string, force?: boolean, container?: HTMLElement | null) => void;
|
|
5
6
|
export declare const __resetForTesting: () => void;
|
|
6
7
|
export default announce;
|
|
@@ -5,6 +5,7 @@ import React, {
|
|
|
5
5
|
useEffect,
|
|
6
6
|
useRef,
|
|
7
7
|
useContext,
|
|
8
|
+
forwardRef,
|
|
8
9
|
} from 'react';
|
|
9
10
|
import { css, cx } from '@emotion/css';
|
|
10
11
|
import useTheme from '../../theme/useTheme';
|
|
@@ -33,182 +34,200 @@ export interface BaseDialogProps extends HTMLAttributes<HTMLDialogElement> {
|
|
|
33
34
|
testId?: string;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
const BaseDialog = (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
`;
|
|
82
|
-
|
|
83
|
-
useEffect(() => {
|
|
84
|
-
if (open && modal) {
|
|
85
|
-
document.body.classList.add(hideBodyScroll);
|
|
86
|
-
} else {
|
|
87
|
-
document.body.classList.remove(hideBodyScroll);
|
|
88
|
-
}
|
|
89
|
-
return () => {
|
|
90
|
-
document.body.classList.remove(hideBodyScroll);
|
|
91
|
-
};
|
|
92
|
-
}, [open, modal, hideBodyScroll]);
|
|
93
|
-
|
|
94
|
-
useEffect(() => {
|
|
95
|
-
const dialogElement = dialogRef.current;
|
|
96
|
-
|
|
97
|
-
if (!dialogElement) return;
|
|
37
|
+
const BaseDialog = forwardRef<HTMLDialogElement, BaseDialogProps>(
|
|
38
|
+
function BaseDialog(
|
|
39
|
+
{
|
|
40
|
+
open = false,
|
|
41
|
+
size = 'medium',
|
|
42
|
+
modal = true,
|
|
43
|
+
closeOnClickOutside = true,
|
|
44
|
+
closeOnClickOutsideStopPropagation = true,
|
|
45
|
+
nonModalCloseOnEscape = false,
|
|
46
|
+
onClose,
|
|
47
|
+
className,
|
|
48
|
+
children,
|
|
49
|
+
initialFocusRef,
|
|
50
|
+
finalFocusRef,
|
|
51
|
+
disableFocusTrap = false,
|
|
52
|
+
restoreFocus = true,
|
|
53
|
+
skipCloseOnInitialFocus = false,
|
|
54
|
+
testId = NAME,
|
|
55
|
+
...props
|
|
56
|
+
}: BaseDialogProps,
|
|
57
|
+
ref
|
|
58
|
+
) {
|
|
59
|
+
const width = {
|
|
60
|
+
small: SMALL_WIDTH,
|
|
61
|
+
medium: MEDIUM_WIDTH,
|
|
62
|
+
large: LARGE_WIDTH,
|
|
63
|
+
}[size];
|
|
64
|
+
|
|
65
|
+
const [theme] = useTheme();
|
|
66
|
+
|
|
67
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
68
|
+
const previousActiveElement = useRef<HTMLElement | null>(null);
|
|
69
|
+
|
|
70
|
+
const setDialogRef = useCallback(
|
|
71
|
+
(node: HTMLDialogElement | null) => {
|
|
72
|
+
dialogRef.current = node;
|
|
73
|
+
|
|
74
|
+
if (typeof ref === 'function') {
|
|
75
|
+
ref(node);
|
|
76
|
+
} else if (ref) {
|
|
77
|
+
ref.current = node;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[ref]
|
|
81
|
+
);
|
|
98
82
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
83
|
+
const context = useContext(DialogContext);
|
|
84
|
+
const dialogHeaderId = context?.dialogHeaderId;
|
|
85
|
+
const dialogBodyId = context?.dialogBodyId;
|
|
86
|
+
|
|
87
|
+
// Use the focus trap hook
|
|
88
|
+
useFocusTrap({
|
|
89
|
+
isActive: open && modal && !disableFocusTrap,
|
|
90
|
+
containerRef: dialogRef,
|
|
91
|
+
initialFocusRef,
|
|
92
|
+
finalFocusRef,
|
|
93
|
+
restoreFocus,
|
|
94
|
+
skipFirstFocusable: skipCloseOnInitialFocus,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const hideBodyScroll = css`
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (open && modal) {
|
|
103
|
+
document.body.classList.add(hideBodyScroll);
|
|
103
104
|
} else {
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
} else if (!open && dialogElement.hasAttribute('open')) {
|
|
107
|
-
dialogElement.close();
|
|
108
|
-
// Focus restoration is handled by the focus trap hook for modal dialogs,
|
|
109
|
-
// but we keep the fallback for non-modal dialogs or when focus trap is disabled
|
|
110
|
-
if ((!modal || disableFocusTrap) && restoreFocus) {
|
|
111
|
-
previousActiveElement.current?.focus();
|
|
105
|
+
document.body.classList.remove(hideBodyScroll);
|
|
112
106
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
107
|
+
return () => {
|
|
108
|
+
document.body.classList.remove(hideBodyScroll);
|
|
109
|
+
};
|
|
110
|
+
}, [open, modal, hideBodyScroll]);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const dialogElement = dialogRef.current;
|
|
114
|
+
|
|
115
|
+
if (!dialogElement) return;
|
|
116
|
+
|
|
117
|
+
if (open && !dialogElement.hasAttribute('open')) {
|
|
118
|
+
previousActiveElement.current = document.activeElement as HTMLElement;
|
|
119
|
+
if (modal) {
|
|
120
|
+
dialogElement.showModal();
|
|
121
|
+
} else {
|
|
122
|
+
dialogElement.show();
|
|
123
|
+
}
|
|
124
|
+
} else if (!open && dialogElement.hasAttribute('open')) {
|
|
125
|
+
dialogElement.close();
|
|
126
|
+
// Focus restoration is handled by the focus trap hook for modal dialogs,
|
|
127
|
+
// but we keep the fallback for non-modal dialogs or when focus trap is disabled
|
|
128
|
+
if ((!modal || disableFocusTrap) && restoreFocus) {
|
|
129
|
+
previousActiveElement.current?.focus();
|
|
130
|
+
}
|
|
123
131
|
}
|
|
124
|
-
};
|
|
132
|
+
}, [open, modal, disableFocusTrap, restoreFocus]);
|
|
125
133
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
// Handle Escape key to close dialog
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!open || modal || !nonModalCloseOnEscape) return;
|
|
129
137
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
139
|
+
if (event.key === 'Escape' && onClose) {
|
|
140
|
+
onClose(event);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
145
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
146
|
+
}, [open, modal, nonModalCloseOnEscape, onClose]);
|
|
147
|
+
|
|
148
|
+
const handleClick = useCallback(
|
|
149
|
+
(ev: React.MouseEvent<HTMLDialogElement>) => {
|
|
150
|
+
if (closeOnClickOutside && onClose && dialogRef.current) {
|
|
151
|
+
if (closeOnClickOutsideStopPropagation) {
|
|
152
|
+
ev.stopPropagation();
|
|
153
|
+
}
|
|
154
|
+
const rect = dialogRef.current.getBoundingClientRect();
|
|
155
|
+
const isInDialog =
|
|
156
|
+
rect.top <= ev.clientY &&
|
|
157
|
+
ev.clientY <= rect.top + rect.height &&
|
|
158
|
+
rect.left <= ev.clientX &&
|
|
159
|
+
ev.clientX <= rect.left + rect.width;
|
|
160
|
+
if (!isInDialog) {
|
|
161
|
+
onClose(ev);
|
|
162
|
+
}
|
|
135
163
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (
|
|
164
|
+
},
|
|
165
|
+
[closeOnClickOutside, closeOnClickOutsideStopPropagation, onClose]
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const handleDialogClose = useCallback(
|
|
169
|
+
(ev: React.MouseEvent<HTMLDialogElement>) => {
|
|
170
|
+
if (onClose) {
|
|
143
171
|
onClose(ev);
|
|
144
172
|
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
const handleDialogClose = useCallback(
|
|
151
|
-
(ev: React.MouseEvent<HTMLDialogElement>) => {
|
|
152
|
-
if (onClose) {
|
|
153
|
-
onClose(ev);
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
|
-
[onClose]
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
const baseStyle = css`
|
|
160
|
-
padding: 0;
|
|
161
|
-
border: none;
|
|
162
|
-
background: ${theme.color.neutral.white};
|
|
163
|
-
color: ${theme.color.text.primary};
|
|
164
|
-
font-family: ${theme.font.family.primary};
|
|
165
|
-
font-size: ${theme.font.size.f16};
|
|
166
|
-
width: 100vw;
|
|
167
|
-
height: 100vh;
|
|
168
|
-
margin: auto;
|
|
169
|
-
|
|
170
|
-
&:modal {
|
|
171
|
-
max-width: none;
|
|
172
|
-
max-height: none;
|
|
173
|
-
}
|
|
173
|
+
},
|
|
174
|
+
[onClose]
|
|
175
|
+
);
|
|
174
176
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
const baseStyle = css`
|
|
178
|
+
padding: 0;
|
|
179
|
+
border: none;
|
|
180
|
+
background: ${theme.color.neutral.white};
|
|
181
|
+
color: ${theme.color.text.primary};
|
|
182
|
+
font-family: ${theme.font.family.primary};
|
|
183
|
+
font-size: ${theme.font.size.f16};
|
|
184
|
+
width: 100vw;
|
|
185
|
+
height: 100vh;
|
|
186
|
+
margin: auto;
|
|
179
187
|
|
|
180
188
|
&:modal {
|
|
181
|
-
max-width:
|
|
182
|
-
max-height:
|
|
189
|
+
max-width: none;
|
|
190
|
+
max-height: none;
|
|
183
191
|
}
|
|
184
|
-
}
|
|
185
192
|
|
|
186
|
-
|
|
187
|
-
|
|
193
|
+
@media (min-width: ${theme.breakpoints.tablet}px) {
|
|
194
|
+
width: ${width}px;
|
|
195
|
+
max-width: calc(100vw - ${theme.margin.m16});
|
|
196
|
+
height: fit-content;
|
|
197
|
+
|
|
198
|
+
&:modal {
|
|
199
|
+
max-width: min(${width}px, calc(100vw - ${theme.margin.m16}));
|
|
200
|
+
max-height: calc(100vh - 32px);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
&::backdrop {
|
|
205
|
+
background-color: ${theme.color.overlay.blanket};
|
|
206
|
+
}
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
const style = cx(NAME, baseStyle, className);
|
|
210
|
+
|
|
211
|
+
if (open) {
|
|
212
|
+
return (
|
|
213
|
+
<dialog
|
|
214
|
+
ref={setDialogRef}
|
|
215
|
+
className={style}
|
|
216
|
+
data-testid={testId}
|
|
217
|
+
onClick={handleClick}
|
|
218
|
+
onClose={handleDialogClose}
|
|
219
|
+
aria-modal={modal ? 'true' : 'false'}
|
|
220
|
+
aria-labelledby={dialogHeaderId}
|
|
221
|
+
aria-describedby={dialogBodyId}
|
|
222
|
+
{...props}
|
|
223
|
+
>
|
|
224
|
+
{children}
|
|
225
|
+
</dialog>
|
|
226
|
+
);
|
|
227
|
+
} else {
|
|
228
|
+
return null;
|
|
188
229
|
}
|
|
189
|
-
`;
|
|
190
|
-
|
|
191
|
-
const style = cx(NAME, baseStyle, className);
|
|
192
|
-
|
|
193
|
-
if (open) {
|
|
194
|
-
return (
|
|
195
|
-
<dialog
|
|
196
|
-
ref={dialogRef}
|
|
197
|
-
className={style}
|
|
198
|
-
data-testid={testId}
|
|
199
|
-
onClick={handleClick}
|
|
200
|
-
onClose={handleDialogClose}
|
|
201
|
-
aria-modal={modal ? 'true' : 'false'}
|
|
202
|
-
aria-labelledby={dialogHeaderId}
|
|
203
|
-
aria-describedby={dialogBodyId}
|
|
204
|
-
{...props}
|
|
205
|
-
>
|
|
206
|
-
{children}
|
|
207
|
-
</dialog>
|
|
208
|
-
);
|
|
209
|
-
} else {
|
|
210
|
-
return null;
|
|
211
230
|
}
|
|
212
|
-
|
|
231
|
+
);
|
|
213
232
|
|
|
214
233
|
export default memo(BaseDialog);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext, useId } from 'react';
|
|
1
|
+
import { createContext, forwardRef, useId } from 'react';
|
|
2
2
|
import { css, cx } from '@emotion/css';
|
|
3
3
|
import useTheme from '../../theme/useTheme';
|
|
4
4
|
import BaseDialog, { BaseDialogProps } from './BaseDialog';
|
|
@@ -23,15 +23,18 @@ export interface DialogProps extends BaseDialogProps {
|
|
|
23
23
|
onSecondaryAction?: () => void;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const Dialog = (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
const Dialog = forwardRef<HTMLDialogElement, DialogProps>(function Dialog(
|
|
27
|
+
{
|
|
28
|
+
onClose,
|
|
29
|
+
onAction,
|
|
30
|
+
onSecondaryAction,
|
|
31
|
+
testId = NAME,
|
|
32
|
+
className,
|
|
33
|
+
children,
|
|
34
|
+
...props
|
|
35
|
+
}: DialogProps,
|
|
36
|
+
ref
|
|
37
|
+
) {
|
|
35
38
|
const [theme] = useTheme();
|
|
36
39
|
const dialogHeaderId = useId();
|
|
37
40
|
const dialogBodyId = useId();
|
|
@@ -53,6 +56,7 @@ const Dialog = ({
|
|
|
53
56
|
return (
|
|
54
57
|
<DialogContext value={contextValue}>
|
|
55
58
|
<BaseDialog
|
|
59
|
+
ref={ref}
|
|
56
60
|
onClose={onClose}
|
|
57
61
|
testId={testId}
|
|
58
62
|
className={style}
|
|
@@ -63,7 +67,7 @@ const Dialog = ({
|
|
|
63
67
|
</BaseDialog>
|
|
64
68
|
</DialogContext>
|
|
65
69
|
);
|
|
66
|
-
};
|
|
70
|
+
});
|
|
67
71
|
|
|
68
72
|
export interface DialogSubComponents {
|
|
69
73
|
Header: typeof DialogHeader;
|
|
@@ -60,6 +60,59 @@ describe('announce (ARIA live region)', () => {
|
|
|
60
60
|
expect(allRegions.length).toBe(1);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test('appends the live region to the provided container', async () => {
|
|
64
|
+
const container = document.createElement('section');
|
|
65
|
+
container.setAttribute('data-testid', 'announce-container');
|
|
66
|
+
document.body.appendChild(container);
|
|
67
|
+
|
|
68
|
+
announce('Container message', false, container);
|
|
69
|
+
|
|
70
|
+
const region = await screen.findByTestId('aria-live-region');
|
|
71
|
+
|
|
72
|
+
expect(container.contains(region)).toBe(true);
|
|
73
|
+
expect(document.body.contains(region)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('falls back to document.body when no container is provided', async () => {
|
|
77
|
+
announce('Default container message');
|
|
78
|
+
|
|
79
|
+
const region = await screen.findByTestId('aria-live-region');
|
|
80
|
+
|
|
81
|
+
expect(region.parentElement).toBe(document.body);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('moves live region when a new container is provided later', async () => {
|
|
85
|
+
const firstContainer = document.createElement('div');
|
|
86
|
+
const secondContainer = document.createElement('div');
|
|
87
|
+
document.body.appendChild(firstContainer);
|
|
88
|
+
document.body.appendChild(secondContainer);
|
|
89
|
+
|
|
90
|
+
announce('First container message', false, firstContainer);
|
|
91
|
+
|
|
92
|
+
const region = await screen.findByTestId('aria-live-region');
|
|
93
|
+
expect(firstContainer.contains(region)).toBe(true);
|
|
94
|
+
|
|
95
|
+
announce('Second container message', false, secondContainer);
|
|
96
|
+
|
|
97
|
+
expect(firstContainer.contains(region)).toBe(false);
|
|
98
|
+
expect(secondContainer.contains(region)).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('recreates live region when previous one was removed from the DOM', async () => {
|
|
102
|
+
announce('Initial message');
|
|
103
|
+
|
|
104
|
+
const initialRegion = await screen.findByTestId('aria-live-region');
|
|
105
|
+
initialRegion.remove();
|
|
106
|
+
|
|
107
|
+
expect(screen.queryByTestId('aria-live-region')).toBeNull();
|
|
108
|
+
|
|
109
|
+
announce('Message after removal');
|
|
110
|
+
|
|
111
|
+
const recreatedRegion = await screen.findByTestId('aria-live-region');
|
|
112
|
+
expect(recreatedRegion).not.toBe(initialRegion);
|
|
113
|
+
expect(recreatedRegion.parentElement).toBe(document.body);
|
|
114
|
+
});
|
|
115
|
+
|
|
63
116
|
test('queues multiple announcements and processes sequentially', async () => {
|
|
64
117
|
vi.useFakeTimers();
|
|
65
118
|
|
package/lib/utils/announce.ts
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
*
|
|
12
12
|
* @param force - If true, forces the announcement even if it's the same as the last one.
|
|
13
13
|
*
|
|
14
|
+
* @param container - Optional element to contain the live region. Accepts `null`
|
|
15
|
+
* to support passing values like `ref.current`; falls back to `document.body`
|
|
16
|
+
* when no container is available.
|
|
17
|
+
*
|
|
14
18
|
* @example
|
|
15
19
|
* announce("Item removed");
|
|
16
20
|
*
|
|
@@ -26,14 +30,28 @@ const SR_DETECTION_DELAY = 100; // Time for SR to detect change
|
|
|
26
30
|
const SR_PROCESSING_DELAY = 1000; // Time for SR to finish announcement
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
|
-
* Creates the live region if it does not already exist
|
|
33
|
+
* Creates the live region if it does not already exist, or moves it to a new
|
|
34
|
+
* container if one is provided. If the existing region has been removed from
|
|
35
|
+
* the DOM (e.g. a dialog unmounted), it will be recreated.
|
|
30
36
|
*/
|
|
31
|
-
const initLiveRegion = (): void => {
|
|
32
|
-
if (typeof document === 'undefined'
|
|
37
|
+
const initLiveRegion = (container?: HTMLElement | null): void => {
|
|
38
|
+
if (typeof document === 'undefined') return;
|
|
33
39
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
// Reset if existing region has been removed from the DOM
|
|
41
|
+
if (liveRegion && !document.contains(liveRegion)) {
|
|
42
|
+
liveRegion = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const target = container || document.body || document.documentElement;
|
|
46
|
+
if (!target) return;
|
|
47
|
+
|
|
48
|
+
// If a specific container is requested and the region is elsewhere, move it
|
|
49
|
+
if (liveRegion && container && liveRegion.parentNode !== container) {
|
|
50
|
+
container.appendChild(liveRegion);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (liveRegion) return;
|
|
37
55
|
|
|
38
56
|
const region = document.createElement('div');
|
|
39
57
|
region.setAttribute('aria-live', 'polite');
|
|
@@ -48,7 +66,7 @@ const initLiveRegion = (): void => {
|
|
|
48
66
|
region.style.height = '1px';
|
|
49
67
|
region.style.overflow = 'hidden';
|
|
50
68
|
|
|
51
|
-
|
|
69
|
+
target.appendChild(region);
|
|
52
70
|
liveRegion = region;
|
|
53
71
|
};
|
|
54
72
|
|
|
@@ -86,13 +104,18 @@ const processQueue = (): void => {
|
|
|
86
104
|
|
|
87
105
|
/**
|
|
88
106
|
* Announces a message to screen readers.
|
|
107
|
+
*
|
|
89
108
|
*/
|
|
90
|
-
const announce = (
|
|
109
|
+
const announce = (
|
|
110
|
+
message: string,
|
|
111
|
+
force = false,
|
|
112
|
+
container?: HTMLElement | null
|
|
113
|
+
): void => {
|
|
91
114
|
// SSR guard: do nothing if document is unavailable
|
|
92
115
|
if (typeof document === 'undefined') return;
|
|
93
116
|
|
|
94
|
-
//
|
|
95
|
-
|
|
117
|
+
// Ensure live region exists and is attached to the requested container when provided
|
|
118
|
+
initLiveRegion(container);
|
|
96
119
|
|
|
97
120
|
// If forcing, clear the queue to prevent old messages
|
|
98
121
|
if (force) {
|