@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.
Files changed (82) hide show
  1. package/dist/{Table-JTgcR_Fh.js → Table-CUp12BKZ.js} +78 -75
  2. package/dist/Table-CUp12BKZ.js.map +1 -0
  3. package/dist/components/ActionBar/ActionBar.d.ts +7 -0
  4. package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
  5. package/dist/components/ActionBar/ActionBar.meta.d.ts.map +1 -1
  6. package/dist/components/ActionBar/ActionBar.stories.d.ts +3 -0
  7. package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
  8. package/dist/components/ActionBar/styles.css.d.ts +25 -2
  9. package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
  10. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  11. package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -1
  12. package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
  13. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  14. package/dist/components/Drawer/DrawerScroll.stories.d.ts +13 -0
  15. package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -0
  16. package/dist/components/Drawer/useSwipeDismiss.d.ts +7 -2
  17. package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
  18. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  19. package/dist/components/Popover/Popover.d.ts.map +1 -1
  20. package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
  21. package/dist/components/Popover/PopoverLayerContext.d.ts +10 -0
  22. package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -0
  23. package/dist/components/Popover/PopoverLayers.stories.d.ts +13 -0
  24. package/dist/components/Popover/PopoverLayers.stories.d.ts.map +1 -0
  25. package/dist/components/ScrollArea/ScrollArea.d.ts +8 -2
  26. package/dist/components/ScrollArea/ScrollArea.d.ts.map +1 -1
  27. package/dist/components/Select/Select.d.ts.map +1 -1
  28. package/dist/components/Select/Select.meta.d.ts.map +1 -1
  29. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  30. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  31. package/dist/components/Toggle/rowHeight.d.ts +17 -0
  32. package/dist/components/Toggle/rowHeight.d.ts.map +1 -0
  33. package/dist/components/Toggle/styles.css.d.ts +10 -0
  34. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  35. package/dist/components/ToggleGroup/ToggleGroup.d.ts +7 -1
  36. package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
  37. package/dist/components/ToggleGroup/ToggleGroup.meta.d.ts.map +1 -1
  38. package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts +4 -0
  39. package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts.map +1 -1
  40. package/dist/components/ToggleGroup/ToggleGroupContext.d.ts +6 -0
  41. package/dist/components/ToggleGroup/ToggleGroupContext.d.ts.map +1 -1
  42. package/dist/components/ToggleGroup/styles.css.d.ts +19 -0
  43. package/dist/components/ToggleGroup/styles.css.d.ts.map +1 -1
  44. package/dist/components/ToggleGroup/useRovingFocus.d.ts +17 -0
  45. package/dist/components/ToggleGroup/useRovingFocus.d.ts.map +1 -0
  46. package/dist/index.css +1 -1
  47. package/dist/index.js +2987 -2682
  48. package/dist/index.js.map +1 -1
  49. package/dist/table.js +1 -1
  50. package/package.json +2 -2
  51. package/src/components/ActionBar/ActionBar.meta.ts +7 -0
  52. package/src/components/ActionBar/ActionBar.stories.tsx +95 -3
  53. package/src/components/ActionBar/ActionBar.tsx +12 -1
  54. package/src/components/ActionBar/styles.css.ts +34 -2
  55. package/src/components/Combobox/Combobox.meta.ts +6 -0
  56. package/src/components/Combobox/Combobox.tsx +5 -0
  57. package/src/components/DetailPanel/DetailPanel.meta.ts +6 -0
  58. package/src/components/Drawer/Drawer.meta.ts +1 -1
  59. package/src/components/Drawer/Drawer.tsx +12 -2
  60. package/src/components/Drawer/DrawerScroll.stories.tsx +292 -0
  61. package/src/components/Drawer/useSwipeDismiss.ts +53 -8
  62. package/src/components/Menu/useMenuRoot.ts +5 -1
  63. package/src/components/Popover/Popover.meta.ts +7 -0
  64. package/src/components/Popover/Popover.tsx +30 -24
  65. package/src/components/Popover/PopoverLayerContext.ts +20 -0
  66. package/src/components/Popover/PopoverLayers.stories.tsx +234 -0
  67. package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
  68. package/src/components/ScrollArea/ScrollArea.tsx +12 -6
  69. package/src/components/Select/Select.meta.ts +6 -0
  70. package/src/components/Select/Select.tsx +8 -1
  71. package/src/components/Select/useSelectRoot.ts +6 -2
  72. package/src/components/Toggle/Toggle.meta.ts +1 -1
  73. package/src/components/Toggle/Toggle.tsx +19 -2
  74. package/src/components/Toggle/rowHeight.ts +12 -0
  75. package/src/components/Toggle/styles.css.ts +12 -0
  76. package/src/components/ToggleGroup/ToggleGroup.meta.ts +11 -1
  77. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +156 -0
  78. package/src/components/ToggleGroup/ToggleGroup.tsx +87 -10
  79. package/src/components/ToggleGroup/ToggleGroupContext.ts +6 -0
  80. package/src/components/ToggleGroup/styles.css.ts +30 -1
  81. package/src/components/ToggleGroup/useRovingFocus.ts +121 -0
  82. 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
- isGrouped && styles.grouped,
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 value={{value, toggle, disabled, orientation, size}}>
62
- <html.div
63
- role="toolbar"
64
- aria-orientation={orientation}
65
- {...a11y}
66
- style={[styles.root, orientation === 'vertical' && styles.vertical]}
67
- >
68
- {children}
69
- </html.div>
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
+ }