@duro-app/ui 5.1.0 → 5.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 (233) hide show
  1. package/dist/Table-Cm5cCnZD.js +1524 -0
  2. package/dist/Table-Cm5cCnZD.js.map +1 -0
  3. package/dist/components/ActionBar/styles.css.d.ts +1 -1
  4. package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
  5. package/dist/components/Badge/Badge.d.ts +1 -1
  6. package/dist/components/Badge/Badge.d.ts.map +1 -1
  7. package/dist/components/Badge/Badge.stories.d.ts +3 -0
  8. package/dist/components/Badge/Badge.stories.d.ts.map +1 -1
  9. package/dist/components/Badge/styles.css.d.ts +4 -0
  10. package/dist/components/Badge/styles.css.d.ts.map +1 -1
  11. package/dist/components/Button/Button.d.ts +11 -2
  12. package/dist/components/Button/Button.d.ts.map +1 -1
  13. package/dist/components/Button/Button.stories.d.ts +1 -0
  14. package/dist/components/Button/Button.stories.d.ts.map +1 -1
  15. package/dist/components/ButtonGroup/ButtonGroup.d.ts +9 -2
  16. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -1
  17. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
  18. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +19 -0
  19. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  20. package/dist/components/ButtonGroup/ButtonGroupContext.d.ts +13 -0
  21. package/dist/components/ButtonGroup/ButtonGroupContext.d.ts.map +1 -0
  22. package/dist/components/ButtonGroup/attached.css.d.ts +34 -0
  23. package/dist/components/ButtonGroup/attached.css.d.ts.map +1 -0
  24. package/dist/components/ButtonGroup/styles.css.d.ts +7 -0
  25. package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -1
  26. package/dist/components/ButtonGroup/useGroupedControl.d.ts +25 -0
  27. package/dist/components/ButtonGroup/useGroupedControl.d.ts.map +1 -0
  28. package/dist/components/Combobox/Combobox.d.ts +1 -1
  29. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  30. package/dist/components/Combobox/styles.css.d.ts +0 -64
  31. package/dist/components/Combobox/styles.css.d.ts.map +1 -1
  32. package/dist/components/Dialog/Dialog.d.ts +11 -3
  33. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  34. package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
  35. package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
  36. package/dist/components/Dialog/styles.css.d.ts +3 -2
  37. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  38. package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
  39. package/dist/components/DragDrop/styles.css.d.ts +11 -3
  40. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  41. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  42. package/dist/components/Drawer/styles.css.d.ts +2 -2
  43. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  44. package/dist/components/Grid/Grid.d.ts +11 -2
  45. package/dist/components/Grid/Grid.d.ts.map +1 -1
  46. package/dist/components/Grid/Grid.stories.d.ts +2 -0
  47. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  48. package/dist/components/Input/Input.d.ts +6 -1
  49. package/dist/components/Input/Input.d.ts.map +1 -1
  50. package/dist/components/Input/Input.meta.d.ts.map +1 -1
  51. package/dist/components/Input/Input.stories.d.ts +1 -0
  52. package/dist/components/Input/Input.stories.d.ts.map +1 -1
  53. package/dist/components/Input/styles.css.d.ts +18 -0
  54. package/dist/components/Input/styles.css.d.ts.map +1 -1
  55. package/dist/components/InputGroup/InputGroup.d.ts +6 -1
  56. package/dist/components/InputGroup/InputGroup.d.ts.map +1 -1
  57. package/dist/components/InputGroup/InputGroup.stories.d.ts +1 -0
  58. package/dist/components/InputGroup/InputGroup.stories.d.ts.map +1 -1
  59. package/dist/components/Listbox/Listbox.d.ts +75 -0
  60. package/dist/components/Listbox/Listbox.d.ts.map +1 -0
  61. package/dist/components/Listbox/Listbox.meta.d.ts +3 -0
  62. package/dist/components/Listbox/Listbox.meta.d.ts.map +1 -0
  63. package/dist/components/Listbox/Listbox.stories.d.ts +7 -0
  64. package/dist/components/Listbox/Listbox.stories.d.ts.map +1 -0
  65. package/dist/components/Listbox/styles.css.d.ts +75 -0
  66. package/dist/components/Listbox/styles.css.d.ts.map +1 -0
  67. package/dist/components/Menu/Menu.d.ts +18 -5
  68. package/dist/components/Menu/Menu.d.ts.map +1 -1
  69. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  70. package/dist/components/Menu/Menu.stories.d.ts +7 -0
  71. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  72. package/dist/components/Menu/MenuContext.d.ts +3 -0
  73. package/dist/components/Menu/MenuContext.d.ts.map +1 -1
  74. package/dist/components/Menu/styles.css.d.ts +24 -15
  75. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  76. package/dist/components/Menu/useMenuRoot.d.ts +14 -1
  77. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  78. package/dist/components/Popover/Popover.d.ts +7 -2
  79. package/dist/components/Popover/Popover.d.ts.map +1 -1
  80. package/dist/components/Popover/styles.css.d.ts +4 -1
  81. package/dist/components/Popover/styles.css.d.ts.map +1 -1
  82. package/dist/components/ScrollArea/styles.css.d.ts +1 -1
  83. package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
  84. package/dist/components/Select/Select.d.ts.map +1 -1
  85. package/dist/components/Select/Select.stories.d.ts +2 -0
  86. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  87. package/dist/components/Select/styles.css.d.ts +2 -2
  88. package/dist/components/Select/styles.css.d.ts.map +1 -1
  89. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  90. package/dist/components/TextLink/TextLink.d.ts +24 -0
  91. package/dist/components/TextLink/TextLink.d.ts.map +1 -0
  92. package/dist/components/TextLink/TextLink.meta.d.ts +3 -0
  93. package/dist/components/TextLink/TextLink.meta.d.ts.map +1 -0
  94. package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
  95. package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -0
  96. package/dist/components/TextLink/styles.css.d.ts +28 -0
  97. package/dist/components/TextLink/styles.css.d.ts.map +1 -0
  98. package/dist/components/ThemeProvider/Layers.stories.d.ts +24 -0
  99. package/dist/components/ThemeProvider/Layers.stories.d.ts.map +1 -0
  100. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  101. package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts +1 -0
  102. package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts.map +1 -1
  103. package/dist/components/Toast/styles.css.d.ts +1 -0
  104. package/dist/components/Toast/styles.css.d.ts.map +1 -1
  105. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  106. package/dist/components/Toggle/styles.css.d.ts +3 -0
  107. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  108. package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
  109. package/dist/components/ToggleGroup/styles.css.d.ts +5 -0
  110. package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
  111. package/dist/components/Toolbar/ControlContextBoundary.d.ts +10 -0
  112. package/dist/components/Toolbar/ControlContextBoundary.d.ts.map +1 -0
  113. package/dist/components/Toolbar/Toolbar.d.ts +22 -0
  114. package/dist/components/Toolbar/Toolbar.d.ts.map +1 -0
  115. package/dist/components/Toolbar/Toolbar.meta.d.ts +3 -0
  116. package/dist/components/Toolbar/Toolbar.meta.d.ts.map +1 -0
  117. package/dist/components/Toolbar/Toolbar.stories.d.ts +28 -0
  118. package/dist/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
  119. package/dist/components/Toolbar/ToolbarContext.d.ts +10 -0
  120. package/dist/components/Toolbar/ToolbarContext.d.ts.map +1 -0
  121. package/dist/components/Toolbar/styles.css.d.ts +16 -0
  122. package/dist/components/Toolbar/styles.css.d.ts.map +1 -0
  123. package/dist/components/Tooltip/styles.css.d.ts +1 -1
  124. package/dist/components/Tooltip/styles.css.d.ts.map +1 -1
  125. package/dist/components/VirtualTable/styles.css.d.ts +1 -1
  126. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  127. package/dist/components/VisuallyHidden/VisuallyHidden.d.ts +14 -0
  128. package/dist/components/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  129. package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts +3 -0
  130. package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts.map +1 -0
  131. package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts +7 -0
  132. package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts.map +1 -0
  133. package/dist/docs/FlexGrow.stories.d.ts +7 -0
  134. package/dist/docs/FlexGrow.stories.d.ts.map +1 -0
  135. package/dist/index.css +1 -1
  136. package/dist/index.d.ts +7 -1
  137. package/dist/index.d.ts.map +1 -1
  138. package/dist/index.js +3768 -3356
  139. package/dist/index.js.map +1 -1
  140. package/dist/index.native.d.ts +1 -0
  141. package/dist/index.native.d.ts.map +1 -1
  142. package/dist/shared/ModalContext.d.ts +14 -0
  143. package/dist/shared/ModalContext.d.ts.map +1 -0
  144. package/dist/shared/mergeRefs.d.ts +4 -0
  145. package/dist/shared/mergeRefs.d.ts.map +1 -0
  146. package/dist/shared/useOrderedRegistry.d.ts +26 -0
  147. package/dist/shared/useOrderedRegistry.d.ts.map +1 -0
  148. package/dist/styles/visually-hidden.css.d.ts +6 -0
  149. package/dist/styles/visually-hidden.css.d.ts.map +1 -1
  150. package/dist/table.js +45 -45
  151. package/dist/table.js.map +1 -1
  152. package/package.json +2 -2
  153. package/src/components/ActionBar/styles.css.ts +2 -1
  154. package/src/components/Badge/Badge.stories.tsx +62 -1
  155. package/src/components/Badge/Badge.tsx +1 -1
  156. package/src/components/Badge/styles.css.ts +5 -0
  157. package/src/components/Button/Button.stories.tsx +41 -0
  158. package/src/components/Button/Button.tsx +22 -1
  159. package/src/components/ButtonGroup/ButtonGroup.meta.ts +14 -1
  160. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +271 -1
  161. package/src/components/ButtonGroup/ButtonGroup.tsx +46 -14
  162. package/src/components/ButtonGroup/ButtonGroupContext.ts +19 -0
  163. package/src/components/ButtonGroup/attached.css.ts +44 -0
  164. package/src/components/ButtonGroup/styles.css.ts +8 -0
  165. package/src/components/ButtonGroup/useGroupedControl.ts +75 -0
  166. package/src/components/Combobox/Combobox.tsx +25 -79
  167. package/src/components/Combobox/styles.css.ts +0 -74
  168. package/src/components/Dialog/Dialog.meta.ts +1 -1
  169. package/src/components/Dialog/Dialog.stories.tsx +109 -3
  170. package/src/components/Dialog/Dialog.tsx +68 -10
  171. package/src/components/Dialog/styles.css.ts +5 -2
  172. package/src/components/DragDrop/DragDrop.tsx +15 -8
  173. package/src/components/DragDrop/styles.css.ts +20 -4
  174. package/src/components/Drawer/Drawer.tsx +5 -1
  175. package/src/components/Drawer/styles.css.ts +3 -2
  176. package/src/components/Grid/Grid.meta.ts +1 -1
  177. package/src/components/Grid/Grid.stories.tsx +53 -1
  178. package/src/components/Grid/Grid.tsx +23 -11
  179. package/src/components/Input/Input.meta.ts +10 -3
  180. package/src/components/Input/Input.stories.tsx +52 -2
  181. package/src/components/Input/Input.tsx +12 -3
  182. package/src/components/Input/styles.css.ts +29 -0
  183. package/src/components/InputGroup/InputGroup.stories.tsx +20 -0
  184. package/src/components/InputGroup/InputGroup.tsx +20 -2
  185. package/src/components/Listbox/Listbox.meta.ts +52 -0
  186. package/src/components/Listbox/Listbox.stories.tsx +170 -0
  187. package/src/components/Listbox/Listbox.tsx +278 -0
  188. package/src/components/Listbox/styles.css.ts +90 -0
  189. package/src/components/Menu/Menu.meta.ts +2 -1
  190. package/src/components/Menu/Menu.stories.tsx +289 -8
  191. package/src/components/Menu/Menu.tsx +107 -29
  192. package/src/components/Menu/MenuContext.ts +3 -0
  193. package/src/components/Menu/styles.css.ts +32 -15
  194. package/src/components/Menu/useMenuRoot.ts +105 -48
  195. package/src/components/Popover/Popover.tsx +26 -5
  196. package/src/components/Popover/styles.css.ts +9 -1
  197. package/src/components/ScrollArea/styles.css.ts +2 -1
  198. package/src/components/Select/Select.stories.tsx +20 -1
  199. package/src/components/Select/Select.tsx +9 -3
  200. package/src/components/Select/styles.css.ts +3 -2
  201. package/src/components/Select/useSelectRoot.ts +17 -0
  202. package/src/components/TextLink/TextLink.meta.ts +33 -0
  203. package/src/components/TextLink/TextLink.stories.tsx +63 -0
  204. package/src/components/TextLink/TextLink.tsx +46 -0
  205. package/src/components/TextLink/styles.css.ts +58 -0
  206. package/src/components/ThemeProvider/Layers.stories.tsx +379 -0
  207. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +50 -0
  208. package/src/components/ThemeProvider/ThemeProvider.tsx +4 -2
  209. package/src/components/Toast/styles.css.ts +3 -0
  210. package/src/components/Toggle/Toggle.tsx +20 -4
  211. package/src/components/Toggle/styles.css.ts +6 -0
  212. package/src/components/ToggleGroup/ToggleGroup.tsx +16 -4
  213. package/src/components/ToggleGroup/styles.css.ts +7 -0
  214. package/src/components/Toolbar/ControlContextBoundary.tsx +16 -0
  215. package/src/components/Toolbar/Toolbar.meta.ts +48 -0
  216. package/src/components/Toolbar/Toolbar.stories.tsx +468 -0
  217. package/src/components/Toolbar/Toolbar.tsx +113 -0
  218. package/src/components/Toolbar/ToolbarContext.ts +15 -0
  219. package/src/components/Toolbar/styles.css.ts +17 -0
  220. package/src/components/Tooltip/styles.css.ts +2 -1
  221. package/src/components/VirtualTable/styles.css.ts +2 -1
  222. package/src/components/VisuallyHidden/VisuallyHidden.meta.ts +19 -0
  223. package/src/components/VisuallyHidden/VisuallyHidden.stories.tsx +30 -0
  224. package/src/components/VisuallyHidden/VisuallyHidden.tsx +22 -0
  225. package/src/docs/FlexGrow.stories.tsx +83 -0
  226. package/src/index.native.ts +1 -0
  227. package/src/index.ts +7 -1
  228. package/src/shared/ModalContext.ts +18 -0
  229. package/src/shared/mergeRefs.ts +11 -0
  230. package/src/shared/useOrderedRegistry.ts +57 -0
  231. package/src/styles/visually-hidden.css.ts +8 -0
  232. package/dist/Table-BkrgH0jb.js +0 -1199
  233. package/dist/Table-BkrgH0jb.js.map +0 -1
