@duro-app/ui 5.2.0 → 5.4.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 (139) hide show
  1. package/dist/{Table-C70zdSkM.js → Table-Cm5cCnZD.js} +730 -625
  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.map +1 -1
  12. package/dist/components/ButtonGroup/ButtonGroup.d.ts +9 -2
  13. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -1
  14. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
  15. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +19 -0
  16. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  17. package/dist/components/ButtonGroup/ButtonGroupContext.d.ts +13 -0
  18. package/dist/components/ButtonGroup/ButtonGroupContext.d.ts.map +1 -0
  19. package/dist/components/ButtonGroup/attached.css.d.ts +34 -0
  20. package/dist/components/ButtonGroup/attached.css.d.ts.map +1 -0
  21. package/dist/components/ButtonGroup/styles.css.d.ts +7 -0
  22. package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -1
  23. package/dist/components/ButtonGroup/useGroupedControl.d.ts +25 -0
  24. package/dist/components/ButtonGroup/useGroupedControl.d.ts.map +1 -0
  25. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  26. package/dist/components/Dialog/styles.css.d.ts +2 -2
  27. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  28. package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
  29. package/dist/components/DragDrop/styles.css.d.ts +1 -1
  30. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  31. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  32. package/dist/components/Drawer/styles.css.d.ts +2 -2
  33. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  34. package/dist/components/Listbox/styles.css.d.ts +1 -1
  35. package/dist/components/Listbox/styles.css.d.ts.map +1 -1
  36. package/dist/components/Menu/Menu.d.ts.map +1 -1
  37. package/dist/components/Menu/styles.css.d.ts +1 -1
  38. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  39. package/dist/components/Popover/Popover.d.ts +7 -2
  40. package/dist/components/Popover/Popover.d.ts.map +1 -1
  41. package/dist/components/Popover/styles.css.d.ts +4 -1
  42. package/dist/components/Popover/styles.css.d.ts.map +1 -1
  43. package/dist/components/ScrollArea/styles.css.d.ts +1 -1
  44. package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
  45. package/dist/components/Select/Select.d.ts.map +1 -1
  46. package/dist/components/Select/Select.stories.d.ts +2 -0
  47. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  48. package/dist/components/Select/styles.css.d.ts +2 -2
  49. package/dist/components/Select/styles.css.d.ts.map +1 -1
  50. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  51. package/dist/components/ThemeProvider/Layers.stories.d.ts +24 -0
  52. package/dist/components/ThemeProvider/Layers.stories.d.ts.map +1 -0
  53. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  54. package/dist/components/Toast/styles.css.d.ts +1 -0
  55. package/dist/components/Toast/styles.css.d.ts.map +1 -1
  56. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  57. package/dist/components/Toggle/styles.css.d.ts +3 -0
  58. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  59. package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
  60. package/dist/components/ToggleGroup/styles.css.d.ts +5 -0
  61. package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
  62. package/dist/components/Toolbar/ControlContextBoundary.d.ts +10 -0
  63. package/dist/components/Toolbar/ControlContextBoundary.d.ts.map +1 -0
  64. package/dist/components/Toolbar/Toolbar.d.ts +22 -0
  65. package/dist/components/Toolbar/Toolbar.d.ts.map +1 -0
  66. package/dist/components/Toolbar/Toolbar.meta.d.ts +3 -0
  67. package/dist/components/Toolbar/Toolbar.meta.d.ts.map +1 -0
  68. package/dist/components/Toolbar/Toolbar.stories.d.ts +28 -0
  69. package/dist/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
  70. package/dist/components/Toolbar/ToolbarContext.d.ts +10 -0
  71. package/dist/components/Toolbar/ToolbarContext.d.ts.map +1 -0
  72. package/dist/components/Toolbar/styles.css.d.ts +16 -0
  73. package/dist/components/Toolbar/styles.css.d.ts.map +1 -0
  74. package/dist/components/Tooltip/styles.css.d.ts +1 -1
  75. package/dist/components/Tooltip/styles.css.d.ts.map +1 -1
  76. package/dist/components/VirtualTable/styles.css.d.ts +1 -1
  77. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  78. package/dist/index.css +1 -1
  79. package/dist/index.d.ts +2 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +3164 -2917
  82. package/dist/index.js.map +1 -1
  83. package/dist/shared/ModalContext.d.ts +14 -0
  84. package/dist/shared/ModalContext.d.ts.map +1 -0
  85. package/dist/shared/mergeRefs.d.ts +4 -0
  86. package/dist/shared/mergeRefs.d.ts.map +1 -0
  87. package/dist/shared/useOrderedRegistry.d.ts +26 -0
  88. package/dist/shared/useOrderedRegistry.d.ts.map +1 -0
  89. package/dist/table.js +45 -45
  90. package/dist/table.js.map +1 -1
  91. package/package.json +2 -2
  92. package/src/components/ActionBar/styles.css.ts +2 -1
  93. package/src/components/Badge/Badge.stories.tsx +62 -1
  94. package/src/components/Badge/Badge.tsx +1 -1
  95. package/src/components/Badge/styles.css.ts +5 -0
  96. package/src/components/Button/Button.tsx +7 -1
  97. package/src/components/ButtonGroup/ButtonGroup.meta.ts +14 -1
  98. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +271 -1
  99. package/src/components/ButtonGroup/ButtonGroup.tsx +46 -14
  100. package/src/components/ButtonGroup/ButtonGroupContext.ts +19 -0
  101. package/src/components/ButtonGroup/attached.css.ts +44 -0
  102. package/src/components/ButtonGroup/styles.css.ts +8 -0
  103. package/src/components/ButtonGroup/useGroupedControl.ts +75 -0
  104. package/src/components/Dialog/Dialog.tsx +5 -1
  105. package/src/components/Dialog/styles.css.ts +3 -2
  106. package/src/components/DragDrop/DragDrop.tsx +15 -8
  107. package/src/components/DragDrop/styles.css.ts +4 -1
  108. package/src/components/Drawer/Drawer.tsx +5 -1
  109. package/src/components/Drawer/styles.css.ts +3 -2
  110. package/src/components/Listbox/styles.css.ts +2 -1
  111. package/src/components/Menu/Menu.tsx +10 -3
  112. package/src/components/Menu/styles.css.ts +2 -1
  113. package/src/components/Popover/Popover.tsx +26 -5
  114. package/src/components/Popover/styles.css.ts +9 -1
  115. package/src/components/ScrollArea/styles.css.ts +2 -1
  116. package/src/components/Select/Select.stories.tsx +20 -1
  117. package/src/components/Select/Select.tsx +9 -3
  118. package/src/components/Select/styles.css.ts +3 -2
  119. package/src/components/Select/useSelectRoot.ts +17 -0
  120. package/src/components/ThemeProvider/Layers.stories.tsx +379 -0
  121. package/src/components/ThemeProvider/ThemeProvider.tsx +4 -2
  122. package/src/components/Toast/styles.css.ts +3 -0
  123. package/src/components/Toggle/Toggle.tsx +20 -4
  124. package/src/components/Toggle/styles.css.ts +6 -0
  125. package/src/components/ToggleGroup/ToggleGroup.tsx +16 -4
  126. package/src/components/ToggleGroup/styles.css.ts +7 -0
  127. package/src/components/Toolbar/ControlContextBoundary.tsx +16 -0
  128. package/src/components/Toolbar/Toolbar.meta.ts +48 -0
  129. package/src/components/Toolbar/Toolbar.stories.tsx +468 -0
  130. package/src/components/Toolbar/Toolbar.tsx +113 -0
  131. package/src/components/Toolbar/ToolbarContext.ts +15 -0
  132. package/src/components/Toolbar/styles.css.ts +17 -0
  133. package/src/components/Tooltip/styles.css.ts +2 -1
  134. package/src/components/VirtualTable/styles.css.ts +2 -1
  135. package/src/index.ts +2 -1
  136. package/src/shared/ModalContext.ts +18 -0
  137. package/src/shared/mergeRefs.ts +11 -0
  138. package/src/shared/useOrderedRegistry.ts +57 -0
  139. package/dist/Table-C70zdSkM.js.map +0 -1
