@charcuterie/ui 2.10.2 → 2.12.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/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +5 -1
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
- package/dist/Combobox/ComboboxOption.js +1 -1
- package/dist/Combobox/ComboboxOption.js.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.d.ts +9 -1
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.js +11 -5
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
- package/dist/Toolbar/Toolbar.d.ts +156 -0
- package/dist/Toolbar/Toolbar.d.ts.map +1 -0
- package/dist/Toolbar/Toolbar.js +169 -0
- package/dist/Toolbar/Toolbar.js.map +1 -0
- package/dist/Toolbar/ToolbarSlot.d.ts +44 -0
- package/dist/Toolbar/ToolbarSlot.d.ts.map +1 -0
- package/dist/Toolbar/ToolbarSlot.js +69 -0
- package/dist/Toolbar/ToolbarSlot.js.map +1 -0
- package/dist/Toolbar/chooseVisibleCount.d.ts +38 -0
- package/dist/Toolbar/chooseVisibleCount.d.ts.map +1 -0
- package/dist/Toolbar/chooseVisibleCount.js +69 -0
- package/dist/Toolbar/chooseVisibleCount.js.map +1 -0
- package/dist/Toolbar/useToolbarOverflow.d.ts +49 -0
- package/dist/Toolbar/useToolbarOverflow.d.ts.map +1 -0
- package/dist/Toolbar/useToolbarOverflow.js +157 -0
- package/dist/Toolbar/useToolbarOverflow.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/testing/expectAgentDrivable.d.ts.map +1 -1
- package/dist/testing/expectAgentDrivable.js +32 -7
- package/dist/testing/expectAgentDrivable.js.map +1 -1
- package/package.json +2 -2
- package/src/Combobox/Combobox.stories.tsx +80 -1
- package/src/Combobox/Combobox.test.tsx +55 -0
- package/src/Combobox/Combobox.tsx +5 -1
- package/src/Combobox/ComboboxOption.tsx +19 -8
- package/src/Overlay/useAnchoredOverlay.ts +31 -4
- package/src/Toolbar/Toolbar.mdx +126 -0
- package/src/Toolbar/Toolbar.stories.tsx +347 -0
- package/src/Toolbar/Toolbar.test.tsx +286 -0
- package/src/Toolbar/Toolbar.tsx +461 -0
- package/src/Toolbar/ToolbarSlot.tsx +122 -0
- package/src/Toolbar/chooseVisibleCount.test.ts +92 -0
- package/src/Toolbar/chooseVisibleCount.ts +92 -0
- package/src/Toolbar/useToolbarOverflow.ts +229 -0
- package/src/icons.storyHelpers.tsx +23 -0
- package/src/index.ts +8 -0
- package/src/sourceRules.test.ts +7 -1
- package/src/testing/expectAgentDrivable.ts +50 -20
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The collapse rule: how many of a toolbar's items fit, given what
|
|
3
|
+
* they measured.
|
|
4
|
+
*
|
|
5
|
+
* Pure arithmetic, in its own file for the same reason
|
|
6
|
+
* `chooseColumns` is — the rule is a table of numbers anyone can
|
|
7
|
+
* check in Node, and everything that needs a browser is quarantined
|
|
8
|
+
* in `useToolbarOverflow`.
|
|
9
|
+
*
|
|
10
|
+
* ## Priority is the array order
|
|
11
|
+
*
|
|
12
|
+
* `itemInlineSizes` is in **priority order, highest first**, which
|
|
13
|
+
* is also paint order: the toolbar collapses from the end. One axis
|
|
14
|
+
* rather than two, because a separate `priority` number and a
|
|
15
|
+
* separate display order is a pair a caller can put out of step,
|
|
16
|
+
* and neither mux-magic nor plex-channels has *any* ordering — they
|
|
17
|
+
* hardcode which controls live in the bar and which live in the
|
|
18
|
+
* popover, at every width.
|
|
19
|
+
*
|
|
20
|
+
* ## Why the trigger's width is an input
|
|
21
|
+
*
|
|
22
|
+
* The overflow trigger only exists once something has collapsed, so
|
|
23
|
+
* the question is not "how many fit" but "how many fit **beside a
|
|
24
|
+
* trigger** — unless all of them fit without one". Ignoring that is
|
|
25
|
+
* how an overflow control ends up wrapping to a second line at
|
|
26
|
+
* exactly the width where it first appears.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const chooseVisibleCount = ({
|
|
30
|
+
availableInlineSize,
|
|
31
|
+
gapInlineSize,
|
|
32
|
+
itemInlineSizes,
|
|
33
|
+
triggerInlineSize,
|
|
34
|
+
}: {
|
|
35
|
+
/** The room the bar has, in CSS px. */
|
|
36
|
+
availableInlineSize: number
|
|
37
|
+
/** The row's `column-gap`, in CSS px. */
|
|
38
|
+
gapInlineSize: number
|
|
39
|
+
/** Each item's measured inline size, priority order, in CSS px. */
|
|
40
|
+
itemInlineSizes: readonly number[]
|
|
41
|
+
/** The overflow trigger's inline size, in CSS px. */
|
|
42
|
+
triggerInlineSize: number
|
|
43
|
+
}): number => {
|
|
44
|
+
const itemCount = itemInlineSizes.length
|
|
45
|
+
|
|
46
|
+
if (itemCount === 0) {
|
|
47
|
+
return 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Nothing measured yet — show everything.
|
|
52
|
+
*
|
|
53
|
+
* The safe direction to be wrong in for one frame, and the same
|
|
54
|
+
* one `useAdaptiveColumns` argues from the other end: a bar that
|
|
55
|
+
* starts whole and collapses over-draws, where a bar that starts
|
|
56
|
+
* collapsed hides controls that were there. It is also what makes
|
|
57
|
+
* the first measurement possible at all, because an item that was
|
|
58
|
+
* never mounted has no width to read.
|
|
59
|
+
*/
|
|
60
|
+
if (availableInlineSize <= 0) {
|
|
61
|
+
return itemCount
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const measure = (count: number, extra: number) =>
|
|
65
|
+
itemInlineSizes
|
|
66
|
+
.slice(0, count)
|
|
67
|
+
.reduce((total, size) => total + size, 0) +
|
|
68
|
+
gapInlineSize *
|
|
69
|
+
Math.max(count + (extra > 0 ? 1 : 0) - 1, 0) +
|
|
70
|
+
extra
|
|
71
|
+
|
|
72
|
+
if (measure(itemCount, 0) <= availableInlineSize) {
|
|
73
|
+
return itemCount
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Everything below here has an overflow trigger in the row, so
|
|
77
|
+
// every candidate pays for it. Counting down rather than up
|
|
78
|
+
// because the answer is usually near the top.
|
|
79
|
+
for (let count = itemCount - 1; count > 0; count -= 1) {
|
|
80
|
+
if (
|
|
81
|
+
measure(count, triggerInlineSize) <=
|
|
82
|
+
availableInlineSize
|
|
83
|
+
) {
|
|
84
|
+
return count
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Not even one item and the trigger fit. Everything collapses;
|
|
89
|
+
// the trigger alone may still overflow, and a bar narrower than
|
|
90
|
+
// one control is a layout problem no collapse rule can solve.
|
|
91
|
+
return 0
|
|
92
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { RefObject } from "react"
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react"
|
|
8
|
+
|
|
9
|
+
import { chooseVisibleCount } from "./chooseVisibleCount.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The measuring half of progressive overflow — `chooseVisibleCount`
|
|
13
|
+
* is the decision, this is the tape measure.
|
|
14
|
+
*
|
|
15
|
+
* ## Measured, not breakpointed
|
|
16
|
+
*
|
|
17
|
+
* mux-magic collapses at a hardcoded `480px` and plex-channels at a
|
|
18
|
+
* hardcoded `760px`, and both numbers are wrong the moment a label
|
|
19
|
+
* gets longer, the density axis changes, or a rail takes half the
|
|
20
|
+
* row. A measured bar has no number to be wrong: it asks the
|
|
21
|
+
* elements how wide they actually are, in the font and density they
|
|
22
|
+
* are actually rendered in.
|
|
23
|
+
*
|
|
24
|
+
* ## One instance, and cached widths
|
|
25
|
+
*
|
|
26
|
+
* An item that has collapsed is **not in the DOM** — that is the
|
|
27
|
+
* whole point, against a mechanism that renders every action twice
|
|
28
|
+
* and hides one with a CSS specificity coin-flip. So its width
|
|
29
|
+
* cannot be re-read while it is away, and it is remembered from
|
|
30
|
+
* when it was last in the bar instead.
|
|
31
|
+
*
|
|
32
|
+
* The first paint is what seeds the cache: every item starts in the
|
|
33
|
+
* bar (`chooseVisibleCount` returns all of them for an unmeasured
|
|
34
|
+
* container), gets measured, and the row collapses **before paint**
|
|
35
|
+
* because the measuring runs in a layout effect. Any item whose
|
|
36
|
+
* width is still unknown forces one more all-visible pass rather
|
|
37
|
+
* than being guessed at — one extra layout pass, and it converges,
|
|
38
|
+
* because a mounted item always measures.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ **The measured element must not be sized by its own contents.**
|
|
41
|
+
* If the bar shrink-wraps its items, collapsing one narrows the box,
|
|
42
|
+
* which collapses another — a loop with no error anywhere.
|
|
43
|
+
* `Toolbar`'s root is `flex-1 min-w-0` for exactly this reason, the
|
|
44
|
+
* same trap `useAdaptiveColumns` documents about a container capped
|
|
45
|
+
* by its own answer.
|
|
46
|
+
*/
|
|
47
|
+
export const useToolbarOverflow = ({
|
|
48
|
+
itemKeys,
|
|
49
|
+
}: {
|
|
50
|
+
/** Priority order, highest first. */
|
|
51
|
+
itemKeys: readonly string[]
|
|
52
|
+
}): {
|
|
53
|
+
containerRef: RefObject<HTMLDivElement | null>
|
|
54
|
+
/** Callback ref for each item's box. `null` on unmount. */
|
|
55
|
+
trackItem: (
|
|
56
|
+
key: string,
|
|
57
|
+
element: HTMLElement | null,
|
|
58
|
+
) => void
|
|
59
|
+
/** Callback ref for the overflow trigger's box. */
|
|
60
|
+
trackTrigger: (element: HTMLElement | null) => void
|
|
61
|
+
visibleCount: number
|
|
62
|
+
} => {
|
|
63
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
64
|
+
|
|
65
|
+
const itemElements = useRef(
|
|
66
|
+
new Map<string, HTMLElement>(),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const triggerElement = useRef<HTMLElement | null>(null)
|
|
70
|
+
|
|
71
|
+
const itemInlineSizes = useRef(new Map<string, number>())
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Zero until the trigger has been in the row once.
|
|
75
|
+
*
|
|
76
|
+
* A bar that has never overflowed has no trigger to measure, and
|
|
77
|
+
* the very first decision to collapse is taken without one. It
|
|
78
|
+
* errs the only safe way: with the trigger costed at nothing, the
|
|
79
|
+
* chosen count is at worst one item too many, which resolves on
|
|
80
|
+
* the next pass once the trigger exists and measures.
|
|
81
|
+
*/
|
|
82
|
+
const triggerInlineSize = useRef(0)
|
|
83
|
+
|
|
84
|
+
const [visibleCount, setVisibleCount] = useState(
|
|
85
|
+
itemKeys.length,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Written during render, and deliberately.
|
|
90
|
+
*
|
|
91
|
+
* `itemKeys` is a fresh array on every render of the caller, so
|
|
92
|
+
* depending on it would give `measure` a new identity each time
|
|
93
|
+
* — which re-subscribes the `ResizeObserver` on every render. A
|
|
94
|
+
* ref is a pure cache here: a render React throws away and
|
|
95
|
+
* re-runs writes the same value, and nothing observable happens.
|
|
96
|
+
* Same reasoning as `useClonedChild`'s `useShallowStable`.
|
|
97
|
+
*/
|
|
98
|
+
const itemKeysRef = useRef(itemKeys)
|
|
99
|
+
|
|
100
|
+
itemKeysRef.current = itemKeys
|
|
101
|
+
|
|
102
|
+
const measure = useCallback(() => {
|
|
103
|
+
const container = containerRef.current
|
|
104
|
+
|
|
105
|
+
if (!container) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const keys = itemKeysRef.current
|
|
110
|
+
|
|
111
|
+
for (const [key, element] of itemElements.current) {
|
|
112
|
+
itemInlineSizes.current.set(
|
|
113
|
+
key,
|
|
114
|
+
element.getBoundingClientRect().width,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (triggerElement.current) {
|
|
119
|
+
triggerInlineSize.current =
|
|
120
|
+
triggerElement.current.getBoundingClientRect().width
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const hasEveryWidth = keys.every((key) =>
|
|
124
|
+
itemInlineSizes.current.has(key),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const next = hasEveryWidth
|
|
128
|
+
? chooseVisibleCount({
|
|
129
|
+
availableInlineSize: container.clientWidth,
|
|
130
|
+
gapInlineSize:
|
|
131
|
+
Number.parseFloat(
|
|
132
|
+
getComputedStyle(container).columnGap,
|
|
133
|
+
) || 0,
|
|
134
|
+
itemInlineSizes: keys.map(
|
|
135
|
+
(key) => itemInlineSizes.current.get(key) ?? 0,
|
|
136
|
+
),
|
|
137
|
+
triggerInlineSize: triggerInlineSize.current,
|
|
138
|
+
})
|
|
139
|
+
: // An item that has never been mounted has no width. Show
|
|
140
|
+
// everything for one pass so it can be measured, rather
|
|
141
|
+
// than guessing a number and collapsing around it.
|
|
142
|
+
keys.length
|
|
143
|
+
|
|
144
|
+
setVisibleCount((current) =>
|
|
145
|
+
current === next ? current : next,
|
|
146
|
+
)
|
|
147
|
+
}, [])
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A layout effect, not an effect: it runs after the DOM is
|
|
151
|
+
* written and **before the browser paints**, so the collapse that
|
|
152
|
+
* follows the first measurement is never a visible flash of a bar
|
|
153
|
+
* too wide for its box.
|
|
154
|
+
*
|
|
155
|
+
* No dependency array — it re-measures after every render, which
|
|
156
|
+
* is what keeps a changed label or a new item honest. It is cheap
|
|
157
|
+
* and it converges: `setVisibleCount` bails on an unchanged
|
|
158
|
+
* answer, so a stable bar renders once and stops.
|
|
159
|
+
*/
|
|
160
|
+
useLayoutEffect(measure)
|
|
161
|
+
|
|
162
|
+
useLayoutEffect(() => {
|
|
163
|
+
const container = containerRef.current
|
|
164
|
+
|
|
165
|
+
if (!container) {
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Only when the **box** changed.
|
|
171
|
+
*
|
|
172
|
+
* A `ResizeObserver` delivers a record on observe and again for
|
|
173
|
+
* every content reflow inside the frame it fired in, and
|
|
174
|
+
* measuring from each one is what raises chromium's
|
|
175
|
+
* "ResizeObserver loop completed with undelivered
|
|
176
|
+
* notifications" — the callback doing work that schedules more
|
|
177
|
+
* observation. The item-side re-measure is already covered by
|
|
178
|
+
* the layout effect above; this subscription exists for one
|
|
179
|
+
* question, which is whether the container got wider or
|
|
180
|
+
* narrower.
|
|
181
|
+
*/
|
|
182
|
+
let lastInlineSize = -1
|
|
183
|
+
|
|
184
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
185
|
+
const inlineSize =
|
|
186
|
+
entry?.contentRect.width ??
|
|
187
|
+
container.getBoundingClientRect().width
|
|
188
|
+
|
|
189
|
+
if (inlineSize === lastInlineSize) {
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
lastInlineSize = inlineSize
|
|
194
|
+
|
|
195
|
+
measure()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
observer.observe(container)
|
|
199
|
+
|
|
200
|
+
return () => {
|
|
201
|
+
observer.disconnect()
|
|
202
|
+
}
|
|
203
|
+
}, [measure])
|
|
204
|
+
|
|
205
|
+
const trackItem = useCallback(
|
|
206
|
+
(key: string, element: HTMLElement | null) => {
|
|
207
|
+
if (element) {
|
|
208
|
+
itemElements.current.set(key, element)
|
|
209
|
+
} else {
|
|
210
|
+
itemElements.current.delete(key)
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
[],
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
const trackTrigger = useCallback(
|
|
217
|
+
(element: HTMLElement | null) => {
|
|
218
|
+
triggerElement.current = element
|
|
219
|
+
},
|
|
220
|
+
[],
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
containerRef,
|
|
225
|
+
trackItem,
|
|
226
|
+
trackTrigger,
|
|
227
|
+
visibleCount,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -168,3 +168,26 @@ export const DotIcon = (): ReactNode => (
|
|
|
168
168
|
<circle cx="12" cy="12" r="4" />
|
|
169
169
|
</svg>
|
|
170
170
|
)
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The overflow glyph — three dots, the ⋮ every header in the fleet
|
|
174
|
+
* draws as a **character**. Drawn rather than typed, because
|
|
175
|
+
* `⋮` renders as nothing where the font lacks it: this sandbox's
|
|
176
|
+
* chromium, the kiosk image, the ePaper build
|
|
177
|
+
* ([decision](../../../docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md)).
|
|
178
|
+
* It lives here, in the stories, because the library ships no icons.
|
|
179
|
+
*/
|
|
180
|
+
export const MoreIcon = (): ReactNode => (
|
|
181
|
+
<svg
|
|
182
|
+
{...iconProps}
|
|
183
|
+
aria-hidden="true"
|
|
184
|
+
fill="currentColor"
|
|
185
|
+
stroke="none"
|
|
186
|
+
>
|
|
187
|
+
<circle cx="12" cy="5" r="1.75" />
|
|
188
|
+
|
|
189
|
+
<circle cx="12" cy="12" r="1.75" />
|
|
190
|
+
|
|
191
|
+
<circle cx="12" cy="19" r="1.75" />
|
|
192
|
+
</svg>
|
|
193
|
+
)
|
package/src/index.ts
CHANGED
|
@@ -262,6 +262,14 @@ export type {
|
|
|
262
262
|
export { ToastRegion } from "./Toast/ToastRegion.tsx"
|
|
263
263
|
export type { ToastStatus } from "./Toast/toastLifecycle.ts"
|
|
264
264
|
export { toastTransitions } from "./Toast/toastLifecycle.ts"
|
|
265
|
+
export { chooseVisibleCount } from "./Toolbar/chooseVisibleCount.ts"
|
|
266
|
+
export type {
|
|
267
|
+
ToolbarAction,
|
|
268
|
+
ToolbarControl,
|
|
269
|
+
ToolbarItem,
|
|
270
|
+
ToolbarProps,
|
|
271
|
+
} from "./Toolbar/Toolbar.tsx"
|
|
272
|
+
export { Toolbar } from "./Toolbar/Toolbar.tsx"
|
|
265
273
|
export type { TooltipProps } from "./Tooltip/Tooltip.tsx"
|
|
266
274
|
export { Tooltip } from "./Tooltip/Tooltip.tsx"
|
|
267
275
|
export { toClassName } from "./toClassName.ts"
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -358,7 +358,13 @@ test("the barrel is the only place components are re-exported", async () => {
|
|
|
358
358
|
// and `shellContext.ts` are `Shell`'s members and stay out of
|
|
359
359
|
// this count by the `<Name>/<Name>.tsx` rule, exactly as
|
|
360
360
|
// `mediaStatus.ts` does.
|
|
361
|
-
|
|
361
|
+
// `Toolbar` unifies the fourth-largest duplication — four repos
|
|
362
|
+
// with a toolbar-and-overflow, three of them collapsing by
|
|
363
|
+
// rendering every action twice: +1 -> 43. `ToolbarSlot.tsx`,
|
|
364
|
+
// `useToolbarOverflow.ts` and `chooseVisibleCount.ts` are its
|
|
365
|
+
// members and stay out of this count by the `<Name>/<Name>.tsx`
|
|
366
|
+
// rule.
|
|
367
|
+
expect(componentNames.length).toBe(43)
|
|
362
368
|
|
|
363
369
|
for (const name of componentNames) {
|
|
364
370
|
expect(barrel).toContain(`export { ${name} }`)
|
|
@@ -86,24 +86,51 @@ const COMPOSITE_MEMBER_ROLES = new Set([
|
|
|
86
86
|
"treeitem",
|
|
87
87
|
])
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* A toolbar's members are ordinary controls.
|
|
91
|
+
*
|
|
92
|
+
* Every other composite here names its members by role — a `tab`, an
|
|
93
|
+
* `option`, a `menuitem`. A `role="toolbar"` names none of them: the
|
|
94
|
+
* APG's toolbar holds plain buttons, toggle buttons, a radio group,
|
|
95
|
+
* a menu button, and it is the *container* that declares the
|
|
96
|
+
* pattern. So `toolbar` sat in `COMPOSITE_ROLES` from M4 and was
|
|
97
|
+
* unreachable, because nothing inside one ever carried a member
|
|
98
|
+
* role — a roving toolbar was rejected outright with "has a negative
|
|
99
|
+
* tabindex", which is the assertion this helper makes for a
|
|
100
|
+
* *standalone* button and precisely the wrong one here.
|
|
101
|
+
*/
|
|
102
|
+
const TOOLBAR_MEMBER_SELECTOR =
|
|
103
|
+
"a[href], button, input, select, textarea, [tabindex]"
|
|
104
|
+
|
|
105
|
+
const getGroupMembers = (group: Element) =>
|
|
106
|
+
group.getAttribute("role") === "toolbar"
|
|
107
|
+
? Array.from(
|
|
108
|
+
group.querySelectorAll<HTMLElement>(
|
|
109
|
+
TOOLBAR_MEMBER_SELECTOR,
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
: Array.from(
|
|
113
|
+
group.querySelectorAll<HTMLElement>("[role]"),
|
|
114
|
+
).filter((member) =>
|
|
115
|
+
COMPOSITE_MEMBER_ROLES.has(
|
|
116
|
+
member.getAttribute("role") ?? "",
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
89
120
|
const getCompositeGroup = (element: Element) => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
-
) {
|
|
95
|
-
return null
|
|
96
|
-
}
|
|
121
|
+
const isMemberByRole = COMPOSITE_MEMBER_ROLES.has(
|
|
122
|
+
element.getAttribute("role") ?? "",
|
|
123
|
+
)
|
|
97
124
|
|
|
98
125
|
let current = element.parentElement
|
|
99
126
|
|
|
100
127
|
while (current) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
const groupRole = current.getAttribute("role") ?? ""
|
|
129
|
+
|
|
130
|
+
if (COMPOSITE_ROLES.has(groupRole)) {
|
|
131
|
+
return groupRole === "toolbar" || isMemberByRole
|
|
132
|
+
? current
|
|
133
|
+
: null
|
|
107
134
|
}
|
|
108
135
|
|
|
109
136
|
current = current.parentElement
|
|
@@ -225,16 +252,19 @@ export const expectAgentDrivable = (
|
|
|
225
252
|
// *group* reachable, exactly once". Zero tab stops strands the
|
|
226
253
|
// whole widget; several put every member back in the tab
|
|
227
254
|
// order, which is the pattern not being implemented at all.
|
|
228
|
-
const tabStops =
|
|
229
|
-
group.querySelectorAll("[role]"),
|
|
230
|
-
).filter(
|
|
255
|
+
const tabStops = getGroupMembers(group).filter(
|
|
231
256
|
(member) =>
|
|
232
|
-
COMPOSITE_MEMBER_ROLES.has(
|
|
233
|
-
member.getAttribute("role") ?? "",
|
|
234
|
-
) &&
|
|
235
257
|
!member.hasAttribute("disabled") &&
|
|
236
258
|
member.getAttribute("aria-disabled") !== "true" &&
|
|
237
|
-
|
|
259
|
+
// floating-ui plants `aria-hidden` focus guards
|
|
260
|
+
// (`<span tabindex="0">`) either side of an overlay's
|
|
261
|
+
// reference element, and a toolbar holding an overflow
|
|
262
|
+
// trigger therefore contains two. They are not members of
|
|
263
|
+
// anything — a screen reader and an agent both skip them —
|
|
264
|
+
// and counting them reported a correct roving group as
|
|
265
|
+
// having three tab stops.
|
|
266
|
+
!getIsHiddenFromAgents(member) &&
|
|
267
|
+
member.tabIndex >= 0,
|
|
238
268
|
)
|
|
239
269
|
|
|
240
270
|
if (tabStops.length !== 1) {
|