@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.
- package/dist/{Table-C70zdSkM.js → Table-Cm5cCnZD.js} +730 -625
- package/dist/Table-Cm5cCnZD.js.map +1 -0
- package/dist/components/ActionBar/styles.css.d.ts +1 -1
- package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
- package/dist/components/Badge/Badge.d.ts +1 -1
- package/dist/components/Badge/Badge.d.ts.map +1 -1
- package/dist/components/Badge/Badge.stories.d.ts +3 -0
- package/dist/components/Badge/Badge.stories.d.ts.map +1 -1
- package/dist/components/Badge/styles.css.d.ts +4 -0
- package/dist/components/Badge/styles.css.d.ts.map +1 -1
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.d.ts +9 -2
- package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +19 -0
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroupContext.d.ts +13 -0
- package/dist/components/ButtonGroup/ButtonGroupContext.d.ts.map +1 -0
- package/dist/components/ButtonGroup/attached.css.d.ts +34 -0
- package/dist/components/ButtonGroup/attached.css.d.ts.map +1 -0
- package/dist/components/ButtonGroup/styles.css.d.ts +7 -0
- package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -1
- package/dist/components/ButtonGroup/useGroupedControl.d.ts +25 -0
- package/dist/components/ButtonGroup/useGroupedControl.d.ts.map +1 -0
- package/dist/components/Dialog/Dialog.d.ts.map +1 -1
- package/dist/components/Dialog/styles.css.d.ts +2 -2
- package/dist/components/Dialog/styles.css.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
- package/dist/components/DragDrop/styles.css.d.ts +1 -1
- package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/styles.css.d.ts +2 -2
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Listbox/styles.css.d.ts +1 -1
- package/dist/components/Listbox/styles.css.d.ts.map +1 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +1 -1
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/Popover/Popover.d.ts +7 -2
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/styles.css.d.ts +4 -1
- package/dist/components/Popover/styles.css.d.ts.map +1 -1
- package/dist/components/ScrollArea/styles.css.d.ts +1 -1
- package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +2 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +2 -2
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/ThemeProvider/Layers.stories.d.ts +24 -0
- package/dist/components/ThemeProvider/Layers.stories.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/components/Toast/styles.css.d.ts +1 -0
- package/dist/components/Toast/styles.css.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/styles.css.d.ts +3 -0
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
- package/dist/components/ToggleGroup/styles.css.d.ts +5 -0
- package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
- package/dist/components/Toolbar/ControlContextBoundary.d.ts +10 -0
- package/dist/components/Toolbar/ControlContextBoundary.d.ts.map +1 -0
- package/dist/components/Toolbar/Toolbar.d.ts +22 -0
- package/dist/components/Toolbar/Toolbar.d.ts.map +1 -0
- package/dist/components/Toolbar/Toolbar.meta.d.ts +3 -0
- package/dist/components/Toolbar/Toolbar.meta.d.ts.map +1 -0
- package/dist/components/Toolbar/Toolbar.stories.d.ts +28 -0
- package/dist/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
- package/dist/components/Toolbar/ToolbarContext.d.ts +10 -0
- package/dist/components/Toolbar/ToolbarContext.d.ts.map +1 -0
- package/dist/components/Toolbar/styles.css.d.ts +16 -0
- package/dist/components/Toolbar/styles.css.d.ts.map +1 -0
- package/dist/components/Tooltip/styles.css.d.ts +1 -1
- package/dist/components/Tooltip/styles.css.d.ts.map +1 -1
- package/dist/components/VirtualTable/styles.css.d.ts +1 -1
- package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3164 -2917
- package/dist/index.js.map +1 -1
- package/dist/shared/ModalContext.d.ts +14 -0
- package/dist/shared/ModalContext.d.ts.map +1 -0
- package/dist/shared/mergeRefs.d.ts +4 -0
- package/dist/shared/mergeRefs.d.ts.map +1 -0
- package/dist/shared/useOrderedRegistry.d.ts +26 -0
- package/dist/shared/useOrderedRegistry.d.ts.map +1 -0
- package/dist/table.js +45 -45
- package/dist/table.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/styles.css.ts +2 -1
- package/src/components/Badge/Badge.stories.tsx +62 -1
- package/src/components/Badge/Badge.tsx +1 -1
- package/src/components/Badge/styles.css.ts +5 -0
- package/src/components/Button/Button.tsx +7 -1
- package/src/components/ButtonGroup/ButtonGroup.meta.ts +14 -1
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +271 -1
- package/src/components/ButtonGroup/ButtonGroup.tsx +46 -14
- package/src/components/ButtonGroup/ButtonGroupContext.ts +19 -0
- package/src/components/ButtonGroup/attached.css.ts +44 -0
- package/src/components/ButtonGroup/styles.css.ts +8 -0
- package/src/components/ButtonGroup/useGroupedControl.ts +75 -0
- package/src/components/Dialog/Dialog.tsx +5 -1
- package/src/components/Dialog/styles.css.ts +3 -2
- package/src/components/DragDrop/DragDrop.tsx +15 -8
- package/src/components/DragDrop/styles.css.ts +4 -1
- package/src/components/Drawer/Drawer.tsx +5 -1
- package/src/components/Drawer/styles.css.ts +3 -2
- package/src/components/Listbox/styles.css.ts +2 -1
- package/src/components/Menu/Menu.tsx +10 -3
- package/src/components/Menu/styles.css.ts +2 -1
- package/src/components/Popover/Popover.tsx +26 -5
- package/src/components/Popover/styles.css.ts +9 -1
- package/src/components/ScrollArea/styles.css.ts +2 -1
- package/src/components/Select/Select.stories.tsx +20 -1
- package/src/components/Select/Select.tsx +9 -3
- package/src/components/Select/styles.css.ts +3 -2
- package/src/components/Select/useSelectRoot.ts +17 -0
- package/src/components/ThemeProvider/Layers.stories.tsx +379 -0
- package/src/components/ThemeProvider/ThemeProvider.tsx +4 -2
- package/src/components/Toast/styles.css.ts +3 -0
- package/src/components/Toggle/Toggle.tsx +20 -4
- package/src/components/Toggle/styles.css.ts +6 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +16 -4
- package/src/components/ToggleGroup/styles.css.ts +7 -0
- package/src/components/Toolbar/ControlContextBoundary.tsx +16 -0
- package/src/components/Toolbar/Toolbar.meta.ts +48 -0
- package/src/components/Toolbar/Toolbar.stories.tsx +468 -0
- package/src/components/Toolbar/Toolbar.tsx +113 -0
- package/src/components/Toolbar/ToolbarContext.ts +15 -0
- package/src/components/Toolbar/styles.css.ts +17 -0
- package/src/components/Tooltip/styles.css.ts +2 -1
- package/src/components/VirtualTable/styles.css.ts +2 -1
- package/src/index.ts +2 -1
- package/src/shared/ModalContext.ts +18 -0
- package/src/shared/mergeRefs.ts +11 -0
- package/src/shared/useOrderedRegistry.ts +57 -0
- 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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
{
|
|
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:
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
{
|
|
303
|
+
<ModalContext.Provider value={true}>
|
|
304
|
+
<ControlContextBoundary>{children}</ControlContextBoundary>
|
|
305
|
+
</ModalContext.Provider>
|
|
302
306
|
</html.div>
|
|
303
307
|
</html.div>
|
|
304
308
|
</>
|