@@ -1,48 +1,93 @@
1
- import {useState, useCallback, useRef, useId, useEffect} from 'react'
1
+ import {useState, useCallback, useRef, useId, useEffect, useLayoutEffect} from 'react'
2
2
  import type {MenuContextValue} from './MenuContext'
3
+ import {isOutsidePress} from '../Popover/outside'
3
4
 
4
- export function useMenuRoot() {
5
- const [open, setOpen] = useState(false)
5
+ export interface MenuRootOptions {
6
+ open?: boolean
7
+ defaultOpen?: boolean
8
+ onOpenChange?: (open: boolean) => void
9
+ }
10
+
11
+ /**
12
+ * The menu-button pattern (WAI-ARIA APG): on open, focus moves to the
13
+ * `role="menu"` popup, which carries `aria-activedescendant`; arrows move the
14
+ * highlight; Escape closes and focus returns to the trigger. The keydown
15
+ * handler sits on the popup itself, never on `document`, so keys typed
16
+ * elsewhere (an editor) are never intercepted, and Escape stops there so an
17
+ * enclosing Popover or Dialog stays open.
18
+ */
19
+ export function useMenuRoot({open: openProp, defaultOpen = false, onOpenChange}: MenuRootOptions) {
20
+ const [inner, setInner] = useState(defaultOpen)
21
+ const controlled = openProp !== undefined
22
+ const open = controlled ? openProp : inner
6
23
  const [highlightedId, setHighlightedId] = useState<string | null>(null)
24
+ // Read by the keydown handler, so activating an item is not a side effect
25
+ // inside a state updater (React may run updaters twice).
26
+ const highlightedRef = useRef(highlightedId)
27
+ useLayoutEffect(() => {
28
+ highlightedRef.current = highlightedId
29
+ }, [highlightedId])
7
30
  const menuId = useId()
31
+ const triggerId = useId()
8
32
  const rootRef = useRef<HTMLDivElement>(null)
9
33
  const triggerRef = useRef<HTMLButtonElement | null>(null)
34
+ const popupRef = useRef<HTMLDivElement | null>(null)
10
35
  const itemsRef = useRef(new Map<string, HTMLElement>())
11
36
  const orderRef = useRef<string[]>([])
12
- const needsInitialHighlightRef = useRef(false)
37
+ const openRef = useRef(open)
38
+ openRef.current = open
39
+ const onOpenChangeRef = useRef(onOpenChange)
40
+ onOpenChangeRef.current = onOpenChange
13
41
 
14
- const close = useCallback(() => {
42
+ const setOpen = useCallback(
43
+ (next: boolean) => {
44
+ if (next === openRef.current) return
45
+ if (!controlled) setInner(next)
46
+ onOpenChangeRef.current?.(next)
47
+ },
48
+ [controlled],
49
+ )
50
+
51
+ // Closes and leaves focus where it is (a press elsewhere put it there).
52
+ const dismiss = useCallback(() => {
53
+ if (!openRef.current) return
15
54
  setOpen(false)
16
55
  setHighlightedId(null)
17
- needsInitialHighlightRef.current = false
56
+ }, [setOpen])
57
+
58
+ // Closes and puts focus back on the trigger.
59
+ const close = useCallback(() => {
60
+ if (!openRef.current) return
61
+ dismiss()
18
62
  triggerRef.current?.focus()
19
- }, [])
63
+ }, [dismiss])
20
64
 
21
65
  const toggle = useCallback(() => {
22
- setOpen((prev) => {
23
- if (!prev) {
24
- needsInitialHighlightRef.current = true
25
- } else {
26
- setHighlightedId(null)
27
- needsInitialHighlightRef.current = false
28
- }
29
- return !prev
30
- })
31
- }, [])
66
+ if (openRef.current) close()
67
+ else setOpen(true)
68
+ }, [close, setOpen])
32
69
 
