@ds-mo/ui 0.2.4 → 0.3.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/Accordion/Accordion.d.ts +29 -0
- package/dist/components/Accordion/Accordion.d.ts.map +1 -0
- package/dist/components/Accordion/index.d.ts +3 -0
- package/dist/components/Accordion/index.d.ts.map +1 -0
- package/dist/components/Badge/Badge.d.ts +18 -0
- package/dist/components/Badge/Badge.d.ts.map +1 -0
- package/dist/components/Badge/index.d.ts +3 -0
- package/dist/components/Badge/index.d.ts.map +1 -0
- package/dist/components/Banner/Banner.d.ts +16 -0
- package/dist/components/Banner/Banner.d.ts.map +1 -0
- package/dist/components/Banner/index.d.ts +3 -0
- package/dist/components/Banner/index.d.ts.map +1 -0
- package/dist/components/Breadcrumb/Breadcrumb.d.ts +15 -0
- package/dist/components/Breadcrumb/Breadcrumb.d.ts.map +1 -0
- package/dist/components/Breadcrumb/index.d.ts +3 -0
- package/dist/components/Breadcrumb/index.d.ts.map +1 -0
- package/dist/components/Button/Button.d.ts +85 -0
- package/dist/components/Button/Button.d.ts.map +1 -0
- package/dist/components/Button/index.d.ts +3 -0
- package/dist/components/Button/index.d.ts.map +1 -0
- package/dist/components/ButtonGroup/ButtonGroup.d.ts +26 -0
- package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -0
- package/dist/components/ButtonGroup/index.d.ts +3 -0
- package/dist/components/ButtonGroup/index.d.ts.map +1 -0
- package/dist/components/Card/Card.d.ts +20 -0
- package/dist/components/Card/Card.d.ts.map +1 -0
- package/dist/components/Card/index.d.ts +3 -0
- package/dist/components/Card/index.d.ts.map +1 -0
- package/dist/components/Checkbox/Checkbox.d.ts +18 -0
- package/dist/components/Checkbox/Checkbox.d.ts.map +1 -0
- package/dist/components/Checkbox/index.d.ts +3 -0
- package/dist/components/Checkbox/index.d.ts.map +1 -0
- package/dist/components/Divider/Divider.d.ts +9 -0
- package/dist/components/Divider/Divider.d.ts.map +1 -0
- package/dist/components/Divider/index.d.ts +3 -0
- package/dist/components/Divider/index.d.ts.map +1 -0
- package/dist/components/EmptyState/EmptyState.d.ts +9 -0
- package/dist/components/EmptyState/EmptyState.d.ts.map +1 -0
- package/dist/components/EmptyState/index.d.ts +3 -0
- package/dist/components/EmptyState/index.d.ts.map +1 -0
- package/dist/components/ErrorBoundary/ErrorBoundary.d.ts +18 -0
- package/dist/components/ErrorBoundary/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary/index.d.ts +3 -0
- package/dist/components/ErrorBoundary/index.d.ts.map +1 -0
- package/dist/components/Fade/Fade.d.ts +11 -0
- package/dist/components/Fade/Fade.d.ts.map +1 -0
- package/dist/components/Fade/index.d.ts +3 -0
- package/dist/components/Fade/index.d.ts.map +1 -0
- package/dist/components/Field/Field.d.ts +9 -0
- package/dist/components/Field/Field.d.ts.map +1 -0
- package/dist/components/Field/index.d.ts +3 -0
- package/dist/components/Field/index.d.ts.map +1 -0
- package/dist/components/Header/Header.d.ts +12 -0
- package/dist/components/Header/Header.d.ts.map +1 -0
- package/dist/components/Header/index.d.ts +3 -0
- package/dist/components/Header/index.d.ts.map +1 -0
- package/dist/components/Input/Input.d.ts +27 -0
- package/dist/components/Input/Input.d.ts.map +1 -0
- package/dist/components/Input/index.d.ts +3 -0
- package/dist/components/Input/index.d.ts.map +1 -0
- package/dist/components/LabelWrap/LabelWrap.d.ts +27 -0
- package/dist/components/LabelWrap/LabelWrap.d.ts.map +1 -0
- package/dist/components/LabelWrap/index.d.ts +2 -0
- package/dist/components/LabelWrap/index.d.ts.map +1 -0
- package/dist/components/Loader/Loader.d.ts +33 -0
- package/dist/components/Loader/Loader.d.ts.map +1 -0
- package/dist/components/Loader/index.d.ts +3 -0
- package/dist/components/Loader/index.d.ts.map +1 -0
- package/dist/components/Menu/DestructiveMenuItem.d.ts +12 -0
- package/dist/components/Menu/DestructiveMenuItem.d.ts.map +1 -0
- package/dist/components/Menu/Menu.d.ts +55 -0
- package/dist/components/Menu/Menu.d.ts.map +1 -0
- package/dist/components/Menu/MenuItem.d.ts +33 -0
- package/dist/components/Menu/MenuItem.d.ts.map +1 -0
- package/dist/components/Menu/index.d.ts +7 -0
- package/dist/components/Menu/index.d.ts.map +1 -0
- package/dist/components/Modal/Modal.d.ts +15 -0
- package/dist/components/Modal/Modal.d.ts.map +1 -0
- package/dist/components/Modal/index.d.ts +3 -0
- package/dist/components/Modal/index.d.ts.map +1 -0
- package/dist/components/Pagination/Pagination.d.ts +16 -0
- package/dist/components/Pagination/Pagination.d.ts.map +1 -0
- package/dist/components/Pagination/index.d.ts +3 -0
- package/dist/components/Pagination/index.d.ts.map +1 -0
- package/dist/components/Radio/Radio.d.ts +35 -0
- package/dist/components/Radio/Radio.d.ts.map +1 -0
- package/dist/components/Radio/index.d.ts +3 -0
- package/dist/components/Radio/index.d.ts.map +1 -0
- package/dist/components/Scrollbar/Scrollbar.d.ts +13 -0
- package/dist/components/Scrollbar/Scrollbar.d.ts.map +1 -0
- package/dist/components/Scrollbar/index.d.ts +3 -0
- package/dist/components/Scrollbar/index.d.ts.map +1 -0
- package/dist/components/Select/Select.d.ts +22 -0
- package/dist/components/Select/Select.d.ts.map +1 -0
- package/dist/components/Select/index.d.ts +3 -0
- package/dist/components/Select/index.d.ts.map +1 -0
- package/dist/components/Sidebar/Sidebar.d.ts +15 -0
- package/dist/components/Sidebar/Sidebar.d.ts.map +1 -0
- package/dist/components/Sidebar/SidebarItem.d.ts +14 -0
- package/dist/components/Sidebar/SidebarItem.d.ts.map +1 -0
- package/dist/components/Sidebar/SidebarSection.d.ts +10 -0
- package/dist/components/Sidebar/SidebarSection.d.ts.map +1 -0
- package/dist/components/Sidebar/index.d.ts +7 -0
- package/dist/components/Sidebar/index.d.ts.map +1 -0
- package/dist/components/Skeleton/Skeleton.d.ts +17 -0
- package/dist/components/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/components/Skeleton/index.d.ts +3 -0
- package/dist/components/Skeleton/index.d.ts.map +1 -0
- package/dist/components/Slider/Slider.d.ts +16 -0
- package/dist/components/Slider/Slider.d.ts.map +1 -0
- package/dist/components/Slider/index.d.ts +3 -0
- package/dist/components/Slider/index.d.ts.map +1 -0
- package/dist/components/Surface/Surface.d.ts +59 -0
- package/dist/components/Surface/Surface.d.ts.map +1 -0
- package/dist/components/Surface/index.d.ts +3 -0
- package/dist/components/Surface/index.d.ts.map +1 -0
- package/dist/components/Tab/Tab.d.ts +21 -0
- package/dist/components/Tab/Tab.d.ts.map +1 -0
- package/dist/components/Tab/index.d.ts +3 -0
- package/dist/components/Tab/index.d.ts.map +1 -0
- package/dist/components/TabGroup/TabGroup.d.ts +26 -0
- package/dist/components/TabGroup/TabGroup.d.ts.map +1 -0
- package/dist/components/TabGroup/index.d.ts +3 -0
- package/dist/components/TabGroup/index.d.ts.map +1 -0
- package/dist/components/Table/Table.d.ts +46 -0
- package/dist/components/Table/Table.d.ts.map +1 -0
- package/dist/components/Table/index.d.ts +3 -0
- package/dist/components/Table/index.d.ts.map +1 -0
- package/dist/components/Tag/Tag.d.ts +38 -0
- package/dist/components/Tag/Tag.d.ts.map +1 -0
- package/dist/components/Tag/index.d.ts +3 -0
- package/dist/components/Tag/index.d.ts.map +1 -0
- package/dist/components/Text/Text.d.ts +41 -0
- package/dist/components/Text/Text.d.ts.map +1 -0
- package/dist/components/Text/index.d.ts +3 -0
- package/dist/components/Text/index.d.ts.map +1 -0
- package/dist/components/Toast/Toast.d.ts +33 -0
- package/dist/components/Toast/Toast.d.ts.map +1 -0
- package/dist/components/Toast/index.d.ts +3 -0
- package/dist/components/Toast/index.d.ts.map +1 -0
- package/dist/components/Toggle/Toggle.d.ts +17 -0
- package/dist/components/Toggle/Toggle.d.ts.map +1 -0
- package/dist/components/Toggle/index.d.ts +3 -0
- package/dist/components/Toggle/index.d.ts.map +1 -0
- package/dist/components/ToggleButton/ToggleButton.d.ts +35 -0
- package/dist/components/ToggleButton/ToggleButton.d.ts.map +1 -0
- package/dist/components/ToggleButton/index.d.ts +3 -0
- package/dist/components/ToggleButton/index.d.ts.map +1 -0
- package/dist/components/ToggleButtonGroup/ToggleButtonGroup.d.ts +24 -0
- package/dist/components/ToggleButtonGroup/ToggleButtonGroup.d.ts.map +1 -0
- package/dist/components/ToggleButtonGroup/index.d.ts +3 -0
- package/dist/components/ToggleButtonGroup/index.d.ts.map +1 -0
- package/dist/components/Tooltip/Tooltip.d.ts +17 -0
- package/dist/components/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/components/Tooltip/index.d.ts +3 -0
- package/dist/components/Tooltip/index.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +82 -969
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1641 -1165
- package/dist/index.js.map +1 -1
- package/dist/r/badge.json +1 -1
- package/dist/r/banner.json +2 -2
- package/dist/r/loader.json +2 -2
- package/dist/r/menu.json +4 -4
- package/dist/r/select.json +1 -1
- package/dist/r/tab-group.json +1 -1
- package/dist/r/tab.json +1 -1
- package/dist/r/toggle.json +1 -1
- package/dist/types/icons.d.ts +14 -0
- package/dist/types/icons.d.ts.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/utils/cn.d.ts +5 -0
- package/dist/utils/cn.d.ts.map +1 -0
- package/dist/utils/css-tokens.d.ts +11 -0
- package/dist/utils/css-tokens.d.ts.map +1 -0
- package/package.json +17 -12
- package/src/components/Accordion/Accordion.tsx +10 -1
- package/src/components/Badge/Badge.stories.tsx +32 -0
- package/src/components/Badge/Badge.tsx +19 -2
- package/src/components/Banner/Banner.module.css +38 -0
- package/src/components/Banner/Banner.tsx +34 -4
- package/src/components/Button/Button.stories.tsx +1 -1
- package/src/components/Button/Button.tsx +42 -11
- package/src/components/Checkbox/Checkbox.tsx +6 -3
- package/src/components/Field/Field.tsx +20 -7
- package/src/components/Input/Input.module.css +21 -0
- package/src/components/Input/Input.tsx +62 -21
- package/src/components/Loader/Loader.module.css +17 -0
- package/src/components/Loader/Loader.stories.tsx +9 -0
- package/src/components/Loader/Loader.tsx +42 -20
- package/src/components/Menu/DestructiveMenuItem.tsx +59 -6
- package/src/components/Menu/Menu.tsx +160 -11
- package/src/components/Menu/MenuItem.module.css +2 -0
- package/src/components/Menu/MenuItem.tsx +48 -5
- package/src/components/Modal/Modal.tsx +81 -4
- package/src/components/Radio/Radio.tsx +117 -29
- package/src/components/Scrollbar/Scrollbar.module.css +5 -0
- package/src/components/Scrollbar/Scrollbar.tsx +18 -4
- package/src/components/Select/Select.tsx +139 -9
- package/src/components/Sidebar/Sidebar.module.css +7 -0
- package/src/components/Sidebar/Sidebar.tsx +102 -4
- package/src/components/Slider/Slider.module.css +5 -1
- package/src/components/Slider/Slider.stories.tsx +25 -0
- package/src/components/Slider/Slider.tsx +4 -4
- package/src/components/Tab/Tab.tsx +32 -2
- package/src/components/TabGroup/TabGroup.stories.tsx +46 -15
- package/src/components/TabGroup/TabGroup.tsx +77 -10
- package/src/components/Table/Table.module.css +10 -0
- package/src/components/Table/Table.tsx +58 -29
- package/src/components/Tag/Tag.tsx +5 -2
- package/src/components/Toast/Toast.tsx +44 -8
- package/src/components/Toggle/Toggle.tsx +23 -22
- package/src/components/Tooltip/Tooltip.tsx +42 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import { Text } from '@/components/Text';
|
|
4
4
|
import { Surface } from '@/components/Surface';
|
|
@@ -53,6 +53,16 @@ export interface MenuProps {
|
|
|
53
53
|
matchAnchorWidth?: boolean;
|
|
54
54
|
matchAnchorWidthOffset?: number;
|
|
55
55
|
usePortal?: boolean;
|
|
56
|
+
/** Container role + item role semantics. Defaults to `menu` (items are buttons with menuitem* roles, keyboard nav handled internally). `listbox` makes items render as `option` with `aria-selected`, with keyboard nav handled by the consumer via `activeIndex`. */
|
|
57
|
+
role?: 'menu' | 'listbox';
|
|
58
|
+
/** DOM id for the popup container — required when an external combobox references it via `aria-controls`/`aria-activedescendant`. */
|
|
59
|
+
id?: string;
|
|
60
|
+
/** Accessible label for the popup container. Defaults to `'Menu'` for menu role; omitted for listbox (let consumer provide via aria-labelledby on the combobox). */
|
|
61
|
+
ariaLabel?: string;
|
|
62
|
+
/** Active descendant index for listbox keyboard nav — highlights one option visually without moving DOM focus. */
|
|
63
|
+
activeIndex?: number;
|
|
64
|
+
/** Builds the DOM id for option `index`. Required in listbox mode if `aria-activedescendant` is used externally. */
|
|
65
|
+
getOptionId?: (index: number) => string;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
export const Menu: React.FC<MenuProps> = ({
|
|
@@ -73,16 +83,25 @@ export const Menu: React.FC<MenuProps> = ({
|
|
|
73
83
|
matchAnchorWidth = false,
|
|
74
84
|
matchAnchorWidthOffset = 0,
|
|
75
85
|
usePortal = true,
|
|
86
|
+
role = 'menu',
|
|
87
|
+
id,
|
|
88
|
+
ariaLabel,
|
|
89
|
+
activeIndex,
|
|
90
|
+
getOptionId,
|
|
76
91
|
}) => {
|
|
92
|
+
const isListbox = role === 'listbox';
|
|
77
93
|
const [isClosing, setIsClosing] = useState(false);
|
|
78
94
|
const [shouldRender, setShouldRender] = useState(isOpen);
|
|
79
95
|
const [isPositionReady, setIsPositionReady] = useState(false);
|
|
80
96
|
const [position, setPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
81
97
|
const [menuSize, setMenuSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
|
82
98
|
const [anchorSize, setAnchorSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
|
99
|
+
const [focusedIndex, setFocusedIndex] = useState(0);
|
|
83
100
|
const positionCalculatedRef = useRef(false);
|
|
84
101
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
85
102
|
const lastSectionsRef = useRef<MenuSection[]>([]);
|
|
103
|
+
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
104
|
+
const focusedOnOpenRef = useRef(false);
|
|
86
105
|
|
|
87
106
|
const menuSections: MenuSection[] = sections || (items ? [{ items }] : []);
|
|
88
107
|
|
|
@@ -92,6 +111,14 @@ export const Menu: React.FC<MenuProps> = ({
|
|
|
92
111
|
}
|
|
93
112
|
const sectionsToRender = isClosing ? lastSectionsRef.current : menuSections;
|
|
94
113
|
|
|
114
|
+
const flatItems = useMemo(() => {
|
|
115
|
+
const list: MenuItemData[] = [];
|
|
116
|
+
sectionsToRender.forEach((section) => {
|
|
117
|
+
section.items.forEach((it) => list.push(it));
|
|
118
|
+
});
|
|
119
|
+
return list;
|
|
120
|
+
}, [sectionsToRender]);
|
|
121
|
+
|
|
95
122
|
useEffect(() => {
|
|
96
123
|
if (isOpen) {
|
|
97
124
|
setShouldRender(true);
|
|
@@ -178,6 +205,15 @@ export const Menu: React.FC<MenuProps> = ({
|
|
|
178
205
|
};
|
|
179
206
|
}, [isOpen, shouldRender, anchorRef, side, align, sideOffset, alignOffset, sideOffsetToken, alignOffsetToken, menuSize.width, menuSize.height, menuSections.length, matchAnchorWidthOffset]);
|
|
180
207
|
|
|
208
|
+
const closeAndRestoreFocus = useCallback(() => {
|
|
209
|
+
const active = document.activeElement as HTMLElement | null;
|
|
210
|
+
const focusInside = !!(active && menuRef.current?.contains(active));
|
|
211
|
+
if (focusInside && anchorRef.current) {
|
|
212
|
+
anchorRef.current.focus();
|
|
213
|
+
}
|
|
214
|
+
onClose();
|
|
215
|
+
}, [onClose, anchorRef]);
|
|
216
|
+
|
|
181
217
|
useEffect(() => {
|
|
182
218
|
if (!isOpen || !shouldRender) return;
|
|
183
219
|
const handleClickOutside = (event: MouseEvent) => {
|
|
@@ -192,38 +228,138 @@ export const Menu: React.FC<MenuProps> = ({
|
|
|
192
228
|
|
|
193
229
|
useEffect(() => {
|
|
194
230
|
if (!isOpen) return;
|
|
195
|
-
const handleEscape = (e: KeyboardEvent) => {
|
|
231
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
232
|
+
if (e.key === 'Escape') {
|
|
233
|
+
if (isListbox) {
|
|
234
|
+
onClose();
|
|
235
|
+
} else {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
closeAndRestoreFocus();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
196
241
|
document.addEventListener('keydown', handleEscape);
|
|
197
242
|
return () => document.removeEventListener('keydown', handleEscape);
|
|
198
|
-
}, [isOpen, onClose]);
|
|
243
|
+
}, [isOpen, isListbox, onClose, closeAndRestoreFocus]);
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (!isOpen) {
|
|
247
|
+
focusedOnOpenRef.current = false;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (isListbox || !isPositionReady || focusedOnOpenRef.current) return;
|
|
251
|
+
const firstEnabled = flatItems.findIndex((it) => !it.isInactive);
|
|
252
|
+
const targetIdx = firstEnabled >= 0 ? firstEnabled : 0;
|
|
253
|
+
setFocusedIndex(targetIdx);
|
|
254
|
+
itemRefs.current[targetIdx]?.focus();
|
|
255
|
+
focusedOnOpenRef.current = true;
|
|
256
|
+
}, [isOpen, isListbox, isPositionReady, flatItems]);
|
|
257
|
+
|
|
258
|
+
const moveFocus = useCallback((idx: number) => {
|
|
259
|
+
setFocusedIndex(idx);
|
|
260
|
+
itemRefs.current[idx]?.focus();
|
|
261
|
+
}, []);
|
|
262
|
+
|
|
263
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
264
|
+
if (isListbox || !flatItems.length) return;
|
|
265
|
+
const enabled: number[] = [];
|
|
266
|
+
flatItems.forEach((it, i) => { if (!it.isInactive) enabled.push(i); });
|
|
267
|
+
if (!enabled.length) return;
|
|
268
|
+
const currentPos = enabled.indexOf(focusedIndex);
|
|
269
|
+
const safePos = currentPos < 0 ? 0 : currentPos;
|
|
270
|
+
|
|
271
|
+
switch (e.key) {
|
|
272
|
+
case 'ArrowDown': {
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
e.stopPropagation();
|
|
275
|
+
moveFocus(enabled[(safePos + 1) % enabled.length]);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
case 'ArrowUp': {
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
e.stopPropagation();
|
|
281
|
+
moveFocus(enabled[(safePos - 1 + enabled.length) % enabled.length]);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case 'Home': {
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
e.stopPropagation();
|
|
287
|
+
moveFocus(enabled[0]);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
case 'End': {
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
e.stopPropagation();
|
|
293
|
+
moveFocus(enabled[enabled.length - 1]);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case 'Tab': {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
closeAndRestoreFocus();
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}, [isListbox, flatItems, focusedIndex, moveFocus, closeAndRestoreFocus]);
|
|
199
303
|
|
|
200
304
|
if (!shouldRender || sectionsToRender.length === 0) return null;
|
|
201
305
|
|
|
202
|
-
const
|
|
306
|
+
const setItemRef = (flatIndex: number) => (el: HTMLButtonElement | null) => {
|
|
307
|
+
itemRefs.current[flatIndex] = el;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const handleItemFocus = (flatIndex: number) => () => {
|
|
311
|
+
setFocusedIndex(flatIndex);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const handleItemActivate = (item: MenuItemData) => {
|
|
315
|
+
if (item.isInactive) return;
|
|
316
|
+
item.onClick();
|
|
317
|
+
if (item.keepOpenOnClick) return;
|
|
318
|
+
if (isListbox) {
|
|
319
|
+
onClose();
|
|
320
|
+
} else {
|
|
321
|
+
closeAndRestoreFocus();
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const renderMenuItem = (item: MenuItemData, key: number, flatIndex: number) => {
|
|
203
326
|
if (item.isDestructive) {
|
|
327
|
+
const tabIndexValue = isListbox ? -1 : (focusedIndex === flatIndex ? 0 : -1);
|
|
204
328
|
return (
|
|
205
329
|
<DestructiveMenuItem
|
|
206
|
-
key={
|
|
330
|
+
key={key}
|
|
331
|
+
ref={setItemRef(flatIndex)}
|
|
207
332
|
label={item.label}
|
|
208
333
|
subtext={item.subtext}
|
|
209
334
|
holdDuration={item.holdDuration ?? 4000}
|
|
210
|
-
onClick={() => { item.onClick(); if (!item.keepOpenOnClick) onClose(); }}
|
|
335
|
+
onClick={() => { item.onClick(); if (!item.keepOpenOnClick) { if (isListbox) onClose(); else closeAndRestoreFocus(); } }}
|
|
336
|
+
tabIndex={tabIndexValue}
|
|
337
|
+
onFocus={handleItemFocus(flatIndex)}
|
|
211
338
|
/>
|
|
212
339
|
);
|
|
213
340
|
}
|
|
341
|
+
const optionId = isListbox && getOptionId ? getOptionId(flatIndex) : undefined;
|
|
342
|
+
const tabIndexValue = isListbox ? -1 : (focusedIndex === flatIndex ? 0 : -1);
|
|
214
343
|
return (
|
|
215
344
|
<MenuItemComponent
|
|
216
|
-
key={
|
|
345
|
+
key={key}
|
|
346
|
+
ref={setItemRef(flatIndex)}
|
|
217
347
|
icon={item.icon}
|
|
218
348
|
label={item.label}
|
|
219
349
|
subtext={item.subtext}
|
|
220
|
-
onClick={() =>
|
|
350
|
+
onClick={() => handleItemActivate(item)}
|
|
221
351
|
isSelected={item.isSelected}
|
|
222
352
|
isInactive={item.isInactive}
|
|
223
353
|
intent={item.intent}
|
|
224
354
|
showToggle={item.showToggle}
|
|
225
355
|
toggleValue={item.toggleValue}
|
|
226
356
|
selectionStyle={item.selectionStyle}
|
|
357
|
+
role={isListbox ? 'option' : undefined}
|
|
358
|
+
id={optionId}
|
|
359
|
+
ariaSelected={isListbox ? !!item.isSelected : undefined}
|
|
360
|
+
isActive={isListbox && activeIndex === flatIndex}
|
|
361
|
+
tabIndex={tabIndexValue}
|
|
362
|
+
onFocus={handleItemFocus(flatIndex)}
|
|
227
363
|
/>
|
|
228
364
|
);
|
|
229
365
|
};
|
|
@@ -241,8 +377,19 @@ export const Menu: React.FC<MenuProps> = ({
|
|
|
241
377
|
...(maxWidth ? { maxWidth } : {}),
|
|
242
378
|
};
|
|
243
379
|
|
|
380
|
+
let flatIndexCounter = 0;
|
|
381
|
+
const containerLabel = ariaLabel ?? (isListbox ? undefined : 'Menu');
|
|
244
382
|
const menuContent = (
|
|
245
|
-
<div
|
|
383
|
+
<div
|
|
384
|
+
ref={menuRef}
|
|
385
|
+
id={id}
|
|
386
|
+
className={`${styles.menuContainer} ${isClosing ? styles.closing : ''}`}
|
|
387
|
+
style={menuStyle}
|
|
388
|
+
role={role}
|
|
389
|
+
aria-label={containerLabel}
|
|
390
|
+
aria-orientation="vertical"
|
|
391
|
+
onKeyDown={handleKeyDown}
|
|
392
|
+
>
|
|
246
393
|
{sectionsToRender.map((section, sectionIndex) => (
|
|
247
394
|
<Surface
|
|
248
395
|
key={sectionIndex}
|
|
@@ -250,13 +397,15 @@ export const Menu: React.FC<MenuProps> = ({
|
|
|
250
397
|
className={styles.menuSection}
|
|
251
398
|
edge={sectionIndex < sectionsToRender.length - 1 ? 'bottom' : undefined}
|
|
252
399
|
elevation="none"
|
|
400
|
+
role={section.header ? 'group' : undefined}
|
|
401
|
+
aria-label={section.header}
|
|
253
402
|
>
|
|
254
403
|
{section.header && (
|
|
255
|
-
<div className={styles.sectionHeader}>
|
|
404
|
+
<div className={styles.sectionHeader} aria-hidden="true">
|
|
256
405
|
<Text variant="text-body-small-emphasis" as="span" className={styles.sectionLabel}>{section.header}</Text>
|
|
257
406
|
</div>
|
|
258
407
|
)}
|
|
259
|
-
{section.items.map((item, itemIndex) => renderMenuItem(item, itemIndex))}
|
|
408
|
+
{section.items.map((item, itemIndex) => renderMenuItem(item, itemIndex, flatIndexCounter++))}
|
|
260
409
|
</Surface>
|
|
261
410
|
))}
|
|
262
411
|
</div>
|
|
@@ -21,6 +21,18 @@ export interface MenuItemProps {
|
|
|
21
21
|
showTrailingChevron?: boolean;
|
|
22
22
|
checkIcon?: IconComponent;
|
|
23
23
|
chevronIcon?: IconComponent;
|
|
24
|
+
/** Override the implicit role. Defaults derive from menu context: `menuitem`, `menuitemcheckbox` (showToggle), or `menuitemradio` (radio selectionStyle). Pass `'option'` for listbox usage. */
|
|
25
|
+
role?: string;
|
|
26
|
+
/** Element id — required when used as an `option` referenced by `aria-activedescendant`. */
|
|
27
|
+
id?: string;
|
|
28
|
+
/** Maps to `aria-selected`. Used when role is `option`. */
|
|
29
|
+
ariaSelected?: boolean;
|
|
30
|
+
/** Visual highlight for keyboard "active" cursor (separate from selection state). */
|
|
31
|
+
isActive?: boolean;
|
|
32
|
+
/** Override default tabIndex (e.g. -1 for listbox options driven by aria-activedescendant). */
|
|
33
|
+
tabIndex?: number;
|
|
34
|
+
onKeyDown?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
|
|
35
|
+
onFocus?: (e: React.FocusEvent<HTMLButtonElement>) => void;
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
export const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(
|
|
@@ -39,11 +51,34 @@ export const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(
|
|
|
39
51
|
showTrailingChevron = false,
|
|
40
52
|
checkIcon: CheckIcon,
|
|
41
53
|
chevronIcon: ChevronIcon,
|
|
54
|
+
role,
|
|
55
|
+
id,
|
|
56
|
+
ariaSelected,
|
|
57
|
+
isActive = false,
|
|
58
|
+
tabIndex,
|
|
59
|
+
onKeyDown,
|
|
60
|
+
onFocus,
|
|
42
61
|
}, ref) => {
|
|
43
62
|
const useRadioStyle = selectionStyle === 'radio';
|
|
44
63
|
const noOverlay = selectionStyle === 'noOverlay' || useRadioStyle;
|
|
45
64
|
const showSelectedOverlay = isSelected && !noOverlay;
|
|
46
65
|
|
|
66
|
+
const resolvedRole = role ?? (
|
|
67
|
+
showToggle ? 'menuitemcheckbox'
|
|
68
|
+
: useRadioStyle ? 'menuitemradio'
|
|
69
|
+
: 'menuitem'
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const ariaCheckedValue: boolean | undefined = role
|
|
73
|
+
? undefined
|
|
74
|
+
: resolvedRole === 'menuitemcheckbox'
|
|
75
|
+
? !!toggleValue
|
|
76
|
+
: resolvedRole === 'menuitemradio'
|
|
77
|
+
? !!isSelected
|
|
78
|
+
: undefined;
|
|
79
|
+
|
|
80
|
+
const ariaSelectedValue: boolean | undefined = role && ariaSelected !== undefined ? ariaSelected : undefined;
|
|
81
|
+
|
|
47
82
|
return (
|
|
48
83
|
<Surface
|
|
49
84
|
ref={ref as React.Ref<HTMLElement>}
|
|
@@ -51,13 +86,21 @@ export const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(
|
|
|
51
86
|
interactive={!isInactive}
|
|
52
87
|
selected={showSelectedOverlay}
|
|
53
88
|
radius="sm"
|
|
54
|
-
className={`${styles.menuItem} ${isSelected ? styles.menuItemSelected : ''} ${isInactive ? styles.menuItemInactive : ''} ${intent === 'negative' ? styles.menuItemNegative : ''} ${labelColor === 'tertiary' ? styles.menuItemLabelTertiary : ''}`}
|
|
89
|
+
className={`${styles.menuItem} ${isSelected ? styles.menuItemSelected : ''} ${isInactive ? styles.menuItemInactive : ''} ${intent === 'negative' ? styles.menuItemNegative : ''} ${labelColor === 'tertiary' ? styles.menuItemLabelTertiary : ''} ${isActive ? styles.menuItemActive : ''}`}
|
|
55
90
|
onClick={onClick}
|
|
56
91
|
type="button"
|
|
57
92
|
inactive={isInactive}
|
|
93
|
+
role={resolvedRole}
|
|
94
|
+
id={id}
|
|
95
|
+
aria-checked={ariaCheckedValue}
|
|
96
|
+
aria-selected={ariaSelectedValue}
|
|
97
|
+
aria-disabled={isInactive || undefined}
|
|
98
|
+
tabIndex={tabIndex}
|
|
99
|
+
onKeyDown={onKeyDown}
|
|
100
|
+
onFocus={onFocus}
|
|
58
101
|
>
|
|
59
102
|
{Icon && (
|
|
60
|
-
<div className={styles.iconPrefix}>
|
|
103
|
+
<div className={styles.iconPrefix} aria-hidden="true">
|
|
61
104
|
<Icon size={20} />
|
|
62
105
|
</div>
|
|
63
106
|
)}
|
|
@@ -77,19 +120,19 @@ export const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(
|
|
|
77
120
|
)}
|
|
78
121
|
</div>
|
|
79
122
|
{showToggle && (
|
|
80
|
-
<div className={styles.toggleSuffix}>
|
|
123
|
+
<div className={styles.toggleSuffix} aria-hidden="true">
|
|
81
124
|
<div className={`${styles.toggle} ${toggleValue ? styles.toggleOn : ''}`}>
|
|
82
125
|
<div className={styles.toggleThumb} />
|
|
83
126
|
</div>
|
|
84
127
|
</div>
|
|
85
128
|
)}
|
|
86
129
|
{useRadioStyle && isSelected && CheckIcon && (
|
|
87
|
-
<div className={styles.checkSuffix}>
|
|
130
|
+
<div className={styles.checkSuffix} aria-hidden="true">
|
|
88
131
|
<CheckIcon size={20} />
|
|
89
132
|
</div>
|
|
90
133
|
)}
|
|
91
134
|
{showTrailingChevron && ChevronIcon && (
|
|
92
|
-
<div className={styles.chevronSuffix}>
|
|
135
|
+
<div className={styles.chevronSuffix} aria-hidden="true">
|
|
93
136
|
<ChevronIcon size={20} />
|
|
94
137
|
</div>
|
|
95
138
|
)}
|
|
@@ -14,6 +14,27 @@ const WIDTH_MAP: Record<ModalWidth, string> = {
|
|
|
14
14
|
|
|
15
15
|
const CLOSE_ANIMATION_MS = 220;
|
|
16
16
|
|
|
17
|
+
const FOCUSABLE_SELECTOR = [
|
|
18
|
+
'a[href]',
|
|
19
|
+
'area[href]',
|
|
20
|
+
'button:not([disabled])',
|
|
21
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
22
|
+
'select:not([disabled])',
|
|
23
|
+
'textarea:not([disabled])',
|
|
24
|
+
'iframe',
|
|
25
|
+
'object',
|
|
26
|
+
'embed',
|
|
27
|
+
'[contenteditable="true"]',
|
|
28
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
29
|
+
].join(',');
|
|
30
|
+
|
|
31
|
+
const getFocusableElements = (root: HTMLElement): HTMLElement[] => {
|
|
32
|
+
const nodes = root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
33
|
+
return Array.from(nodes).filter(
|
|
34
|
+
(el) => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true'
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
17
38
|
export interface ModalProps {
|
|
18
39
|
isOpen: boolean;
|
|
19
40
|
onClose: () => void;
|
|
@@ -23,6 +44,7 @@ export interface ModalProps {
|
|
|
23
44
|
footer?: React.ReactNode;
|
|
24
45
|
width?: ModalWidth | string;
|
|
25
46
|
bodyClassName?: string;
|
|
47
|
+
initialFocus?: React.RefObject<HTMLElement>;
|
|
26
48
|
}
|
|
27
49
|
|
|
28
50
|
export const Modal: React.FC<ModalProps> = ({
|
|
@@ -34,9 +56,12 @@ export const Modal: React.FC<ModalProps> = ({
|
|
|
34
56
|
footer,
|
|
35
57
|
width = 'md',
|
|
36
58
|
bodyClassName,
|
|
59
|
+
initialFocus,
|
|
37
60
|
}) => {
|
|
38
61
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
39
63
|
const titleId = useId();
|
|
64
|
+
const subtitleId = useId();
|
|
40
65
|
const [isClosing, setIsClosing] = useState(false);
|
|
41
66
|
const [shouldRender, setShouldRender] = useState(isOpen);
|
|
42
67
|
|
|
@@ -72,20 +97,69 @@ export const Modal: React.FC<ModalProps> = ({
|
|
|
72
97
|
|
|
73
98
|
useEffect(() => {
|
|
74
99
|
if (!isOpen || !shouldRender) return;
|
|
100
|
+
previouslyFocusedRef.current =
|
|
101
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
75
102
|
const id = requestAnimationFrame(() => {
|
|
76
|
-
dialogRef.current
|
|
103
|
+
const dialog = dialogRef.current;
|
|
104
|
+
if (!dialog) return;
|
|
105
|
+
if (initialFocus?.current) {
|
|
106
|
+
initialFocus.current.focus();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const focusables = getFocusableElements(dialog);
|
|
110
|
+
if (focusables.length > 0) {
|
|
111
|
+
focusables[0].focus();
|
|
112
|
+
} else {
|
|
113
|
+
dialog.focus();
|
|
114
|
+
}
|
|
77
115
|
});
|
|
78
|
-
return () =>
|
|
79
|
-
|
|
116
|
+
return () => {
|
|
117
|
+
cancelAnimationFrame(id);
|
|
118
|
+
const previous = previouslyFocusedRef.current;
|
|
119
|
+
if (previous && document.contains(previous)) {
|
|
120
|
+
previous.focus();
|
|
121
|
+
}
|
|
122
|
+
previouslyFocusedRef.current = null;
|
|
123
|
+
};
|
|
124
|
+
}, [isOpen, shouldRender, initialFocus]);
|
|
125
|
+
|
|
126
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
127
|
+
if (e.key !== 'Tab') return;
|
|
128
|
+
const dialog = dialogRef.current;
|
|
129
|
+
if (!dialog) return;
|
|
130
|
+
const focusables = getFocusableElements(dialog);
|
|
131
|
+
if (focusables.length === 0) {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
dialog.focus();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const first = focusables[0];
|
|
137
|
+
const last = focusables[focusables.length - 1];
|
|
138
|
+
const active = document.activeElement;
|
|
139
|
+
if (e.shiftKey) {
|
|
140
|
+
if (active === first || active === dialog || !dialog.contains(active)) {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
last.focus();
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
if (active === last || !dialog.contains(active)) {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
first.focus();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
80
151
|
|
|
81
152
|
if (!shouldRender) return null;
|
|
82
153
|
|
|
154
|
+
const describedById = subtitle != null ? subtitleId : undefined;
|
|
155
|
+
|
|
83
156
|
return createPortal(
|
|
84
157
|
<div
|
|
85
158
|
className={`${styles.backdrop} ${isClosing ? styles.closing : ''}`}
|
|
86
159
|
onMouseDown={(e) => {
|
|
87
160
|
if (e.target === e.currentTarget) onClose();
|
|
88
161
|
}}
|
|
162
|
+
onKeyDown={handleKeyDown}
|
|
89
163
|
>
|
|
90
164
|
<Surface
|
|
91
165
|
ref={dialogRef}
|
|
@@ -97,6 +171,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|
|
97
171
|
role="dialog"
|
|
98
172
|
aria-modal="true"
|
|
99
173
|
aria-labelledby={titleId}
|
|
174
|
+
aria-describedby={describedById}
|
|
100
175
|
tabIndex={-1}
|
|
101
176
|
>
|
|
102
177
|
<div className={styles.header}>
|
|
@@ -104,7 +179,9 @@ export const Modal: React.FC<ModalProps> = ({
|
|
|
104
179
|
{title}
|
|
105
180
|
</Text>
|
|
106
181
|
{subtitle != null && (
|
|
107
|
-
<div className={styles.subtitle}>
|
|
182
|
+
<div id={subtitleId} className={styles.subtitle}>
|
|
183
|
+
{subtitle}
|
|
184
|
+
</div>
|
|
108
185
|
)}
|
|
109
186
|
</div>
|
|
110
187
|
<div className={`${styles.body} ${bodyClassName ?? ''}`.trim()}>
|