@duro-app/ui 4.3.0 → 4.5.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-JTgcR_Fh.js → Table-CUp12BKZ.js} +78 -75
- package/dist/Table-CUp12BKZ.js.map +1 -0
- package/dist/components/ActionBar/ActionBar.d.ts +7 -0
- package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBar.meta.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBar.stories.d.ts +3 -0
- package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
- package/dist/components/ActionBar/styles.css.d.ts +25 -2
- package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -1
- package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerScroll.stories.d.ts +13 -0
- package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts +7 -2
- package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
- package/dist/components/Popover/PopoverLayerContext.d.ts +10 -0
- package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -0
- package/dist/components/Popover/PopoverLayers.stories.d.ts +13 -0
- package/dist/components/Popover/PopoverLayers.stories.d.ts.map +1 -0
- package/dist/components/ScrollArea/ScrollArea.d.ts +8 -2
- package/dist/components/ScrollArea/ScrollArea.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.meta.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/rowHeight.d.ts +17 -0
- package/dist/components/Toggle/rowHeight.d.ts.map +1 -0
- package/dist/components/Toggle/styles.css.d.ts +10 -0
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts +7 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.meta.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts +4 -0
- package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroupContext.d.ts +6 -0
- package/dist/components/ToggleGroup/ToggleGroupContext.d.ts.map +1 -1
- package/dist/components/ToggleGroup/styles.css.d.ts +19 -0
- package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
- package/dist/components/ToggleGroup/useRovingFocus.d.ts +17 -0
- package/dist/components/ToggleGroup/useRovingFocus.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.js +2987 -2682
- package/dist/index.js.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/ActionBar.meta.ts +7 -0
- package/src/components/ActionBar/ActionBar.stories.tsx +95 -3
- package/src/components/ActionBar/ActionBar.tsx +12 -1
- package/src/components/ActionBar/styles.css.ts +34 -2
- package/src/components/Combobox/Combobox.meta.ts +6 -0
- package/src/components/Combobox/Combobox.tsx +5 -0
- package/src/components/DetailPanel/DetailPanel.meta.ts +6 -0
- package/src/components/Drawer/Drawer.meta.ts +1 -1
- package/src/components/Drawer/Drawer.tsx +12 -2
- package/src/components/Drawer/DrawerScroll.stories.tsx +292 -0
- package/src/components/Drawer/useSwipeDismiss.ts +53 -8
- package/src/components/Menu/useMenuRoot.ts +5 -1
- package/src/components/Popover/Popover.meta.ts +7 -0
- package/src/components/Popover/Popover.tsx +30 -24
- package/src/components/Popover/PopoverLayerContext.ts +20 -0
- package/src/components/Popover/PopoverLayers.stories.tsx +234 -0
- package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
- package/src/components/ScrollArea/ScrollArea.tsx +12 -6
- package/src/components/Select/Select.meta.ts +6 -0
- package/src/components/Select/Select.tsx +8 -1
- package/src/components/Select/useSelectRoot.ts +6 -2
- package/src/components/Toggle/Toggle.meta.ts +1 -1
- package/src/components/Toggle/Toggle.tsx +19 -2
- package/src/components/Toggle/rowHeight.ts +12 -0
- package/src/components/Toggle/styles.css.ts +12 -0
- package/src/components/ToggleGroup/ToggleGroup.meta.ts +11 -1
- package/src/components/ToggleGroup/ToggleGroup.stories.tsx +156 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +87 -10
- package/src/components/ToggleGroup/ToggleGroupContext.ts +6 -0
- package/src/components/ToggleGroup/styles.css.ts +30 -1
- package/src/components/ToggleGroup/useRovingFocus.ts +121 -0
- package/dist/Table-JTgcR_Fh.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {type ReactNode, useCallback} from 'react'
|
|
1
|
+
import {type ReactNode, useCallback, useLayoutEffect, useRef} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
4
4
|
import {useToggleGroup} from '../ToggleGroup/ToggleGroupContext'
|
|
@@ -23,6 +23,11 @@ interface ToggleProps {
|
|
|
23
23
|
children: ReactNode
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const wrappedSizeMap = {
|
|
27
|
+
default: styles.wrappedDefault,
|
|
28
|
+
small: styles.wrappedSmall,
|
|
29
|
+
} as const
|
|
30
|
+
|
|
26
31
|
const sizeMap = {
|
|
27
32
|
default: styles.sizeDefault,
|
|
28
33
|
small: styles.sizeSmall,
|
|
@@ -46,6 +51,14 @@ export function Toggle({
|
|
|
46
51
|
const disabled = disabledProp || (group?.disabled ?? false)
|
|
47
52
|
const size = group?.size ?? sizeProp
|
|
48
53
|
|
|
54
|
+
const wrap = group?.wrap ?? false
|
|
55
|
+
const ref = useRef<HTMLButtonElement>(null)
|
|
56
|
+
const register = group?.register
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
if (!wrap || !register || value === undefined || !ref.current) return
|
|
59
|
+
return register(value, ref.current)
|
|
60
|
+
}, [wrap, register, value])
|
|
61
|
+
|
|
49
62
|
const [standalonePressed, setStandalonePressed] = useControllableValue(
|
|
50
63
|
controlledPressed,
|
|
51
64
|
defaultPressed,
|
|
@@ -65,7 +78,10 @@ export function Toggle({
|
|
|
65
78
|
|
|
66
79
|
return (
|
|
67
80
|
<html.button
|
|
81
|
+
ref={ref}
|
|
68
82
|
type="button"
|
|
83
|
+
tabIndex={wrap && value !== undefined ? (group?.tabStopValue === value ? 0 : -1) : undefined}
|
|
84
|
+
onFocus={wrap && value !== undefined ? () => group?.onItemFocus(value) : undefined}
|
|
69
85
|
aria-pressed={pressed}
|
|
70
86
|
aria-label={ariaLabel}
|
|
71
87
|
disabled={disabled}
|
|
@@ -75,7 +91,8 @@ export function Toggle({
|
|
|
75
91
|
styles.base,
|
|
76
92
|
sizeMap[size],
|
|
77
93
|
pressed ? styles.pressed : styles.unpressed,
|
|
78
|
-
|
|
94
|
+
wrap && wrappedSizeMap[size],
|
|
95
|
+
isGrouped && !wrap && styles.grouped,
|
|
79
96
|
disabled && styles.disabled,
|
|
80
97
|
]}
|
|
81
98
|
>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fixed block size of a Toggle in a wrapping group, per size, and under a
|
|
3
|
+
* coarse pointer. One source for ToggleGroup's `maxRows` arithmetic and the
|
|
4
|
+
* stories that measure it.
|
|
5
|
+
*
|
|
6
|
+
* holds-until: StyleX can import a constant into a static style. Toggle's
|
|
7
|
+
* `styles.css.ts` must repeat these numbers in its `wrapped*` heights. The
|
|
8
|
+
* ToggleGroup stories (small, default) and the Drawer coarse-pointer story
|
|
9
|
+
* measure a rendered toggle against this module to ±0.5px, so a drift in
|
|
10
|
+
* either file fails a test rather than a layout.
|
|
11
|
+
*/
|
|
12
|
+
export const ROW_HEIGHT = {small: 28, default: 39, coarse: 44} as const
|
|
@@ -21,6 +21,8 @@ export const styles = css.create({
|
|
|
21
21
|
outlineStyle: 'solid',
|
|
22
22
|
outlineColor: colors.accent,
|
|
23
23
|
outlineOffset: 2,
|
|
24
|
+
minWidth: {default: null, '@media (pointer: coarse)': 44},
|
|
25
|
+
minHeight: {default: null, '@media (pointer: coarse)': 44},
|
|
24
26
|
},
|
|
25
27
|
sizeDefault: {
|
|
26
28
|
padding: `${spacing.sm} ${spacing.md}`,
|
|
@@ -34,6 +36,16 @@ export const styles = css.create({
|
|
|
34
36
|
borderRadius: radii.sm,
|
|
35
37
|
gap: spacing.xs,
|
|
36
38
|
},
|
|
39
|
+
// wrapped toggles: a fixed block size per size, 44px under a coarse pointer.
|
|
40
|
+
// Must equal ROW_HEIGHT in ./rowHeight (StyleX cannot import it here).
|
|
41
|
+
wrappedDefault: {
|
|
42
|
+
boxSizing: 'border-box',
|
|
43
|
+
height: {default: 39, '@media (pointer: coarse)': 44},
|
|
44
|
+
},
|
|
45
|
+
wrappedSmall: {
|
|
46
|
+
boxSizing: 'border-box',
|
|
47
|
+
height: {default: 28, '@media (pointer: coarse)': 44},
|
|
48
|
+
},
|
|
37
49
|
unpressed: {
|
|
38
50
|
backgroundColor: {
|
|
39
51
|
default: 'transparent',
|
|
@@ -6,10 +6,11 @@ export const meta: ComponentMeta = {
|
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Visible option set where user picks one or more (view mode, filter categories)',
|
|
8
8
|
'Segmented control pattern',
|
|
9
|
+
'A visible grid of icon choices: wrap + maxRows (rows scroll past maxRows, one tab stop with arrow keys, 44px touch targets)',
|
|
9
10
|
],
|
|
10
11
|
whenNotToUse: [
|
|
11
12
|
'Dropdown selection — use Select',
|
|
12
|
-
'Many options (5+) — use Select for space efficiency',
|
|
13
|
+
'Many text options (5+) — use Select for space efficiency; does not apply to icon-only choices, which keep options visible and open no layer',
|
|
13
14
|
'Form checkboxes — use Checkbox group in Fieldset',
|
|
14
15
|
],
|
|
15
16
|
relatedTo: [
|
|
@@ -30,5 +31,14 @@ export const meta: ComponentMeta = {
|
|
|
30
31
|
<Toggle value="list">List view</Toggle>
|
|
31
32
|
<Toggle value="grid">Grid view</Toggle>
|
|
32
33
|
<Toggle value="board">Board view</Toggle>
|
|
34
|
+
</ToggleGroup>
|
|
35
|
+
|
|
36
|
+
// Icon choices: wraps onto rows, scrolls past three, one tab stop
|
|
37
|
+
<ToggleGroup wrap maxRows={3} size="small" value={[icon]} onValueChange={(v) => v[0] && setIcon(v[0])} aria-label="Icon">
|
|
38
|
+
{names.map((name) => (
|
|
39
|
+
<Toggle key={name} value={name} aria-label={name}>
|
|
40
|
+
<Icon name={name} size="sm" />
|
|
41
|
+
</Toggle>
|
|
42
|
+
))}
|
|
33
43
|
</ToggleGroup>`,
|
|
34
44
|
}
|
|
@@ -3,6 +3,8 @@ import {expect, fn} from 'storybook/test'
|
|
|
3
3
|
import {css, html} from 'react-strict-dom'
|
|
4
4
|
import {ToggleGroup} from './ToggleGroup'
|
|
5
5
|
import {Toggle} from '../Toggle/Toggle'
|
|
6
|
+
import {Icon, type IconName} from '../Icon'
|
|
7
|
+
import {ROW_HEIGHT} from '../Toggle/rowHeight'
|
|
6
8
|
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
7
9
|
|
|
8
10
|
const meta: Meta<typeof ToggleGroup> = {
|
|
@@ -158,3 +160,157 @@ export const AllVariants: Story = {
|
|
|
158
160
|
await expect(groups.length).toBe(4)
|
|
159
161
|
},
|
|
160
162
|
}
|
|
163
|
+
|
|
164
|
+
const ICON_NAMES: IconName[] = [
|
|
165
|
+
'x-circle',
|
|
166
|
+
'check-circle',
|
|
167
|
+
'check-done',
|
|
168
|
+
'clock',
|
|
169
|
+
'forbidden',
|
|
170
|
+
'info-circle',
|
|
171
|
+
'alert-triangle',
|
|
172
|
+
'shield',
|
|
173
|
+
'lock',
|
|
174
|
+
'key',
|
|
175
|
+
'map',
|
|
176
|
+
'layers',
|
|
177
|
+
'repeat',
|
|
178
|
+
'database',
|
|
179
|
+
'shield-check',
|
|
180
|
+
'route',
|
|
181
|
+
'git-branch',
|
|
182
|
+
'menu',
|
|
183
|
+
'pin',
|
|
184
|
+
'server',
|
|
185
|
+
'hard-drive',
|
|
186
|
+
'box',
|
|
187
|
+
'image',
|
|
188
|
+
'tag',
|
|
189
|
+
'pie-chart',
|
|
190
|
+
'users',
|
|
191
|
+
'user-plus',
|
|
192
|
+
'mail',
|
|
193
|
+
'file-text',
|
|
194
|
+
'plug',
|
|
195
|
+
'search',
|
|
196
|
+
'mic',
|
|
197
|
+
'sun',
|
|
198
|
+
'moon',
|
|
199
|
+
'monitor',
|
|
200
|
+
'contrast',
|
|
201
|
+
'info-circle-filled',
|
|
202
|
+
'alert-triangle-filled',
|
|
203
|
+
'check-circle-filled',
|
|
204
|
+
'x-circle-filled',
|
|
205
|
+
'shield-filled',
|
|
206
|
+
'lock-filled',
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
const iconStyles = css.create({
|
|
210
|
+
frame: {width: 320},
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
function IconChoices({
|
|
214
|
+
names,
|
|
215
|
+
pressed,
|
|
216
|
+
size = 'small',
|
|
217
|
+
}: {
|
|
218
|
+
names: IconName[]
|
|
219
|
+
pressed: string
|
|
220
|
+
size?: 'small' | 'default'
|
|
221
|
+
}) {
|
|
222
|
+
return (
|
|
223
|
+
<html.div style={iconStyles.frame}>
|
|
224
|
+
<ToggleGroup wrap maxRows={3} size={size} defaultValue={[pressed]} aria-label="Icon">
|
|
225
|
+
{names.map((name) => (
|
|
226
|
+
<Toggle key={name} value={name} aria-label={name}>
|
|
227
|
+
<Icon name={name} size="sm" />
|
|
228
|
+
</Toggle>
|
|
229
|
+
))}
|
|
230
|
+
</ToggleGroup>
|
|
231
|
+
</html.div>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export const IconChoicesStory: Story = {
|
|
236
|
+
name: 'Icon choices',
|
|
237
|
+
render: () => <IconChoices names={ICON_NAMES} pressed={ICON_NAMES[39]} />,
|
|
238
|
+
play: async ({canvas, canvasElement}) => {
|
|
239
|
+
const scrollTopBefore = document.scrollingElement?.scrollTop
|
|
240
|
+
await expect(ICON_NAMES.length).toBe(42)
|
|
241
|
+
const viewport = canvasElement.querySelector<HTMLElement>('[data-duro-scroll]')!
|
|
242
|
+
await expect(Math.abs(viewport.clientHeight - 118)).toBeLessThanOrEqual(2)
|
|
243
|
+
|
|
244
|
+
const pressed = canvas.getByRole('button', {name: ICON_NAMES[39]})
|
|
245
|
+
const other = canvas.getByRole('button', {name: ICON_NAMES[0]})
|
|
246
|
+
await expect(pressed).toHaveAttribute('aria-pressed', 'true')
|
|
247
|
+
for (const el of [pressed, other]) {
|
|
248
|
+
const cs = getComputedStyle(el)
|
|
249
|
+
await expect(cs.borderTopWidth).toBe('1px')
|
|
250
|
+
await expect(cs.borderRightWidth).toBe('1px')
|
|
251
|
+
await expect(cs.borderBottomWidth).toBe('1px')
|
|
252
|
+
await expect(cs.borderLeftWidth).toBe('1px')
|
|
253
|
+
// radii.sm
|
|
254
|
+
await expect(cs.borderTopLeftRadius).toBe('8px')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// the pressed toggle is visible inside the viewport
|
|
258
|
+
const v = viewport.getBoundingClientRect()
|
|
259
|
+
const p = pressed.getBoundingClientRect()
|
|
260
|
+
await expect(p.top).toBeGreaterThanOrEqual(v.top - 1)
|
|
261
|
+
await expect(p.bottom).toBeLessThanOrEqual(v.bottom + 1)
|
|
262
|
+
await expect(document.scrollingElement?.scrollTop).toBe(scrollTopBefore)
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const FiveIcons: Story = {
|
|
267
|
+
name: 'Five icons',
|
|
268
|
+
render: () => <IconChoices names={ICON_NAMES.slice(0, 5)} pressed={ICON_NAMES[0]} />,
|
|
269
|
+
play: async ({canvas}) => {
|
|
270
|
+
const group = canvas.getByRole('toolbar')
|
|
271
|
+
await expect(group.getBoundingClientRect().height).toBe(ROW_HEIGHT.small)
|
|
272
|
+
// exact: Toggle's styles repeat ROW_HEIGHT, and a drift must fail here
|
|
273
|
+
const toggle = canvas.getAllByRole('button')[0].getBoundingClientRect()
|
|
274
|
+
await expect(Math.abs(toggle.height - ROW_HEIGHT.small)).toBeLessThanOrEqual(0.5)
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const KeyboardRoving: Story = {
|
|
279
|
+
render: () => <IconChoices names={ICON_NAMES} pressed={ICON_NAMES[3]} />,
|
|
280
|
+
play: async ({canvas, userEvent}) => {
|
|
281
|
+
const btn = (i: number) => canvas.getByRole('button', {name: ICON_NAMES[i]})
|
|
282
|
+
await userEvent.tab()
|
|
283
|
+
await expect(btn(3)).toHaveFocus()
|
|
284
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
285
|
+
await expect(btn(4)).toHaveFocus()
|
|
286
|
+
await userEvent.keyboard('{ArrowLeft}{ArrowLeft}')
|
|
287
|
+
await expect(btn(2)).toHaveFocus()
|
|
288
|
+
// Down moves to the next row, Up back
|
|
289
|
+
const top = btn(2).getBoundingClientRect().top
|
|
290
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
291
|
+
const active = document.activeElement as HTMLElement
|
|
292
|
+
await expect(active.getBoundingClientRect().top).toBeGreaterThan(top + 20)
|
|
293
|
+
await userEvent.keyboard('{ArrowUp}')
|
|
294
|
+
await expect(btn(2)).toHaveFocus()
|
|
295
|
+
await userEvent.keyboard('{End}')
|
|
296
|
+
await expect(btn(41)).toHaveFocus()
|
|
297
|
+
await userEvent.keyboard('{Home}')
|
|
298
|
+
await expect(btn(0)).toHaveFocus()
|
|
299
|
+
await userEvent.keyboard('{Enter}')
|
|
300
|
+
await expect(btn(0)).toHaveAttribute('aria-pressed', 'true')
|
|
301
|
+
// a second tab leaves the group
|
|
302
|
+
await userEvent.tab()
|
|
303
|
+
await expect(canvas.getAllByRole('button')).not.toContain(document.activeElement)
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export const DefaultSizeRows: Story = {
|
|
308
|
+
name: 'Default size rows',
|
|
309
|
+
render: () => (
|
|
310
|
+
<IconChoices names={ICON_NAMES.slice(0, 5)} pressed={ICON_NAMES[0]} size="default" />
|
|
311
|
+
),
|
|
312
|
+
play: async ({canvas}) => {
|
|
313
|
+
const toggle = canvas.getAllByRole('button')[0].getBoundingClientRect()
|
|
314
|
+
await expect(Math.abs(toggle.height - ROW_HEIGHT.default)).toBeLessThanOrEqual(0.5)
|
|
315
|
+
},
|
|
316
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {type ReactNode, useCallback} from 'react'
|
|
1
|
+
import {type ReactNode, useCallback, useLayoutEffect, useRef} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
4
4
|
import type {ToggleSize} from '../Toggle/Toggle'
|
|
5
5
|
import {ToggleGroupContext, type Orientation} from './ToggleGroupContext'
|
|
6
6
|
import {useFieldGroupLabelling} from '../Field/FieldContext'
|
|
7
|
+
import {ScrollArea} from '../ScrollArea/ScrollArea'
|
|
8
|
+
import {useRovingFocus} from './useRovingFocus'
|
|
7
9
|
import {styles} from './styles.css'
|
|
8
10
|
|
|
9
11
|
interface ToggleGroupProps {
|
|
@@ -21,6 +23,16 @@ interface ToggleGroupProps {
|
|
|
21
23
|
orientation?: Orientation
|
|
22
24
|
/** Size applied to all child toggles. */
|
|
23
25
|
size?: ToggleSize
|
|
26
|
+
/** Items wrap onto rows, each with its own border (default false). Enables
|
|
27
|
+
* roving focus: one tab stop, arrow keys move between items. */
|
|
28
|
+
wrap?: boolean
|
|
29
|
+
// holds-until: the registry generator (packages/cli/scripts) reads a
|
|
30
|
+
// discriminated union. `{wrap?: false; maxRows?: never} | {wrap: true;
|
|
31
|
+
// maxRows?: number}` is the right type, but today it drops both props from
|
|
32
|
+
// the registry the CLI and MCP serve.
|
|
33
|
+
/** Caps a wrapping group at this many rows (plus half a row, hinting more)
|
|
34
|
+
* and scrolls the rest. Only with `wrap`; ignored without it. */
|
|
35
|
+
maxRows?: number
|
|
24
36
|
/** Accessible name when the group is not inside a Field.Root (inside one,
|
|
25
37
|
* the Field.Label names it). */
|
|
26
38
|
'aria-label'?: string
|
|
@@ -37,6 +49,8 @@ export function ToggleGroup({
|
|
|
37
49
|
disabled = false,
|
|
38
50
|
orientation = 'horizontal',
|
|
39
51
|
size = 'default',
|
|
52
|
+
wrap = false,
|
|
53
|
+
maxRows,
|
|
40
54
|
children,
|
|
41
55
|
...labelling
|
|
42
56
|
}: ToggleGroupProps) {
|
|
@@ -57,16 +71,79 @@ export function ToggleGroup({
|
|
|
57
71
|
[value, multiple, setValue],
|
|
58
72
|
)
|
|
59
73
|
|
|
74
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
75
|
+
const roving = useRovingFocus({enabled: wrap, pressed: value, rootRef})
|
|
76
|
+
const scrolls = wrap && maxRows !== undefined
|
|
77
|
+
const pressedValue = value[0]
|
|
78
|
+
|
|
79
|
+
// bring the pressed toggle into view by moving the viewport itself, never
|
|
80
|
+
// scrollIntoView, which would scroll the page too
|
|
81
|
+
const {items} = roving
|
|
82
|
+
useLayoutEffect(() => {
|
|
83
|
+
if (!scrolls || pressedValue === undefined) return
|
|
84
|
+
const viewport = rootRef.current?.closest<HTMLElement>('[data-duro-scroll]')
|
|
85
|
+
if (!viewport) return
|
|
86
|
+
// Measured against the viewport's own box, not offsetTop: a positioned
|
|
87
|
+
// ancestor (a Drawer panel) would otherwise be the reference.
|
|
88
|
+
const centre = () => {
|
|
89
|
+
const el = items.current.get(pressedValue)
|
|
90
|
+
if (!el || viewport.clientHeight === 0) return false
|
|
91
|
+
const top =
|
|
92
|
+
el.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop
|
|
93
|
+
const centered = top - (viewport.clientHeight - el.offsetHeight) / 2
|
|
94
|
+
const max = viewport.scrollHeight - viewport.clientHeight
|
|
95
|
+
viewport.scrollTop = Math.min(Math.max(centered, 0), Math.max(max, 0))
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
if (centre()) return
|
|
99
|
+
// Not laid out yet (a container still opening): centre once it has a size.
|
|
100
|
+
const observer = new ResizeObserver(() => {
|
|
101
|
+
if (centre()) observer.disconnect()
|
|
102
|
+
})
|
|
103
|
+
observer.observe(viewport)
|
|
104
|
+
return () => observer.disconnect()
|
|
105
|
+
}, [scrolls, pressedValue, items])
|
|
106
|
+
|
|
107
|
+
const group = (
|
|
108
|
+
<html.div
|
|
109
|
+
ref={rootRef}
|
|
110
|
+
role="toolbar"
|
|
111
|
+
aria-orientation={orientation}
|
|
112
|
+
{...a11y}
|
|
113
|
+
style={[styles.root, orientation === 'vertical' && styles.vertical, wrap && styles.wrap]}
|
|
114
|
+
>
|
|
115
|
+
{children}
|
|
116
|
+
</html.div>
|
|
117
|
+
)
|
|
118
|
+
|
|
60
119
|
return (
|
|
61
|
-
<ToggleGroupContext.Provider
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
120
|
+
<ToggleGroupContext.Provider
|
|
121
|
+
value={{
|
|
122
|
+
value,
|
|
123
|
+
toggle,
|
|
124
|
+
disabled,
|
|
125
|
+
orientation,
|
|
126
|
+
size,
|
|
127
|
+
wrap,
|
|
128
|
+
tabStopValue: roving.tabStopValue,
|
|
129
|
+
register: roving.register,
|
|
130
|
+
onItemFocus: roving.onItemFocus,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{scrolls ? (
|
|
134
|
+
<ScrollArea.Root>
|
|
135
|
+
<ScrollArea.Viewport
|
|
136
|
+
rovingOwner
|
|
137
|
+
style={size === 'small' ? styles.maxRowsSmall(maxRows) : styles.maxRowsDefault(maxRows)}
|
|
138
|
+
>
|
|
139
|
+
<ScrollArea.Content>
|
|
140
|
+
<html.div style={styles.scrollContent}>{group}</html.div>
|
|
141
|
+
</ScrollArea.Content>
|
|
142
|
+
</ScrollArea.Viewport>
|
|
143
|
+
</ScrollArea.Root>
|
|
144
|
+
) : (
|
|
145
|
+
group
|
|
146
|
+
)}
|
|
70
147
|
</ToggleGroupContext.Provider>
|
|
71
148
|
)
|
|
72
149
|
}
|
|
@@ -10,6 +10,12 @@ export interface ToggleGroupContextValue {
|
|
|
10
10
|
disabled: boolean
|
|
11
11
|
orientation: Orientation
|
|
12
12
|
size: ToggleSize
|
|
13
|
+
/** Items wrap onto rows with their own borders (and roving focus). */
|
|
14
|
+
wrap: boolean
|
|
15
|
+
/** The item holding the group's single tab stop (wrap only). */
|
|
16
|
+
tabStopValue: string | null
|
|
17
|
+
register: (itemValue: string, el: HTMLElement) => () => void
|
|
18
|
+
onItemFocus: (itemValue: string) => void
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export const ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {css} from 'react-strict-dom'
|
|
2
|
+
import {ROW_HEIGHT} from '../Toggle/rowHeight'
|
|
2
3
|
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
-
import {radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
5
|
|
|
5
6
|
export const styles = css.create({
|
|
6
7
|
root: {
|
|
@@ -11,6 +12,34 @@ export const styles = css.create({
|
|
|
11
12
|
borderColor: colors.border,
|
|
12
13
|
overflow: 'hidden',
|
|
13
14
|
},
|
|
15
|
+
wrap: {
|
|
16
|
+
display: 'flex',
|
|
17
|
+
flexDirection: 'row',
|
|
18
|
+
flexWrap: 'wrap',
|
|
19
|
+
gap: spacing.xs,
|
|
20
|
+
borderWidth: 0,
|
|
21
|
+
borderRadius: 0,
|
|
22
|
+
overflow: 'visible',
|
|
23
|
+
},
|
|
24
|
+
// the scroll content: room for focus rings to draw
|
|
25
|
+
scrollContent: {
|
|
26
|
+
position: 'relative',
|
|
27
|
+
padding: spacing.xs,
|
|
28
|
+
},
|
|
29
|
+
// shows rows + 0.5 rows so the cut-off row hints at more; shorter content
|
|
30
|
+
// sizes itself (max, not fixed). Row heights match the wrapped Toggles.
|
|
31
|
+
maxRowsSmall: (rows: number) => ({
|
|
32
|
+
maxHeight: {
|
|
33
|
+
default: `calc((${rows} + 0.5) * ${ROW_HEIGHT.small}px + ${rows} * ${spacing.xs} + 2 * ${spacing.xs})`,
|
|
34
|
+
'@media (pointer: coarse)': `calc((${rows} + 0.5) * ${ROW_HEIGHT.coarse}px + ${rows} * ${spacing.xs} + 2 * ${spacing.xs})`,
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
maxRowsDefault: (rows: number) => ({
|
|
38
|
+
maxHeight: {
|
|
39
|
+
default: `calc((${rows} + 0.5) * ${ROW_HEIGHT.default}px + ${rows} * ${spacing.xs} + 2 * ${spacing.xs})`,
|
|
40
|
+
'@media (pointer: coarse)': `calc((${rows} + 0.5) * ${ROW_HEIGHT.coarse}px + ${rows} * ${spacing.xs} + 2 * ${spacing.xs})`,
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
14
43
|
vertical: {
|
|
15
44
|
flexDirection: 'column',
|
|
16
45
|
},
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject} from 'react'
|
|
2
|
+
|
|
3
|
+
const ROW_TOLERANCE = 2
|
|
4
|
+
|
|
5
|
+
function sameOrder(a: string[], b: string[]) {
|
|
6
|
+
return a.length === b.length && a.every((v, i) => v === b[i])
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Roving focus for a wrapping ToggleGroup (WAI-ARIA toolbar pattern): one tab
|
|
11
|
+
* stop — the focused item, else the first pressed one, else the first — with
|
|
12
|
+
* arrow keys in reading order, Up/Down by visual row, Home/End.
|
|
13
|
+
*/
|
|
14
|
+
export function useRovingFocus({
|
|
15
|
+
enabled,
|
|
16
|
+
pressed,
|
|
17
|
+
rootRef,
|
|
18
|
+
}: {
|
|
19
|
+
enabled: boolean
|
|
20
|
+
pressed: string[]
|
|
21
|
+
rootRef: RefObject<HTMLElement | null>
|
|
22
|
+
}) {
|
|
23
|
+
const items = useRef(new Map<string, HTMLElement>())
|
|
24
|
+
const [order, setOrder] = useState<string[]>([])
|
|
25
|
+
const [focusedValue, setFocusedValue] = useState<string | null>(null)
|
|
26
|
+
|
|
27
|
+
const sync = useCallback(() => {
|
|
28
|
+
const entries = [...items.current.entries()].sort(([, a], [, b]) =>
|
|
29
|
+
a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
|
|
30
|
+
)
|
|
31
|
+
const next = entries.map(([v]) => v)
|
|
32
|
+
setOrder((prev) => (sameOrder(prev, next) ? prev : next))
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
const register = useCallback(
|
|
36
|
+
(value: string, el: HTMLElement) => {
|
|
37
|
+
items.current.set(value, el)
|
|
38
|
+
sync()
|
|
39
|
+
return () => {
|
|
40
|
+
if (items.current.get(value) === el) items.current.delete(value)
|
|
41
|
+
sync()
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
[sync],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// a focus holder that unmounted no longer holds the tab stop
|
|
48
|
+
useLayoutEffect(() => {
|
|
49
|
+
if (focusedValue !== null && !order.includes(focusedValue)) setFocusedValue(null)
|
|
50
|
+
}, [order, focusedValue])
|
|
51
|
+
|
|
52
|
+
const tabStopValue = !enabled
|
|
53
|
+
? null
|
|
54
|
+
: focusedValue !== null && order.includes(focusedValue)
|
|
55
|
+
? focusedValue
|
|
56
|
+
: (pressed.find((v) => order.includes(v)) ?? order[0] ?? null)
|
|
57
|
+
|
|
58
|
+
// native listener: RSD's onKeyDown event is a reduced shape (as in Tree)
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const root = rootRef.current
|
|
61
|
+
if (!enabled || !root) return
|
|
62
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
63
|
+
if (e.altKey || e.ctrlKey || e.metaKey) return
|
|
64
|
+
const els = order
|
|
65
|
+
.map((v) => items.current.get(v))
|
|
66
|
+
.filter((el): el is HTMLElement => !!el && !(el as HTMLButtonElement).disabled)
|
|
67
|
+
const i = els.indexOf(e.target as HTMLElement)
|
|
68
|
+
if (i < 0) return
|
|
69
|
+
let target: HTMLElement | undefined
|
|
70
|
+
switch (e.key) {
|
|
71
|
+
case 'ArrowRight':
|
|
72
|
+
target = els[Math.min(i + 1, els.length - 1)]
|
|
73
|
+
break
|
|
74
|
+
case 'ArrowLeft':
|
|
75
|
+
target = els[Math.max(i - 1, 0)]
|
|
76
|
+
break
|
|
77
|
+
case 'Home':
|
|
78
|
+
target = els[0]
|
|
79
|
+
break
|
|
80
|
+
case 'End':
|
|
81
|
+
target = els[els.length - 1]
|
|
82
|
+
break
|
|
83
|
+
case 'ArrowDown':
|
|
84
|
+
case 'ArrowUp': {
|
|
85
|
+
const down = e.key === 'ArrowDown'
|
|
86
|
+
const cur = els[i].getBoundingClientRect()
|
|
87
|
+
const cx = cur.left + cur.width / 2
|
|
88
|
+
const rects = els.map((el) => el.getBoundingClientRect())
|
|
89
|
+
// rows, by distinct top, in the direction of travel
|
|
90
|
+
const tops = [...new Set(rects.map((r) => Math.round(r.top)))].sort((a, b) => a - b)
|
|
91
|
+
const rowIdx = tops.findIndex((t) => Math.abs(t - cur.top) <= ROW_TOLERANCE)
|
|
92
|
+
const nextTop = tops[rowIdx + (down ? 1 : -1)]
|
|
93
|
+
if (nextTop === undefined) break
|
|
94
|
+
let best = Infinity
|
|
95
|
+
rects.forEach((r, k) => {
|
|
96
|
+
if (Math.abs(r.top - nextTop) > ROW_TOLERANCE) return
|
|
97
|
+
const d = Math.abs(r.left + r.width / 2 - cx)
|
|
98
|
+
if (d < best) {
|
|
99
|
+
best = d
|
|
100
|
+
target = els[k]
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
e.preventDefault()
|
|
109
|
+
target?.focus()
|
|
110
|
+
}
|
|
111
|
+
root.addEventListener('keydown', onKeyDown)
|
|
112
|
+
return () => root.removeEventListener('keydown', onKeyDown)
|
|
113
|
+
}, [enabled, order, rootRef])
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
tabStopValue,
|
|
117
|
+
register,
|
|
118
|
+
onItemFocus: setFocusedValue,
|
|
119
|
+
items,
|
|
120
|
+
}
|
|
121
|
+
}
|