@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
@@ -2,11 +2,12 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Groups related buttons together with consistent spacing and layout. Supports horizontal and vertical orientation with alignment control.',
5
+ 'Groups related buttons together with consistent spacing and layout. Supports horizontal and vertical orientation with alignment control. With `attached`, the group renders one joined control (shared borders, square inner corners) from any mix of Button, Toggle and Select/Menu/Popover triggers.',
6
6
  whenToUse: [
7
7
  'Dialog footers with Save/Cancel actions',
8
8
  'Toolbar-style groupings of related actions',
9
9
  'Form submission areas with multiple actions',
10
+ '`attached`: a segmented control of mixed controls — [Normal | Arial | 15px] as three Selects, [B | I | U | link] as Toggles and a Popover trigger',
10
11
  ],
11
12
  whenNotToUse: [
12
13
  'Single button — just use Button directly',
@@ -14,6 +15,18 @@ export const meta: ComponentMeta = {
14
15
  'Navigation items — use Inline or SideNav',
15
16
  ],
16
17
  relatedTo: [
18
+ {
19
+ component: 'Toolbar',
20
+ kind: 'composition',
21
+ relationship:
22
+ 'Put attached groups in a Toolbar for one tab stop and arrow keys across them; a ButtonGroup alone leaves every control its own tab stop',
23
+ },
24
+ {
25
+ component: 'ToggleGroup',
26
+ kind: 'contrast',
27
+ relationship:
28
+ 'ToggleGroup owns a selection (single or multiple) across Toggles; ButtonGroup attached only joins controls visually, each keeps its own state',
29
+ },
17
30
  {
18
31
  component: 'Inline',
19
32
  kind: 'contrast',
@@ -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
+ }