@duro-app/ui 5.2.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 (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
@@ -1,8 +1,15 @@
1
+ import {useState} from 'react'
1
2
  import type {Meta, StoryObj} from '@storybook/react'
2
- import {expect} from 'storybook/test'
3
+ import {expect, userEvent, within} from 'storybook/test'
4
+ import {BORDERS_PX, LAYERS, RADII_PX} from '@duro-app/tokens/keys'
3
5
  import {ButtonGroup} from './ButtonGroup'
4
6
  import {Button} from '../Button/Button'
7
+ import {Popover} from '../Popover/Popover'
8
+ import {Select} from '../Select/Select'
5
9
  import {Stack} from '../Stack/Stack'
10
+ import {Text} from '../Text/Text'
11
+ import {ThemeProvider, type ThemeName} from '../ThemeProvider/ThemeProvider'
12
+ import {Toggle} from '../Toggle/Toggle'
6
13
 
7
14
  const meta: Meta<typeof ButtonGroup> = {
8
15
  title: 'Components/ButtonGroup',
@@ -102,3 +109,266 @@ export const WithGaps: Story = {
102
109
  await expect(groups.length).toBe(3)
103
110
  },
104
111
  }
112
+
113
+ // --- Attached (5.3) ---
114
+
115
+ const R = `${RADII_PX.sm}px`
116
+
117
+ /** The four computed corner radii, clockwise from top-left. */
118
+ function corners(el: Element) {
119
+ const cs = getComputedStyle(el)
120
+ return [
121
+ cs.borderTopLeftRadius,
122
+ cs.borderTopRightRadius,
123
+ cs.borderBottomRightRadius,
124
+ cs.borderBottomLeftRadius,
125
+ ]
126
+ }
127
+
128
+ const EXPECTED_CORNERS = {
129
+ first: [R, '0px', '0px', R],
130
+ middle: ['0px', '0px', '0px', '0px'],
131
+ last: ['0px', R, R, '0px'],
132
+ }
133
+
134
+ /** Each control's corners match its place: outer radii.sm, inner 0. */
135
+ async function expectJoined(controls: Element[]) {
136
+ await expect(controls.length).toBeGreaterThan(1)
137
+ for (const [i, el] of controls.entries()) {
138
+ const place = i === 0 ? 'first' : i === controls.length - 1 ? 'last' : 'middle'
139
+ await expect(corners(el)).toEqual(EXPECTED_CORNERS[place])
140
+ }
141
+ // No gaps: each control's left border sits on its neighbour's right one.
142
+ for (let i = 1; i < controls.length; i++) {
143
+ const before = controls[i - 1].getBoundingClientRect()
144
+ const after = controls[i].getBoundingClientRect()
145
+ await expect(Math.round(before.right - after.left)).toBe(BORDERS_PX.hairline)
146
+ }
147
+ }
148
+
149
+ function TextStyleSelects({named = false}: {named?: boolean}) {
150
+ return (
151
+ <ButtonGroup attached aria-label="Text style">
152
+ <Select.Root name={named ? 'block' : undefined} defaultValue="normal">
153
+ <Select.Trigger aria-label="Block type">
154
+ <Select.Value />
155
+ <Select.Icon />
156
+ </Select.Trigger>
157
+ <Select.Popup>
158
+ <Select.Item value="normal">Normal</Select.Item>
159
+ <Select.Item value="h1">Heading 1</Select.Item>
160
+ </Select.Popup>
161
+ </Select.Root>
162
+ <Select.Root name={named ? 'font' : undefined} defaultValue="arial">
163
+ <Select.Trigger aria-label="Font">
164
+ <Select.Value />
165
+ <Select.Icon />
166
+ </Select.Trigger>
167
+ <Select.Popup>
168
+ <Select.Item value="arial">Arial</Select.Item>
169
+ <Select.Item value="georgia">Georgia</Select.Item>
170
+ </Select.Popup>
171
+ </Select.Root>
172
+ <Select.Root name={named ? 'size' : undefined} defaultValue="15">
173
+ <Select.Trigger aria-label="Font size">
174
+ <Select.Value />
175
+ <Select.Icon />
176
+ </Select.Trigger>
177
+ <Select.Popup>
178
+ <Select.Item value="13">13px</Select.Item>
179
+ <Select.Item value="15">15px</Select.Item>
180
+ </Select.Popup>
181
+ </Select.Root>
182
+ </ButtonGroup>
183
+ )
184
+ }
185
+
186
+ /** [Normal | Arial | 15px]: three Selects joined into one control. */
187
+ export const AttachedSelects: Story = {
188
+ render: () => <TextStyleSelects />,
189
+ play: async ({canvas}) => {
190
+ await expectJoined(canvas.getAllByRole('combobox'))
191
+ },
192
+ }
193
+
194
+ /** Named Selects render a hidden input before each trigger; position comes
195
+ * from DOM order, so the trigger buttons still join. */
196
+ export const AttachedNamedSelects: Story = {
197
+ render: () => <TextStyleSelects named />,
198
+ play: async ({canvas, canvasElement}) => {
199
+ await expect(canvasElement.querySelectorAll('input[type="hidden"]').length).toBe(3)
200
+ await expectJoined(canvas.getAllByRole('combobox'))
201
+ },
202
+ }
203
+
204
+ const THEMES: ThemeName[] = ['dark', 'light', 'high-contrast']
205
+
206
+ export const AttachedSelectsThemes: Story = {
207
+ render: () => (
208
+ <Stack gap="md">
209
+ {THEMES.map((theme) => (
210
+ <ThemeProvider key={theme} theme={theme}>
211
+ <Stack gap="xs">
212
+ <Text variant="label">{theme}</Text>
213
+ <TextStyleSelects />
214
+ </Stack>
215
+ </ThemeProvider>
216
+ ))}
217
+ </Stack>
218
+ ),
219
+ play: async ({canvas}) => {
220
+ const groups = canvas.getAllByRole('group', {name: 'Text style'})
221
+ await expect(groups.length).toBe(3)
222
+ for (const group of groups) await expectJoined(within(group).getAllByRole('combobox'))
223
+ },
224
+ }
225
+
226
+ /** Keyboard focus on the middle Select: it rises above both neighbours. */
227
+ export const AttachedFocusMiddle: Story = {
228
+ render: () => <TextStyleSelects />,
229
+ play: async ({canvas}) => {
230
+ const [first, middle, last] = canvas.getAllByRole('combobox')
231
+ first.focus()
232
+ await userEvent.tab()
233
+ await expect(middle).toHaveFocus()
234
+ await expect(getComputedStyle(middle).zIndex).toBe(String(LAYERS.raised))
235
+ await expect(getComputedStyle(first).zIndex).toBe('auto')
236
+ await expect(getComputedStyle(last).zIndex).toBe('auto')
237
+ // The shared edges are drawn by the focused trigger, not its neighbours.
238
+ const r = middle.getBoundingClientRect()
239
+ const y = r.top + r.height / 2
240
+ await expect(middle.contains(document.elementFromPoint(r.left + 0.5, y))).toBe(true)
241
+ await expect(middle.contains(document.elementFromPoint(r.right - 0.5, y))).toBe(true)
242
+ },
243
+ }
244
+
245
+ function FormatGroup() {
246
+ const [pressed, setPressed] = useState<Record<string, boolean>>({u: true})
247
+ const toggle = (key: string) => (next: boolean) => setPressed((p) => ({...p, [key]: next}))
248
+ return (
249
+ <ButtonGroup attached aria-label="Format">
250
+ <Toggle aria-label="Bold" pressed={!!pressed.b} onPressedChange={toggle('b')}>
251
+ B
252
+ </Toggle>
253
+ <Toggle aria-label="Italic" pressed={!!pressed.i} onPressedChange={toggle('i')}>
254
+ I
255
+ </Toggle>
256
+ <Toggle aria-label="Underline" pressed={!!pressed.u} onPressedChange={toggle('u')}>
257
+ U
258
+ </Toggle>
259
+ <Toggle aria-label="Code" pressed={!!pressed.code} onPressedChange={toggle('code')}>
260
+ {'<>'}
261
+ </Toggle>
262
+ <Popover.Root>
263
+ <Popover.Trigger aria-label="Link">↗</Popover.Trigger>
264
+ <Popover.Popup label="Link">
265
+ <Stack gap="sm">
266
+ <Text>Paste a URL.</Text>
267
+ <Button size="small">Apply</Button>
268
+ </Stack>
269
+ </Popover.Popup>
270
+ </Popover.Root>
271
+ <Button variant="secondary" aria-label="Clear formatting">
272
+ T
273
+ </Button>
274
+ <Button variant="secondary" aria-label="Highlight">
275
+ H
276
+ </Button>
277
+ </ButtonGroup>
278
+ )
279
+ }
280
+
281
+ /** [B | I | U | <> | link | T | highlight]: four Toggles, a Popover trigger
282
+ * and two icon Buttons, joined. */
283
+ export const AttachedMixed: Story = {
284
+ render: () => <FormatGroup />,
285
+ play: async ({canvas}) => {
286
+ const group = canvas.getByRole('group', {name: 'Format'})
287
+ await expectJoined(within(group).getAllByRole('button'))
288
+ },
289
+ }
290
+
291
+ /** A pressed Toggle in an attached group keeps aria-pressed, its pressed
292
+ * fill and its joined corners. */
293
+ export const AttachedPressedToggle: Story = {
294
+ render: () => <FormatGroup />,
295
+ play: async ({canvas}) => {
296
+ const underline = canvas.getByRole('button', {name: 'Underline'})
297
+ await expect(underline).toHaveAttribute('aria-pressed', 'true')
298
+ await expect(corners(underline)).toEqual(EXPECTED_CORNERS.middle)
299
+ const bold = canvas.getByRole('button', {name: 'Bold'})
300
+ await expect(getComputedStyle(underline).backgroundColor).not.toBe(
301
+ getComputedStyle(bold).backgroundColor,
302
+ )
303
+ },
304
+ }
305
+
306
+ /** True when `el` is what is drawn at (x, y). */
307
+ const drawnAt = (el: Element, x: number, y: number) => el.contains(document.elementFromPoint(x, y))
308
+
309
+ /** The centre of the first whole pixel inside the border on each side: where
310
+ * the inset accent ring is drawn, after pixel snapping. */
311
+ function ringPixels(r: DOMRect) {
312
+ const h = BORDERS_PX.hairline
313
+ return {
314
+ left: Math.ceil(r.left + h) + 0.5,
315
+ right: Math.floor(r.right - h) - 0.5,
316
+ top: Math.ceil(r.top + h) + 0.5,
317
+ bottom: Math.floor(r.bottom - h) - 0.5,
318
+ }
319
+ }
320
+
321
+ /** U pressed between I and <>: its accent edge shows on all four sides. */
322
+ export const PressedBetweenNeighbours: Story = {
323
+ render: () => <FormatGroup />,
324
+ play: async ({canvas}) => {
325
+ const underline = canvas.getByRole('button', {name: 'Underline'})
326
+ await expect(getComputedStyle(underline).boxShadow).toMatch(/inset/)
327
+ const r = underline.getBoundingClientRect()
328
+ const ring = ringPixels(r)
329
+ const cx = r.left + r.width / 2
330
+ const cy = r.top + r.height / 2
331
+ // Top and bottom: nothing overlaps, so a hit test inside the border lands on U.
332
+ await expect(drawnAt(underline, cx, ring.top)).toBe(true)
333
+ await expect(drawnAt(underline, cx, ring.bottom)).toBe(true)
334
+ await expect(drawnAt(underline, cx, cy)).toBe(true)
335
+ // Left and right: the neighbours' negative margins cover U's border and no
336
+ // more, so the inset ring (inside the border) stays clear on both sides.
337
+ // Measured, not hit-tested: a 1px band does not survive pixel snapping in
338
+ // a scaled test frame.
339
+ const h = BORDERS_PX.hairline
340
+ const left = canvas.getByRole('button', {name: 'Italic'}).getBoundingClientRect()
341
+ const right = canvas.getByRole('button', {name: 'Code'}).getBoundingClientRect()
342
+ await expect(left.right).toBeLessThanOrEqual(r.left + h + 0.01)
343
+ await expect(right.left).toBeGreaterThanOrEqual(r.right - h - 0.01)
344
+ await expect(getComputedStyle(underline).boxShadow).toContain(`${h}px inset`)
345
+ },
346
+ }
347
+
348
+ /** B and I both pressed, then I focused: both accent edges show, and I is
349
+ * drawn over the shared border, so its focus ring is not covered. */
350
+ export const BothPressedFocusSecond: Story = {
351
+ render: () => <FormatGroup />,
352
+ play: async ({canvas}) => {
353
+ const bold = canvas.getByRole('button', {name: 'Bold'})
354
+ const italic = canvas.getByRole('button', {name: 'Italic'})
355
+ await userEvent.click(bold)
356
+ await userEvent.click(italic)
357
+ await expect(bold).toHaveAttribute('aria-pressed', 'true')
358
+ await expect(italic).toHaveAttribute('aria-pressed', 'true')
359
+ bold.focus()
360
+ await userEvent.tab()
361
+ await expect(italic).toHaveFocus()
362
+ await expect(getComputedStyle(italic).zIndex).toBe(String(LAYERS.raised))
363
+ const b = bold.getBoundingClientRect()
364
+ const i = italic.getBoundingClientRect()
365
+ // Sampled down the shared edge: I is on top all along it.
366
+ for (const t of [0.25, 0.5, 0.75]) {
367
+ const y = i.top + i.height * t
368
+ await expect(drawnAt(italic, i.left + 0.5, y)).toBe(true)
369
+ }
370
+ for (const el of [bold, italic]) await expect(getComputedStyle(el).boxShadow).toMatch(/inset/)
371
+ await expect(drawnAt(bold, b.left + b.width / 2, ringPixels(b).top)).toBe(true)
372
+ await expect(drawnAt(bold, b.left + b.width / 2, ringPixels(b).bottom)).toBe(true)
373
+ },
374
+ }
@@ -1,6 +1,8 @@
1
- import type {ReactNode} from 'react'
1
+ import {type ReactNode, useCallback, useMemo} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import type {SpacingToken} from '@duro-app/tokens/keys'
4
+ import {useOrderedRegistry} from '../../shared/useOrderedRegistry'
5
+ import {ButtonGroupContext, type ButtonGroupContextValue} from './ButtonGroupContext'
4
6
  import {styles} from './styles.css'
5
7
 
6
8
  export type ButtonGroupGap = Extract<SpacingToken, 'xs' | 'sm' | 'md'>
@@ -13,8 +15,15 @@ export interface ButtonGroupProps {
13
15
  align?: 'start' | 'end' | 'center'
14
16
  /** Disable all buttons in group */
15
17
  disabled?: boolean
16
- /** Gap between buttons. Default: 'sm' */
18
+ /** Gap between buttons. Default: 'sm' (ignored when `attached`) */
17
19
  gap?: ButtonGroupGap
20
+ /**
21
+ * One joined control: no gap, shared borders, square inner corners. Holds
22
+ * Buttons, Toggles and the Select, Menu and Popover triggers, in any mix.
23
+ */
24
+ attached?: boolean
25
+ /** Names the group for assistive tech (a toolbar segment: "Text style"). */
26
+ 'aria-label'?: string
18
27
  }
19
28
 
20
29
  const gapMap = {
@@ -41,22 +50,45 @@ export function ButtonGroup({
41
50
  align = 'start',
42
51
  disabled = false,
43
52
  gap = 'sm',
53
+ attached = false,
54
+ 'aria-label': ariaLabel,
44
55
  }: ButtonGroupProps) {
45
56
  const isVertical = orientation === 'vertical'
46
57
  const alignStyle = isVertical ? verticalAlignMap[align] : horizontalAlignMap[align]
58
+ const {order, register} = useOrderedRegistry()
59
+
60
+ const positionOf = useCallback<ButtonGroupContextValue['positionOf']>(
61
+ (id) => {
62
+ const i = order.findIndex((entry) => entry.id === id)
63
+ if (i < 0) return 'middle'
64
+ if (order.length === 1) return 'only'
65
+ if (i === 0) return 'first'
66
+ return i === order.length - 1 ? 'last' : 'middle'
67
+ },
68
+ [order],
69
+ )
70
+ const ctx = useMemo<ButtonGroupContextValue>(
71
+ () => ({orientation, register, positionOf}),
72
+ [orientation, register, positionOf],
73
+ )
47
74
 
48
75
  return (
49
- <html.div
50
- role="group"
51
- style={[
52
- styles.base,
53
- isVertical ? styles.vertical : styles.horizontal,
54
- gapMap[gap],
55
- alignStyle,
56
- disabled && styles.disabled,
57
- ]}
58
- >
59
- {children}
60
- </html.div>
76
+ // A non-attached group still resets the context, so a plain group nested
77
+ // in an attached one lays out on its own.
78
+ <ButtonGroupContext.Provider value={attached ? ctx : null}>
79
+ <html.div
80
+ role="group"
81
+ aria-label={ariaLabel}
82
+ style={[
83
+ styles.base,
84
+ isVertical ? styles.vertical : styles.horizontal,
85
+ alignStyle,
86
+ attached ? (isVertical ? styles.attachedVertical : styles.attached) : gapMap[gap],
87
+ disabled && styles.disabled,
88
+ ]}
89
+ >
90
+ {children}
91
+ </html.div>
92
+ </ButtonGroupContext.Provider>
61
93
  )
62
94
  }
@@ -0,0 +1,19 @@
1
+ import {createContext, useContext} from 'react'
2
+ import type {Orientation} from '../../shared/types'
3
+
4
+ /** Where a control sits in an attached group. */
5
+ export type GroupPosition = 'first' | 'middle' | 'last' | 'only'
6
+
7
+ export interface ButtonGroupContextValue {
8
+ orientation: Orientation
9
+ register: (id: string, el: HTMLElement, disabled: boolean) => () => void
10
+ /** `middle` until the control has registered (server render, first paint). */
11
+ positionOf: (id: string) => GroupPosition
12
+ }
13
+
14
+ /** Provided by an `attached` ButtonGroup only; null everywhere else. */
15
+ export const ButtonGroupContext = createContext<ButtonGroupContextValue | null>(null)
16
+
17
+ export function useButtonGroup() {
18
+ return useContext(ButtonGroupContext)
19
+ }
@@ -0,0 +1,44 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
3
+ import {layers} from '@duro-app/tokens/tokens/layers.css'
4
+
5
+ // The styles a control takes in an attached ButtonGroup, by its position.
6
+ // Borders collapse into the neighbour's with a negative hairline margin; only
7
+ // the outer corners keep the control's own radius.
8
+ const overlap = `calc(-1 * ${borders.hairline})`
9
+
10
+ export const attached = css.create({
11
+ // A focused control rises above its neighbours, so their borders never
12
+ // cover its focus ring. Position makes the z-index apply to a trigger that
13
+ // is not itself the flex item (Select and Menu triggers sit in a Root).
14
+ item: {
15
+ position: 'relative',
16
+ zIndex: {default: null, ':focus-visible': layers.raised},
17
+ },
18
+ horizontalFirst: {
19
+ borderTopRightRadius: 0,
20
+ borderBottomRightRadius: 0,
21
+ },
22
+ horizontalMiddle: {
23
+ borderRadius: 0,
24
+ marginLeft: overlap,
25
+ },
26
+ horizontalLast: {
27
+ borderTopLeftRadius: 0,
28
+ borderBottomLeftRadius: 0,
29
+ marginLeft: overlap,
30
+ },
31
+ verticalFirst: {
32
+ borderBottomLeftRadius: 0,
33
+ borderBottomRightRadius: 0,
34
+ },
35
+ verticalMiddle: {
36
+ borderRadius: 0,
37
+ marginTop: overlap,
38
+ },
39
+ verticalLast: {
40
+ borderTopLeftRadius: 0,
41
+ borderTopRightRadius: 0,
42
+ marginTop: overlap,
43
+ },
44
+ })
@@ -21,6 +21,14 @@ export const styles = css.create({
21
21
  gapXs: {gap: spacing.xs},
22
22
  gapSm: {gap: spacing.sm},
23
23
  gapMd: {gap: spacing.md},
24
+ // One joined control: no gap, one row, every control as tall as the row.
25
+ attached: {
26
+ flexWrap: 'nowrap',
27
+ alignItems: 'stretch',
28
+ },
29
+ attachedVertical: {
30
+ alignItems: 'stretch',
31
+ },
24
32
  disabled: {
25
33
  opacity: 0.5,
26
34
  pointerEvents: 'none',
@@ -0,0 +1,75 @@
1
+ import {type RefObject, useId, useLayoutEffect, useRef} from 'react'
2
+ import type {html} from 'react-strict-dom'
3
+ import {isNative} from '../../platform'
4
+ import {useToolbar} from '../Toolbar/ToolbarContext'
5
+ import {useButtonGroup, type GroupPosition} from './ButtonGroupContext'
6
+ import {attached} from './attached.css'
7
+
8
+ const positionStyles = {
9
+ horizontal: {
10
+ first: attached.horizontalFirst,
11
+ middle: attached.horizontalMiddle,
12
+ last: attached.horizontalLast,
13
+ only: null,
14
+ },
15
+ vertical: {
16
+ first: attached.verticalFirst,
17
+ middle: attached.verticalMiddle,
18
+ last: attached.verticalLast,
19
+ only: null,
20
+ },
21
+ } as const
22
+
23
+ type ButtonStyle = Parameters<typeof html.button>[0]['style']
24
+
25
+ export interface GroupedControl<T extends HTMLElement> {
26
+ ref: RefObject<T | null>
27
+ /** In an attached ButtonGroup. */
28
+ attached: boolean
29
+ position: GroupPosition | null
30
+ /** The joined style for its position; spread after the control's own. */
31
+ style: ButtonStyle | null
32
+ /** The Toolbar's roving tabindex, when in one. */
33
+ tabIndex: 0 | -1 | undefined
34
+ onFocus: (() => void) | undefined
35
+ }
36
+
37
+ /**
38
+ * What a control (Button, Toggle, the Select, Menu and Popover triggers) takes
39
+ * from an attached ButtonGroup and a Toolbar around it: its joined style by
40
+ * position, and its roving tabindex. Outside both, every field is inert.
41
+ */
42
+ export function useGroupedControl<T extends HTMLElement>({
43
+ disabled = false,
44
+ }: {disabled?: boolean} = {}): GroupedControl<T> {
45
+ const id = useId()
46
+ const ref = useRef<T | null>(null)
47
+ const group = useButtonGroup()
48
+ const toolbar = useToolbar()
49
+
50
+ const groupRegister = group?.register
51
+ const toolbarRegister = toolbar?.register
52
+ useLayoutEffect(() => {
53
+ const el = ref.current
54
+ if (isNative || !el) return
55
+ const unregisterGroup = groupRegister?.(id, el, disabled)
56
+ const unregisterToolbar = toolbarRegister?.(id, el, disabled)
57
+ return () => {
58
+ unregisterGroup?.()
59
+ unregisterToolbar?.()
60
+ }
61
+ }, [id, disabled, groupRegister, toolbarRegister])
62
+
63
+ const position: GroupPosition | null = group ? group.positionOf(id) : null
64
+ const style: ButtonStyle | null =
65
+ group && position ? [attached.item, positionStyles[group.orientation][position]] : null
66
+
67
+ return {
68
+ ref,
69
+ attached: group !== null,
70
+ position,
71
+ style,
72
+ tabIndex: toolbar?.tabIndexOf(id),
73
+ onFocus: toolbar ? () => toolbar.onItemFocus(id) : undefined,
74
+ }
75
+ }
@@ -14,6 +14,8 @@ import {html} from 'react-strict-dom'
14
14
  import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
15
15
  import {styles} from './styles.css'
16
16
  import {usePortalMount} from '../ThemeProvider/ThemeProvider'
17
+ import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
18
+ import {ModalContext} from '../../shared/ModalContext'
17
19
  import {devWarnOnce} from '../../shared/devWarnOnce'
18
20
 
19
21
  // --- Types ---
@@ -292,7 +294,9 @@ function Portal({children, size = 'md'}: PortalProps) {
292
294
  !closing && styles.popupOpen,
293
295
  ]}
294
296
  >
295
- {children}
297
+ <ModalContext.Provider value={true}>
298
+ <ControlContextBoundary>{children}</ControlContextBoundary>
299
+ </ModalContext.Provider>
296
300
  </html.div>
297
301
  </html.div>
298
302
  </>
@@ -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',
@@ -43,7 +44,7 @@ export const styles = css.create({
43
44
  viewport: {
44
45
  position: 'fixed',
45
46
  inset: 0,
46
- zIndex: 1001,
47
+ zIndex: layers.modal,
47
48
  display: 'flex',
48
49
  alignItems: 'center',
49
50
  justifyContent: 'center',
@@ -8,8 +8,10 @@ import {
8
8
  useState,
9
9
  type ReactNode,
10
10
  } from 'react'
11
+ import {createPortal} from 'react-dom'
11
12
  import {html} from 'react-strict-dom'
12
13
  import {styles} from './styles.css'
14
+ import {usePortalMount} from '../ThemeProvider/ThemeProvider'
13
15
  import {isNative} from '../../platform'
14
16
  import {visuallyHidden} from '../../styles/visually-hidden.css'
15
17
 
@@ -343,6 +345,18 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
343
345
  }
344
346
  }, [activate, finish, locate])
345
347
 
348
+ // The ghost renders in the ThemeProvider mount: inline, a Dialog's
349
+ // transformed, overflow-clipped panel would clip and offset it.
350
+ const mount = usePortalMount()
351
+ const ghost = drag && (
352
+ <html.div
353
+ aria-hidden
354
+ style={[styles.ghost, styles.ghostAt(drag.x, drag.y, drag.width, drag.height)]}
355
+ >
356
+ {drag.preview}
357
+ </html.div>
358
+ )
359
+
346
360
  const value = useMemo<RootContextValue>(
347
361
  () => ({registerZone, registerItem, begin, drag}),
348
362
  [registerZone, registerItem, begin, drag],
@@ -351,14 +365,7 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
351
365
  return (
352
366
  <RootContext.Provider value={value}>
353
367
  {children}
354
- {drag && (
355
- <html.div
356
- aria-hidden
357
- style={[styles.ghost, styles.ghostAt(drag.x, drag.y, drag.width, drag.height)]}
358
- >
359
- {drag.preview}
360
- </html.div>
361
- )}
368
+ {ghost && (mount ? createPortal(ghost, mount) : ghost)}
362
369
  <html.div role="status" aria-live="polite" aria-atomic style={visuallyHidden.base}>
363
370
  {announcement}
364
371
  </html.div>
@@ -4,6 +4,7 @@ import {radii} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
5
5
  import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
6
  import {borders} from '@duro-app/tokens/tokens/borders.css'
7
+ import {layers} from '@duro-app/tokens/tokens/layers.css'
7
8
 
8
9
  export const styles = css.create({
9
10
  // The item is the drag handle: no browser panning starts on it, so a touch
@@ -56,7 +57,9 @@ export const styles = css.create({
56
57
  top: 0,
57
58
  left: 0,
58
59
  pointerEvents: 'none',
59
- zIndex: 1000,
60
+ // Portalled into the ThemeProvider mount, where it must clear a Dialog
61
+ // or Drawer the drag happens in.
62
+ zIndex: layers.popup,
60
63
  opacity: 0.9,
61
64
  cursor: 'grabbing',
62
65
  },
@@ -14,6 +14,8 @@ import {html} from 'react-strict-dom'
14
14
  import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
15
15
  import {styles} from './styles.css'
16
16
  import {usePortalMount} from '../ThemeProvider/ThemeProvider'
17
+ import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
18
+ import {ModalContext} from '../../shared/ModalContext'
17
19
  import {useSwipeDismiss} from './useSwipeDismiss'
18
20
 
19
21
  // --- Types ---
@@ -298,7 +300,9 @@ function Portal({children, size = 'md'}: PortalProps) {
298
300
  !closing && slideInMap[anchor],
299
301
  ]}
300
302
  >
301
- {children}
303
+ <ModalContext.Provider value={true}>
304
+ <ControlContextBoundary>{children}</ControlContextBoundary>
305
+ </ModalContext.Provider>
302
306
  </html.div>
303
307
  </html.div>
304
308
  </>