@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.
Files changed (215) hide show
  1. package/dist/components/Accordion/Accordion.d.ts +29 -0
  2. package/dist/components/Accordion/Accordion.d.ts.map +1 -0
  3. package/dist/components/Accordion/index.d.ts +3 -0
  4. package/dist/components/Accordion/index.d.ts.map +1 -0
  5. package/dist/components/Badge/Badge.d.ts +18 -0
  6. package/dist/components/Badge/Badge.d.ts.map +1 -0
  7. package/dist/components/Badge/index.d.ts +3 -0
  8. package/dist/components/Badge/index.d.ts.map +1 -0
  9. package/dist/components/Banner/Banner.d.ts +16 -0
  10. package/dist/components/Banner/Banner.d.ts.map +1 -0
  11. package/dist/components/Banner/index.d.ts +3 -0
  12. package/dist/components/Banner/index.d.ts.map +1 -0
  13. package/dist/components/Breadcrumb/Breadcrumb.d.ts +15 -0
  14. package/dist/components/Breadcrumb/Breadcrumb.d.ts.map +1 -0
  15. package/dist/components/Breadcrumb/index.d.ts +3 -0
  16. package/dist/components/Breadcrumb/index.d.ts.map +1 -0
  17. package/dist/components/Button/Button.d.ts +85 -0
  18. package/dist/components/Button/Button.d.ts.map +1 -0
  19. package/dist/components/Button/index.d.ts +3 -0
  20. package/dist/components/Button/index.d.ts.map +1 -0
  21. package/dist/components/ButtonGroup/ButtonGroup.d.ts +26 -0
  22. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -0
  23. package/dist/components/ButtonGroup/index.d.ts +3 -0
  24. package/dist/components/ButtonGroup/index.d.ts.map +1 -0
  25. package/dist/components/Card/Card.d.ts +20 -0
  26. package/dist/components/Card/Card.d.ts.map +1 -0
  27. package/dist/components/Card/index.d.ts +3 -0
  28. package/dist/components/Card/index.d.ts.map +1 -0
  29. package/dist/components/Checkbox/Checkbox.d.ts +18 -0
  30. package/dist/components/Checkbox/Checkbox.d.ts.map +1 -0
  31. package/dist/components/Checkbox/index.d.ts +3 -0
  32. package/dist/components/Checkbox/index.d.ts.map +1 -0
  33. package/dist/components/Divider/Divider.d.ts +9 -0
  34. package/dist/components/Divider/Divider.d.ts.map +1 -0
  35. package/dist/components/Divider/index.d.ts +3 -0
  36. package/dist/components/Divider/index.d.ts.map +1 -0
  37. package/dist/components/EmptyState/EmptyState.d.ts +9 -0
  38. package/dist/components/EmptyState/EmptyState.d.ts.map +1 -0
  39. package/dist/components/EmptyState/index.d.ts +3 -0
  40. package/dist/components/EmptyState/index.d.ts.map +1 -0
  41. package/dist/components/ErrorBoundary/ErrorBoundary.d.ts +18 -0
  42. package/dist/components/ErrorBoundary/ErrorBoundary.d.ts.map +1 -0
  43. package/dist/components/ErrorBoundary/index.d.ts +3 -0
  44. package/dist/components/ErrorBoundary/index.d.ts.map +1 -0
  45. package/dist/components/Fade/Fade.d.ts +11 -0
  46. package/dist/components/Fade/Fade.d.ts.map +1 -0
  47. package/dist/components/Fade/index.d.ts +3 -0
  48. package/dist/components/Fade/index.d.ts.map +1 -0
  49. package/dist/components/Field/Field.d.ts +9 -0
  50. package/dist/components/Field/Field.d.ts.map +1 -0
  51. package/dist/components/Field/index.d.ts +3 -0
  52. package/dist/components/Field/index.d.ts.map +1 -0
  53. package/dist/components/Header/Header.d.ts +12 -0
  54. package/dist/components/Header/Header.d.ts.map +1 -0
  55. package/dist/components/Header/index.d.ts +3 -0
  56. package/dist/components/Header/index.d.ts.map +1 -0
  57. package/dist/components/Input/Input.d.ts +27 -0
  58. package/dist/components/Input/Input.d.ts.map +1 -0
  59. package/dist/components/Input/index.d.ts +3 -0
  60. package/dist/components/Input/index.d.ts.map +1 -0
  61. package/dist/components/LabelWrap/LabelWrap.d.ts +27 -0
  62. package/dist/components/LabelWrap/LabelWrap.d.ts.map +1 -0
  63. package/dist/components/LabelWrap/index.d.ts +2 -0
  64. package/dist/components/LabelWrap/index.d.ts.map +1 -0
  65. package/dist/components/Loader/Loader.d.ts +33 -0
  66. package/dist/components/Loader/Loader.d.ts.map +1 -0
  67. package/dist/components/Loader/index.d.ts +3 -0
  68. package/dist/components/Loader/index.d.ts.map +1 -0
  69. package/dist/components/Menu/DestructiveMenuItem.d.ts +12 -0
  70. package/dist/components/Menu/DestructiveMenuItem.d.ts.map +1 -0
  71. package/dist/components/Menu/Menu.d.ts +55 -0
  72. package/dist/components/Menu/Menu.d.ts.map +1 -0
  73. package/dist/components/Menu/MenuItem.d.ts +33 -0
  74. package/dist/components/Menu/MenuItem.d.ts.map +1 -0
  75. package/dist/components/Menu/index.d.ts +7 -0
  76. package/dist/components/Menu/index.d.ts.map +1 -0
  77. package/dist/components/Modal/Modal.d.ts +15 -0
  78. package/dist/components/Modal/Modal.d.ts.map +1 -0
  79. package/dist/components/Modal/index.d.ts +3 -0
  80. package/dist/components/Modal/index.d.ts.map +1 -0
  81. package/dist/components/Pagination/Pagination.d.ts +16 -0
  82. package/dist/components/Pagination/Pagination.d.ts.map +1 -0
  83. package/dist/components/Pagination/index.d.ts +3 -0
  84. package/dist/components/Pagination/index.d.ts.map +1 -0
  85. package/dist/components/Radio/Radio.d.ts +35 -0
  86. package/dist/components/Radio/Radio.d.ts.map +1 -0
  87. package/dist/components/Radio/index.d.ts +3 -0
  88. package/dist/components/Radio/index.d.ts.map +1 -0
  89. package/dist/components/Scrollbar/Scrollbar.d.ts +13 -0
  90. package/dist/components/Scrollbar/Scrollbar.d.ts.map +1 -0
  91. package/dist/components/Scrollbar/index.d.ts +3 -0
  92. package/dist/components/Scrollbar/index.d.ts.map +1 -0
  93. package/dist/components/Select/Select.d.ts +22 -0
  94. package/dist/components/Select/Select.d.ts.map +1 -0
  95. package/dist/components/Select/index.d.ts +3 -0
  96. package/dist/components/Select/index.d.ts.map +1 -0
  97. package/dist/components/Sidebar/Sidebar.d.ts +15 -0
  98. package/dist/components/Sidebar/Sidebar.d.ts.map +1 -0
  99. package/dist/components/Sidebar/SidebarItem.d.ts +14 -0
  100. package/dist/components/Sidebar/SidebarItem.d.ts.map +1 -0
  101. package/dist/components/Sidebar/SidebarSection.d.ts +10 -0
  102. package/dist/components/Sidebar/SidebarSection.d.ts.map +1 -0
  103. package/dist/components/Sidebar/index.d.ts +7 -0
  104. package/dist/components/Sidebar/index.d.ts.map +1 -0
  105. package/dist/components/Skeleton/Skeleton.d.ts +17 -0
  106. package/dist/components/Skeleton/Skeleton.d.ts.map +1 -0
  107. package/dist/components/Skeleton/index.d.ts +3 -0
  108. package/dist/components/Skeleton/index.d.ts.map +1 -0
  109. package/dist/components/Slider/Slider.d.ts +16 -0
  110. package/dist/components/Slider/Slider.d.ts.map +1 -0
  111. package/dist/components/Slider/index.d.ts +3 -0
  112. package/dist/components/Slider/index.d.ts.map +1 -0
  113. package/dist/components/Surface/Surface.d.ts +59 -0
  114. package/dist/components/Surface/Surface.d.ts.map +1 -0
  115. package/dist/components/Surface/index.d.ts +3 -0
  116. package/dist/components/Surface/index.d.ts.map +1 -0
  117. package/dist/components/Tab/Tab.d.ts +21 -0
  118. package/dist/components/Tab/Tab.d.ts.map +1 -0
  119. package/dist/components/Tab/index.d.ts +3 -0
  120. package/dist/components/Tab/index.d.ts.map +1 -0
  121. package/dist/components/TabGroup/TabGroup.d.ts +26 -0
  122. package/dist/components/TabGroup/TabGroup.d.ts.map +1 -0
  123. package/dist/components/TabGroup/index.d.ts +3 -0
  124. package/dist/components/TabGroup/index.d.ts.map +1 -0
  125. package/dist/components/Table/Table.d.ts +46 -0
  126. package/dist/components/Table/Table.d.ts.map +1 -0
  127. package/dist/components/Table/index.d.ts +3 -0
  128. package/dist/components/Table/index.d.ts.map +1 -0
  129. package/dist/components/Tag/Tag.d.ts +38 -0
  130. package/dist/components/Tag/Tag.d.ts.map +1 -0
  131. package/dist/components/Tag/index.d.ts +3 -0
  132. package/dist/components/Tag/index.d.ts.map +1 -0
  133. package/dist/components/Text/Text.d.ts +41 -0
  134. package/dist/components/Text/Text.d.ts.map +1 -0
  135. package/dist/components/Text/index.d.ts +3 -0
  136. package/dist/components/Text/index.d.ts.map +1 -0
  137. package/dist/components/Toast/Toast.d.ts +33 -0
  138. package/dist/components/Toast/Toast.d.ts.map +1 -0
  139. package/dist/components/Toast/index.d.ts +3 -0
  140. package/dist/components/Toast/index.d.ts.map +1 -0
  141. package/dist/components/Toggle/Toggle.d.ts +17 -0
  142. package/dist/components/Toggle/Toggle.d.ts.map +1 -0
  143. package/dist/components/Toggle/index.d.ts +3 -0
  144. package/dist/components/Toggle/index.d.ts.map +1 -0
  145. package/dist/components/ToggleButton/ToggleButton.d.ts +35 -0
  146. package/dist/components/ToggleButton/ToggleButton.d.ts.map +1 -0
  147. package/dist/components/ToggleButton/index.d.ts +3 -0
  148. package/dist/components/ToggleButton/index.d.ts.map +1 -0
  149. package/dist/components/ToggleButtonGroup/ToggleButtonGroup.d.ts +24 -0
  150. package/dist/components/ToggleButtonGroup/ToggleButtonGroup.d.ts.map +1 -0
  151. package/dist/components/ToggleButtonGroup/index.d.ts +3 -0
  152. package/dist/components/ToggleButtonGroup/index.d.ts.map +1 -0
  153. package/dist/components/Tooltip/Tooltip.d.ts +17 -0
  154. package/dist/components/Tooltip/Tooltip.d.ts.map +1 -0
  155. package/dist/components/Tooltip/index.d.ts +3 -0
  156. package/dist/components/Tooltip/index.d.ts.map +1 -0
  157. package/dist/index.css +1 -1
  158. package/dist/index.d.ts +82 -969
  159. package/dist/index.d.ts.map +1 -0
  160. package/dist/index.js +1641 -1165
  161. package/dist/index.js.map +1 -1
  162. package/dist/r/badge.json +1 -1
  163. package/dist/r/banner.json +2 -2
  164. package/dist/r/loader.json +2 -2
  165. package/dist/r/menu.json +4 -4
  166. package/dist/r/select.json +1 -1
  167. package/dist/r/tab-group.json +1 -1
  168. package/dist/r/tab.json +1 -1
  169. package/dist/r/toggle.json +1 -1
  170. package/dist/types/icons.d.ts +14 -0
  171. package/dist/types/icons.d.ts.map +1 -0
  172. package/dist/types/index.d.ts +2 -0
  173. package/dist/types/index.d.ts.map +1 -0
  174. package/dist/utils/cn.d.ts +5 -0
  175. package/dist/utils/cn.d.ts.map +1 -0
  176. package/dist/utils/css-tokens.d.ts +11 -0
  177. package/dist/utils/css-tokens.d.ts.map +1 -0
  178. package/package.json +17 -12
  179. package/src/components/Accordion/Accordion.tsx +10 -1
  180. package/src/components/Badge/Badge.stories.tsx +32 -0
  181. package/src/components/Badge/Badge.tsx +19 -2
  182. package/src/components/Banner/Banner.module.css +38 -0
  183. package/src/components/Banner/Banner.tsx +34 -4
  184. package/src/components/Button/Button.stories.tsx +1 -1
  185. package/src/components/Button/Button.tsx +42 -11
  186. package/src/components/Checkbox/Checkbox.tsx +6 -3
  187. package/src/components/Field/Field.tsx +20 -7
  188. package/src/components/Input/Input.module.css +21 -0
  189. package/src/components/Input/Input.tsx +62 -21
  190. package/src/components/Loader/Loader.module.css +17 -0
  191. package/src/components/Loader/Loader.stories.tsx +9 -0
  192. package/src/components/Loader/Loader.tsx +42 -20
  193. package/src/components/Menu/DestructiveMenuItem.tsx +59 -6
  194. package/src/components/Menu/Menu.tsx +160 -11
  195. package/src/components/Menu/MenuItem.module.css +2 -0
  196. package/src/components/Menu/MenuItem.tsx +48 -5
  197. package/src/components/Modal/Modal.tsx +81 -4
  198. package/src/components/Radio/Radio.tsx +117 -29
  199. package/src/components/Scrollbar/Scrollbar.module.css +5 -0
  200. package/src/components/Scrollbar/Scrollbar.tsx +18 -4
  201. package/src/components/Select/Select.tsx +139 -9
  202. package/src/components/Sidebar/Sidebar.module.css +7 -0
  203. package/src/components/Sidebar/Sidebar.tsx +102 -4
  204. package/src/components/Slider/Slider.module.css +5 -1
  205. package/src/components/Slider/Slider.stories.tsx +25 -0
  206. package/src/components/Slider/Slider.tsx +4 -4
  207. package/src/components/Tab/Tab.tsx +32 -2
  208. package/src/components/TabGroup/TabGroup.stories.tsx +46 -15
  209. package/src/components/TabGroup/TabGroup.tsx +77 -10
  210. package/src/components/Table/Table.module.css +10 -0
  211. package/src/components/Table/Table.tsx +58 -29
  212. package/src/components/Tag/Tag.tsx +5 -2
  213. package/src/components/Toast/Toast.tsx +44 -8
  214. package/src/components/Toggle/Toggle.tsx +23 -22
  215. 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) => { if (e.key === 'Escape') onClose(); };
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 renderMenuItem = (item: MenuItemData, index: number) => {
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={index}
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={index}
345
+ key={key}
346
+ ref={setItemRef(flatIndex)}
217
347
  icon={item.icon}
218
348
  label={item.label}
219
349
  subtext={item.subtext}
220
- onClick={() => { if (!item.isInactive) { item.onClick(); if (!item.keepOpenOnClick) onClose(); } }}
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 ref={menuRef} className={`${styles.menuContainer} ${isClosing ? styles.closing : ''}`} style={menuStyle} role="menu" aria-label="Menu">
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>
@@ -41,6 +41,8 @@
41
41
 
42
42
  .menuItemInactive { opacity: 0.5; cursor: default; pointer-events: none; }
43
43
 
44
+ .menuItemActive { background-color: var(--color-interaction-hover); }
45
+
44
46
  .toggleSuffix { flex-shrink: 0; display: flex; align-items: center; }
45
47
 
46
48
  .toggle {
@@ -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?.focus();
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 () => cancelAnimationFrame(id);
79
- }, [isOpen, shouldRender]);
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}>{subtitle}</div>
182
+ <div id={subtitleId} className={styles.subtitle}>
183
+ {subtitle}
184
+ </div>
108
185
  )}
109
186
  </div>
110
187
  <div className={`${styles.body} ${bodyClassName ?? ''}`.trim()}>