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.
@@ -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
- open = false,
38
- size = 'medium',
39
- modal = true,
40
- closeOnClickOutside = true,
41
- closeOnClickOutsideStopPropagation = true,
42
- nonModalCloseOnEscape = false,
43
- onClose,
44
- className,
45
- children,
46
- initialFocusRef,
47
- finalFocusRef,
48
- disableFocusTrap = false,
49
- restoreFocus = true,
50
- skipCloseOnInitialFocus = false,
51
- testId = NAME,
52
- ...props
53
- }: BaseDialogProps) => {
54
- const width = {
55
- small: SMALL_WIDTH,
56
- medium: MEDIUM_WIDTH,
57
- large: LARGE_WIDTH,
58
- }[size];
59
-
60
- const [theme] = useTheme();
61
-
62
- const dialogRef = useRef<HTMLDialogElement>(null);
63
- const previousActiveElement = useRef<HTMLElement | null>(null);
64
-
65
- const context = useContext(DialogContext);
66
- const dialogHeaderId = context?.dialogHeaderId;
67
- const dialogBodyId = context?.dialogBodyId;
68
-
69
- // Use the focus trap hook
70
- useFocusTrap({
71
- isActive: open && modal && !disableFocusTrap,
72
- containerRef: dialogRef,
73
- initialFocusRef,
74
- finalFocusRef,
75
- restoreFocus,
76
- skipFirstFocusable: skipCloseOnInitialFocus,
77
- });
78
-
79
- const hideBodyScroll = css`
80
- overflow: hidden;
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
- if (open && !dialogElement.hasAttribute('open')) {
100
- previousActiveElement.current = document.activeElement as HTMLElement;
101
- if (modal) {
102
- dialogElement.showModal();
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
- dialogElement.show();
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
- }, [open, modal, disableFocusTrap, restoreFocus]);
115
-
116
- // Handle Escape key to close dialog
117
- useEffect(() => {
118
- if (!open || modal || !nonModalCloseOnEscape) return;
119
-
120
- const handleKeyDown = (event: KeyboardEvent) => {
121
- if (event.key === 'Escape' && onClose) {
122
- onClose(event);
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
- document.addEventListener('keydown', handleKeyDown);
127
- return () => document.removeEventListener('keydown', handleKeyDown);
128
- }, [open, modal, nonModalCloseOnEscape, onClose]);
134
+ // Handle Escape key to close dialog
135
+ useEffect(() => {
136
+ if (!open || modal || !nonModalCloseOnEscape) return;
129
137
 
130
- const handleClick = useCallback(
131
- (ev: React.MouseEvent<HTMLDialogElement>) => {
132
- if (closeOnClickOutside && onClose && dialogRef.current) {
133
- if (closeOnClickOutsideStopPropagation) {
134
- ev.stopPropagation();
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
- const rect = dialogRef.current.getBoundingClientRect();
137
- const isInDialog =
138
- rect.top <= ev.clientY &&
139
- ev.clientY <= rect.top + rect.height &&
140
- rect.left <= ev.clientX &&
141
- ev.clientX <= rect.left + rect.width;
142
- if (!isInDialog) {
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
- [closeOnClickOutside, closeOnClickOutsideStopPropagation, onClose]
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
- @media (min-width: ${theme.breakpoints.tablet}px) {
176
- width: ${width}px;
177
- max-width: calc(100vw - ${theme.margin.m16});
178
- height: fit-content;
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: min(${width}px, calc(100vw - ${theme.margin.m16}));
182
- max-height: calc(100vh - 32px);
189
+ max-width: none;
190
+ max-height: none;
183
191
  }
184
- }
185
192
 
186
- &::backdrop {
187
- background-color: ${theme.color.overlay.blanket};
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
- onClose,
28
- onAction,
29
- onSecondaryAction,
30
- testId = NAME,
31
- className,
32
- children,
33
- ...props
34
- }: DialogProps) => {
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
 
@@ -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' || liveRegion) return;
37
+ const initLiveRegion = (container?: HTMLElement | null): void => {
38
+ if (typeof document === 'undefined') return;
33
39
 
34
- // Guard: body may not exist yet (e.g., script in <head>)
35
- const container = document.body || document.documentElement;
36
- if (!container) return;
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
- document.body.appendChild(region);
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 = (message: string, force = false): void => {
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
- // Initialize live region on first use
95
- if (!liveRegion) initLiveRegion();
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) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "uikit-react-public",
3
3
  "private": false,
4
4
  "license": "UNLICENSED",
5
- "version": "0.30.0",
5
+ "version": "0.30.1",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",