@@ -6,6 +6,7 @@ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
6
  import {duration, easing} 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
  // --- Backdrop ---
@@ -13,7 +14,7 @@ export const styles = css.create({
13
14
  position: 'fixed',
14
15
  inset: 0,
15
16
  backgroundColor: colors.scrim,
16
- zIndex: 1000,
17
+ zIndex: layers.overlay,
17
18
  // Portaled into the ThemeProvider mount, which is pointer-events: none:
18
19
  // the backdrop must take clicks back (outside click dismisses).
19
20
  pointerEvents: 'auto',
@@ -41,7 +42,7 @@ export const styles = css.create({
41
42
  viewport: {
42
43
  position: 'fixed',
43
44
  inset: 0,
44
- zIndex: 1001,
45
+ zIndex: layers.modal,
45
46
  display: 'flex',
46
47
  pointerEvents: 'none',
47
48
  },
@@ -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
  popup: {
@@ -24,7 +25,7 @@ export const styles = css.create({
24
25
  paddingBottom: spacing.xs,
25
26
  overflowY: 'auto',
26
27
  // Above Dialog/Drawer (1000/1001): they portal into the same mount.
27
- zIndex: 1050,
28
+ zIndex: layers.popup,
28
29
  // The portal mount is pointer-events: none; the list takes them back.
29
30
  pointerEvents: 'auto',
30
31
  },
@@ -18,6 +18,8 @@ import {devWarnOnce} from '../../shared/devWarnOnce'
18
18
  import {usePortalMount} from '../ThemeProvider/ThemeProvider'
19
19
  import {computePopoverPosition} from '../Popover/position'
20
20
  import {usePopoverLayer} from '../Popover/PopoverLayerContext'
21
+ import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
22
+ import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
21
23
 
22
24
  // --- Root ---
23
25
  interface RootProps {
@@ -61,14 +63,17 @@ interface TriggerProps {
61
63
 
62
64
  function Trigger({children, 'aria-label': ariaLabel, variant = 'default', ref}: TriggerProps) {
63
65
  const {open, toggle, menuId, triggerId, triggerRef} = useMenu()
66
+ const grouped = useGroupedControl<HTMLButtonElement>()
67
+ const groupedRef = grouped.ref
64
68
 
65
69
  const setRef = useCallback(
66
70
  (el: HTMLButtonElement | null) => {
67
71
  triggerRef.current = el
72
+ groupedRef.current = el
68
73
  if (typeof ref === 'function') ref(el)
69
74
  else if (ref) ref.current = el
70
75
  },
71
- [ref, triggerRef],
76
+ [ref, triggerRef, groupedRef],
72
77
  )
73
78
 
74
79
  // TypeScript can't stop a <Button> child; the DOM can tell.
@@ -85,6 +90,8 @@ function Trigger({children, 'aria-label': ariaLabel, variant = 'default', ref}:
85
90
  return (
86
91
  <html.button
87
92
  ref={setRef}
93
+ tabIndex={grouped.tabIndex}
94
+ onFocus={grouped.onFocus}
88
95
  id={triggerId}
89
96
  type="button"
90
97
  onClick={toggle}
@@ -92,7 +99,7 @@ function Trigger({children, 'aria-label': ariaLabel, variant = 'default', ref}:
92
99
  aria-expanded={open}
93
100
  aria-haspopup="menu"
94
101
  aria-controls={open ? menuId : undefined}
95
- style={[styles.trigger, variant === 'ghost' && styles.triggerGhost]}
102
+ style={[styles.trigger, variant === 'ghost' && styles.triggerGhost, grouped.style]}
96
103
  >
97
104
  {children}
98
105
  </html.button>
@@ -154,7 +161,7 @@ function Popup({children, align = 'start'}: PopupProps) {
154
161
  aria-activedescendant={highlightedId ?? undefined}
155
162
  style={[styles.popup, styles.popupPosition(coords.top, coords.left)]}
156
163
  >
157
- {children}
164
+ <ControlContextBoundary>{children}</ControlContextBoundary>
158
165
  </html.div>,
159
166
  mount ?? document.body,
160
167
  )
@@ -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: {
@@ -63,7 +64,7 @@ export const styles = css.create({
63
64
  overflowY: 'auto',
64
65
  // Above Dialog/Drawer (1000/1001), like Select and Combobox: they portal
65
66
  // into the same mount, so a menu opened inside a dialog must out-rank it.
66
- zIndex: 1050,
67
+ zIndex: layers.popup,
67
68
  // The portal layer is pointer-events: none so clicks fall through it;
68
69
  // the popup takes them back.
69
70
  pointerEvents: 'auto',
@@ -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