33
- // Highlight the first item after items register on open.
34
- // Child effects (item registration) run before this parent effect,
35
- // so orderRef is populated by the time this runs.
70
+ // On open: focus the menu and highlight the first item. Child effects (item
71
+ // registration) run before this parent effect, so orderRef is populated.
72
+ const wasOpenRef = useRef(false)
36
73
  useEffect(() => {
37
- if (open && needsInitialHighlightRef.current) {
38
- needsInitialHighlightRef.current = false
39
- const order = orderRef.current
40
- if (order.length > 0) {
41
- setHighlightedId(order[0])
42
- }
74
+ if (open && !wasOpenRef.current) {
75
+ popupRef.current?.focus({preventScroll: true})
76
+ const first = orderRef.current[0]
77
+ if (first) setHighlightedId(first)
78
+ } else if (!open && wasOpenRef.current) {
79
+ setHighlightedId(null)
43
80
  }
81
+ wasOpenRef.current = open
44
82
  }, [open])
45
83
 
84
+ // Keep the highlighted item in view when the popup scrolls.
85
+ useEffect(() => {
86
+ if (!highlightedId) return
87
+ const el = itemsRef.current.get(highlightedId)
88
+ el?.scrollIntoView?.({block: 'nearest'})
89
+ }, [highlightedId])
90
+
46
91
  const registerItem = useCallback((id: string, element: HTMLElement) => {
47
92
  itemsRef.current.set(id, element)
48
93
  const map = itemsRef.current
@@ -60,12 +105,24 @@ export function useMenuRoot() {
60
105
  }
61
106
  }, [])
62
107
 
63
- // Native keydown for full KeyboardEvent access (preventDefault)
108
+ // Native keydown on the popup element, for full KeyboardEvent access.
64
109
  useEffect(() => {
65
- const root = rootRef.current
66
- if (!root || !open) return
110
+ const popup = popupRef.current
111
+ if (!popup || !open) return
67
112
 
68
113
  function handleKeyDown(e: KeyboardEvent) {
114
+ if (e.key === 'Escape') {
115
+ e.preventDefault()
116
+ e.stopPropagation()
117
+ close()
118
+ return
119
+ }
120
+ if (e.key === 'Tab') {
121
+ // Back on the trigger before the browser moves focus, so Tab and
122
+ // Shift+Tab continue from where the menu was opened.
123
+ close()
124
+ return
125
+ }
69
126
  const order = orderRef.current
70
127
  if (order.length === 0) return
71
128
 
@@ -99,41 +156,41 @@ export function useMenuRoot() {
99
156
  case 'Enter':
100
157
  case ' ': {
101
158
  e.preventDefault()
102
- const items = itemsRef.current
103
- setHighlightedId((prev) => {
104
- if (prev) {
105
- const el = items.get(prev)
106
- el?.click()
107
- }
108
- return prev
109
- })
110
- break
111
- }
112
- case 'Escape': {
113
- e.preventDefault()
114
- close()
115
- break
116
- }
117
- case 'Tab': {
118
- close()
159
+ const current = highlightedRef.current
160
+ if (current) itemsRef.current.get(current)?.click()
119
161
  break
120
162
  }
121
163
  }
122
164
  }
123
165
 
124
- root.addEventListener('keydown', handleKeyDown)
125
- return () => root.removeEventListener('keydown', handleKeyDown)
166
+ popup.addEventListener('keydown', handleKeyDown)
167
+ return () => popup.removeEventListener('keydown', handleKeyDown)
126
168
  }, [open, close])
127
169
 
170
+ // A press outside the trigger and the popup closes the menu, and leaves
171
+ // focus where the press put it.
172
+ useEffect(() => {
173
+ if (!open) return
174
+ function onPointerDown(e: PointerEvent) {
175
+ if (isOutsidePress(e.target as Node | null, [popupRef.current, triggerRef.current])) {
176
+ dismiss()
177
+ }
178
+ }
179
+ document.addEventListener('pointerdown', onPointerDown, true)
180
+ return () => document.removeEventListener('pointerdown', onPointerDown, true)
181
+ }, [open, dismiss])
182
+
128
183
  const ctx: MenuContextValue = {
129
184
  open,
130
185
  toggle,
131
186
  close,
132
187
  menuId,
188
+ triggerId,
133
189
  highlightedId,
134
190
  setHighlightedId,
135
191
  registerItem,
136
192
  triggerRef,
193
+ popupRef,
137
194
  }
138
195
 
139
196
  return {ctx, rootRef}
@@ -19,6 +19,10 @@ import {styles} from './styles.css'
19
19
  import {computePopoverPosition, type PopoverAlign, type PopoverSide} from './position'
20
20
  import {isOutsidePress} from './outside'
21
21
  import {PopoverLayerContext, type PopoverLayerContextValue} from './PopoverLayerContext'
22
+ import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
23
+ import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
24
+ import {mergeRefs} from '../../shared/mergeRefs'
25
+ import {useInModal} from '../../shared/ModalContext'
22
26
 
23
27
  // --- Context ---
24
28
 
@@ -193,18 +197,30 @@ function Root({
193
197
  // --- Trigger ---
194
198
  // Trigger IS the button (like Menu.Trigger): give it a label, not a Button.
195
199
 
196
- function Trigger({children}: {children: ReactNode}) {
200
+ interface TriggerProps {
201
+ children: ReactNode
202
+ /** Accessible name: required when the label is an icon only. */
203
+ 'aria-label'?: string
204
+ /** The trigger <button>, e.g. to measure it or return focus to it. */
205
+ ref?: Ref<HTMLButtonElement>
206
+ }
207
+
208
+ function Trigger({children, 'aria-label': ariaLabel, ref}: TriggerProps) {
197
209
  const {open, setOpen, popoverId, triggerRef} = usePopover('Trigger')
210
+ const grouped = useGroupedControl<HTMLButtonElement>()
198
211
 
199
212
  return (
200
213
  <html.button
201
- ref={triggerRef}
214
+ ref={mergeRefs(triggerRef, grouped.ref, ref)}
215
+ tabIndex={grouped.tabIndex}
216
+ onFocus={grouped.onFocus}
202
217
  type="button"
203
218
  onClick={() => setOpen(!open)}
219
+ aria-label={ariaLabel}
204
220
  aria-haspopup="dialog"
205
221
  aria-expanded={open}
206
222
  aria-controls={open ? popoverId : undefined}
207
- style={styles.trigger}
223
+ style={[styles.trigger, grouped.style]}
208
224
  >
209
225
  {children}
210
226
  </html.button>
@@ -226,6 +242,7 @@ function Popup({children, label}: PopupProps) {
226
242
  // Inside ThemeProvider's portal layer, like Select/Dialog/Drawer: theme
227
243
  // tokens cascade into the popup and it stacks above dialogs.
228
244
  const mount = usePortalMount()
245
+ const inModal = useInModal()
229
246
 
230
247
  // Measure before paint on open; re-measure on scroll/resize (capture phase
231
248
  // catches any scrolling ancestor) and when Root.reposition() is called.
@@ -271,9 +288,13 @@ function Popup({children, label}: PopupProps) {
271
288
  role="dialog"
272
289
  aria-label={label}
273
290
  tabIndex={-1}
274
- style={[styles.popup, styles.popupPosition(coords.top, coords.left)]}
291
+ style={[
292
+ styles.popup,
293
+ inModal && styles.popupInModal,
294
+ styles.popupPosition(coords.top, coords.left),
295
+ ]}
275
296
  >
276
- {children}
297
+ <ControlContextBoundary>{children}</ControlContextBoundary>
277
298
  </html.div>,
278
299
  mount ?? document.body,
279
300
  )
@@ -5,6 +5,7 @@ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
  import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
6
  import {duration} from '@duro-app/tokens/tokens/motion.css'
7
7
  import {borders} from '@duro-app/tokens/tokens/borders.css'
8
+ import {layers} from '@duro-app/tokens/tokens/layers.css'
8
9
 
9
10
  export const styles = css.create({
10
11
  trigger: {
@@ -30,6 +31,11 @@ export const styles = css.create({
30
31
  transitionProperty: 'background-color, border-color',
31
32
  transitionDuration: duration.fast,
32
33
  },
34
+ // Inside a Dialog or Drawer: above that modal (same portal mount), below a
35
+ // Select or Menu opened from inside the Popover.
36
+ popupInModal: {
37
+ zIndex: layers.popover,
38
+ },
33
39
  popup: {
34
40
  position: 'fixed',
35
41
  top: 0,
@@ -45,7 +51,9 @@ export const styles = css.create({
45
51
  fontFamily: typography.fontFamily,
46
52
  fontSize: typography.fontSizeSm,
47
53
  color: colors.text,
48
- zIndex: 50,
54
+ // Outside a modal: floating chrome, so a Dialog or Drawer opened later
55
+ // (even from inside this Popover) covers it.
56
+ zIndex: layers.floating,
49
57
  // The portal layer is pointer-events: none so clicks fall through it;
50
58
  // the popup takes them back.
51
59
  pointerEvents: 'auto',
@@ -3,6 +3,7 @@ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
3
  import {microSpacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
5
5
  import {sizes} from '@duro-app/tokens/tokens/sizes.css'
6
+ import {layers} from '@duro-app/tokens/tokens/layers.css'
6
7
 
7
8
  export const styles = css.create({
8
9
  root: {
@@ -23,7 +24,7 @@ export const styles = css.create({
23
24
  },
24
25
  scrollbar: {
25
26
  position: 'absolute',
26
- zIndex: 1,
27
+ zIndex: layers.raised,
27
28
  display: 'flex',
28
29
  touchAction: 'none',
29
30
  userSelect: 'none',
@@ -1,5 +1,5 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
- import {expect, fn, screen} from 'storybook/test'
2
+ import {expect, screen} from 'storybook/test'
3
3
  import {Dialog} from '../Dialog/Dialog'
4
4
  import {Select} from './Select'
5
5
 
@@ -194,3 +194,22 @@ export const InsideDialog: Story = {
194
194
  await expect(listbox.closest('[role="dialog"]')).toBeNull()
195
195
  },
196
196
  }
197
+
198
+ /** Closed, the trigger opens on ArrowDown or ArrowUp, on the selected option (5.3). */
199
+ export const ArrowKeysOpen: Story = {
200
+ render: Default.render,
201
+ play: async ({canvas, userEvent}) => {
202
+ const trigger = canvas.getByRole('combobox')
203
+ trigger.focus()
204
+ await userEvent.keyboard('{ArrowDown}')
205
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true')
206
+ await expect(trigger).toHaveFocus()
207
+ const selected = screen.getByRole('option', {name: 'English'})
208
+ await expect(trigger).toHaveAttribute('aria-activedescendant', selected.id)
209
+ await userEvent.keyboard('{Escape}')
210
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false')
211
+ await userEvent.keyboard('{ArrowUp}')
212
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true')
213
+ await userEvent.keyboard('{Escape}')
214
+ },
215
+ }
@@ -7,6 +7,9 @@ import {useSelectRoot} from './useSelectRoot'
7
7
  import {useFieldContext} from '../Field/FieldContext'
8
8
  import {usePortalMount} from '../ThemeProvider/ThemeProvider'
9
9
  import {usePopoverLayer} from '../Popover/PopoverLayerContext'
10
+ import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
11
+ import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
12
+ import {mergeRefs} from '../../shared/mergeRefs'
10
13
 
11
14
  // --- Root ---
12
15
  interface RootProps {
@@ -41,6 +44,7 @@ function Trigger({
41
44
  }) {
42
45
  const {open, toggle, listboxId, highlightedId, triggerRef} = useSelect()
43
46
  const localRef = useRef<HTMLButtonElement>(null)
47
+ const grouped = useGroupedControl<HTMLButtonElement>()
44
48
  // Inside a <Field.Root>, adopt its controlId so <Field.Label for={controlId}>
45
49
  // labels the trigger (a labelable <button>) — no manual aria-label needed; and
46
50
  // surface the field's description/error + invalid state, mirroring <Input>.
@@ -56,7 +60,9 @@ function Trigger({
56
60
 
57
61
  return (
58
62
  <html.button
59
- ref={localRef}
63
+ ref={mergeRefs(localRef, grouped.ref)}
64
+ tabIndex={grouped.tabIndex}
65
+ onFocus={grouped.onFocus}
60
66
  id={fieldCtx?.controlId}
61
67
  type="button"
62
68
  role={'combobox' as 'listbox'}
@@ -68,7 +74,7 @@ function Trigger({
68
74
  aria-activedescendant={highlightedId ?? undefined}
69
75
  aria-describedby={describedBy || undefined}
70
76
  aria-invalid={fieldCtx?.invalid || undefined}
71
- style={styles.trigger}
77
+ style={[styles.trigger, grouped.style]}
72
78
  >
73
79
  {children}
74
80
  </html.button>
@@ -161,7 +167,7 @@ function Popup({children}: {children: ReactNode}) {
161
167
  coords && styles.popupPosition(coords.top, coords.left, coords.width),
162
168
  ]}
163
169
  >
164
- {children}
170
+ <ControlContextBoundary>{children}</ControlContextBoundary>
165
171
  </html.div>
166
172
  </>
167
173
  )
@@ -6,6 +6,7 @@ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
6
  import {duration} from '@duro-app/tokens/tokens/motion.css'
7
7
  import {sizes} from '@duro-app/tokens/tokens/sizes.css'
8
8
  import {borders} from '@duro-app/tokens/tokens/borders.css'
9
+ import {layers} from '@duro-app/tokens/tokens/layers.css'
9
10
 
10
11
  export const styles = css.create({
11
12
  root: {
@@ -55,7 +56,7 @@ export const styles = css.create({
55
56
  bottom: 0,
56
57
  // Above Dialog/Drawer (1000/1001): they portal into the same mount, so a
57
58
  // popup opened from inside a dialog must out-rank the dialog itself.
58
- zIndex: 1049,
59
+ zIndex: layers.popupBackdrop,
59
60
  // The portal mount is pointer-events: none, so re-enable here to catch
60
61
  // outside clicks that close the popup.
61
62
  pointerEvents: 'auto',
@@ -76,7 +77,7 @@ export const styles = css.create({
76
77
  minWidth: sizes.labelMinW,
77
78
  maxHeight: sizes.listMaxH,
78
79
  overflowY: 'auto',
79
- zIndex: 1050,
80
+ zIndex: layers.popup,
80
81
  // Re-enable pointer events: the portal mount is pointer-events: none.
81
82
  pointerEvents: 'auto',
82
83
  },
@@ -80,6 +80,23 @@ export function useSelectRoot({
80
80
  }
81
81
  }, [])
82
82
 
83
+ // Closed: ArrowDown/ArrowUp on the trigger open the listbox (the APG
84
+ // select-only combobox), on the selected option or the first. In a Toolbar
85
+ // this is why a closed Select keeps those keys; Left/Right still move on.
86
+ useEffect(() => {
87
+ const root = rootRef.current
88
+ if (!root || open) return
89
+ function handleKeyDown(e: KeyboardEvent) {
90
+ if (e.target !== triggerRef.current) return
91
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
92
+ if (e.altKey || e.ctrlKey || e.metaKey) return
93
+ e.preventDefault()
94
+ toggle()
95
+ }
96
+ root.addEventListener('keydown', handleKeyDown)
97
+ return () => root.removeEventListener('keydown', handleKeyDown)
98
+ }, [open, toggle])
99
+
83
100
  // Native keydown for full KeyboardEvent access (preventDefault)
84
101
  useEffect(() => {
85
102
  const root = rootRef.current
@@ -0,0 +1,33 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Inline hyperlink for running text and standalone text links ("View all", "Edit profile"). Always underlined, so colour is never the only cue: default is accent text with an accent underline; subtle keeps the surrounding text colour with a muted underline and turns accent on hover. target="_blank" defaults rel to noopener noreferrer.',
6
+ whenToUse: [
7
+ 'A link inside a sentence or paragraph',
8
+ 'A standalone text link under a list or card ("View all requests")',
9
+ 'A quiet link in dense UI (variant="subtle")',
10
+ ],
11
+ whenNotToUse: [
12
+ 'A link that should look like a button — use LinkButton',
13
+ 'An action that does not navigate — use Button (variant="link" for a text-styled action)',
14
+ 'A link inside a dropdown — use Menu.LinkItem',
15
+ ],
16
+ relatedTo: [
17
+ {
18
+ component: 'LinkButton',
19
+ kind: 'contrast',
20
+ relationship:
21
+ 'LinkButton is a hyperlink styled as a button; TextLink is a hyperlink styled as text',
22
+ },
23
+ {
24
+ component: 'Button',
25
+ kind: 'contrast',
26
+ relationship: 'Button runs an action; TextLink navigates',
27
+ },
28
+ ],
29
+ example: `<Text>
30
+ Read the <TextLink href="/docs/migrate">migration guide</TextLink> before you change seats, or{' '}
31
+ <TextLink href="/plans" variant="subtle">compare plans</TextLink> first.
32
+ </Text>`,
33
+ }
@@ -0,0 +1,63 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {TextLink} from './TextLink'
4
+ import {Text} from '../Text/Text'
5
+ import {Inline} from '../Inline/Inline'
6
+
7
+ const meta: Meta<typeof TextLink> = {
8
+ title: 'Components/TextLink',
9
+ component: TextLink,
10
+ args: {href: '#guide', children: 'migration guide'},
11
+ }
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof TextLink>
15
+
16
+ export const InRunningText: Story = {
17
+ render: () => (
18
+ <Text>
19
+ Your workspace moved to the new plan. Read the{' '}
20
+ <TextLink href="#guide">migration guide</TextLink> before you change seats, or{' '}
21
+ <TextLink href="#plans" variant="subtle">
22
+ compare plans
23
+ </TextLink>{' '}
24
+ first.
25
+ </Text>
26
+ ),
27
+ play: async ({canvas}) => {
28
+ for (const name of ['migration guide', 'compare plans']) {
29
+ const link = canvas.getByRole('link', {name})
30
+ // Underlined in both variants: colour is never the only cue.
31
+ await expect(getComputedStyle(link).textDecorationLine).toContain('underline')
32
+ }
33
+ },
34
+ }
35
+
36
+ export const Standalone: Story = {
37
+ render: () => (
38
+ <Inline gap="lg">
39
+ <TextLink href="#all">View all requests</TextLink>
40
+ <TextLink href="#profile" variant="subtle">
41
+ Edit profile
42
+ </TextLink>
43
+ <TextLink href="https://example.com/docs" target="_blank">
44
+ Documentation
45
+ </TextLink>
46
+ </Inline>
47
+ ),
48
+ play: async ({canvas}) => {
49
+ const external = canvas.getByRole('link', {name: 'Documentation'})
50
+ await expect(external).toHaveAttribute('target', '_blank')
51
+ await expect(external).toHaveAttribute('rel', 'noopener noreferrer')
52
+ },
53
+ }
54
+
55
+ export const KeyboardFocus: Story = {
56
+ render: () => <TextLink href="#billing">Billing settings</TextLink>,
57
+ play: async ({canvas, userEvent}) => {
58
+ await userEvent.tab()
59
+ const link = canvas.getByRole('link', {name: 'Billing settings'})
60
+ await expect(link).toHaveFocus()
61
+ await expect(getComputedStyle(link).outlineStyle).toBe('solid')
62
+ },
63
+ }
@@ -0,0 +1,46 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+
5
+ export type TextLinkVariant = 'default' | 'subtle'
6
+
7
+ interface TextLinkProps {
8
+ href: string
9
+ /** `_blank` opens a new tab; `rel` then defaults to `noopener noreferrer`. */
10
+ target?: '_blank' | '_self'
11
+ rel?: string
12
+ /**
13
+ * `default`: accent text with an accent underline. `subtle`: the
14
+ * surrounding text colour with a muted underline, accent on hover. Both
15
+ * stay underlined, so colour is never the only cue in running text.
16
+ */
17
+ variant?: TextLinkVariant
18
+ /** Accessible name, when the visible text alone does not say where it goes. */
19
+ 'aria-label'?: string
20
+ children: ReactNode
21
+ }
22
+
23
+ /**
24
+ * An inline hyperlink in running text or a standalone "View all" link. For a
25
+ * link that looks like a button, use LinkButton.
26
+ */
27
+ export function TextLink({
28
+ href,
29
+ target,
30
+ rel,
31
+ variant = 'default',
32
+ 'aria-label': ariaLabel,
33
+ children,
34
+ }: TextLinkProps) {
35
+ return (
36
+ <html.a
37
+ href={href}
38
+ target={target}
39
+ rel={rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)}
40
+ aria-label={ariaLabel}
41
+ style={[styles.base, styles[variant]]}
42
+ >
43
+ {children}
44
+ </html.a>
45
+ )
46
+ }
@@ -0,0 +1,58 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
5
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
6
+
7
+ // Direction "Underlined" (docs/mockups/text-link/Underlined.dc.html): the
8
+ // underline is always there, so colour is never the only cue (WCAG F73).
9
+ export const styles = css.create({
10
+ base: {
11
+ fontFamily: 'inherit',
12
+ fontSize: 'inherit',
13
+ lineHeight: 'inherit',
14
+ cursor: 'pointer',
15
+ textDecorationLine: 'underline',
16
+ textDecorationStyle: 'solid',
17
+ borderRadius: radii.xs,
18
+ transitionProperty: 'color, text-decoration-color',
19
+ transitionDuration: duration.fast,
20
+ transitionTimingFunction: easing.standard,
21
+ outlineWidth: {
22
+ default: 0,
23
+ ':focus-visible': borders.focusRing,
24
+ },
25
+ outlineStyle: {
26
+ default: 'none',
27
+ ':focus-visible': 'solid',
28
+ },
29
+ outlineColor: {
30
+ default: 'transparent',
31
+ ':focus-visible': colors.accent,
32
+ },
33
+ outlineOffset: {
34
+ default: 0,
35
+ ':focus-visible': borders.focusOffset,
36
+ },
37
+ },
38
+ default: {
39
+ color: {
40
+ default: colors.accent,
41
+ ':hover': colors.accentHover,
42
+ },
43
+ textDecorationColor: {
44
+ default: colors.accent,
45
+ ':hover': colors.accentHover,
46
+ },
47
+ },
48
+ subtle: {
49
+ color: {
50
+ default: 'inherit',
51
+ ':hover': colors.accent,
52
+ },
53
+ textDecorationColor: {
54
+ default: colors.textMuted,
55
+ ':hover': colors.accent,
56
+ },
57
+ },
58
+ })