@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.
Files changed (51) hide show
  1. package/dist/Combobox/Combobox.d.ts.map +1 -1
  2. package/dist/Combobox/Combobox.js +5 -1
  3. package/dist/Combobox/Combobox.js.map +1 -1
  4. package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
  5. package/dist/Combobox/ComboboxOption.js +1 -1
  6. package/dist/Combobox/ComboboxOption.js.map +1 -1
  7. package/dist/Overlay/useAnchoredOverlay.d.ts +9 -1
  8. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
  9. package/dist/Overlay/useAnchoredOverlay.js +11 -5
  10. package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
  11. package/dist/Toolbar/Toolbar.d.ts +156 -0
  12. package/dist/Toolbar/Toolbar.d.ts.map +1 -0
  13. package/dist/Toolbar/Toolbar.js +169 -0
  14. package/dist/Toolbar/Toolbar.js.map +1 -0
  15. package/dist/Toolbar/ToolbarSlot.d.ts +44 -0
  16. package/dist/Toolbar/ToolbarSlot.d.ts.map +1 -0
  17. package/dist/Toolbar/ToolbarSlot.js +69 -0
  18. package/dist/Toolbar/ToolbarSlot.js.map +1 -0
  19. package/dist/Toolbar/chooseVisibleCount.d.ts +38 -0
  20. package/dist/Toolbar/chooseVisibleCount.d.ts.map +1 -0
  21. package/dist/Toolbar/chooseVisibleCount.js +69 -0
  22. package/dist/Toolbar/chooseVisibleCount.js.map +1 -0
  23. package/dist/Toolbar/useToolbarOverflow.d.ts +49 -0
  24. package/dist/Toolbar/useToolbarOverflow.d.ts.map +1 -0
  25. package/dist/Toolbar/useToolbarOverflow.js +157 -0
  26. package/dist/Toolbar/useToolbarOverflow.js.map +1 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/testing/expectAgentDrivable.d.ts.map +1 -1
  32. package/dist/testing/expectAgentDrivable.js +32 -7
  33. package/dist/testing/expectAgentDrivable.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/Combobox/Combobox.stories.tsx +80 -1
  36. package/src/Combobox/Combobox.test.tsx +55 -0
  37. package/src/Combobox/Combobox.tsx +5 -1
  38. package/src/Combobox/ComboboxOption.tsx +19 -8
  39. package/src/Overlay/useAnchoredOverlay.ts +31 -4
  40. package/src/Toolbar/Toolbar.mdx +126 -0
  41. package/src/Toolbar/Toolbar.stories.tsx +347 -0
  42. package/src/Toolbar/Toolbar.test.tsx +286 -0
  43. package/src/Toolbar/Toolbar.tsx +461 -0
  44. package/src/Toolbar/ToolbarSlot.tsx +122 -0
  45. package/src/Toolbar/chooseVisibleCount.test.ts +92 -0
  46. package/src/Toolbar/chooseVisibleCount.ts +92 -0
  47. package/src/Toolbar/useToolbarOverflow.ts +229 -0
  48. package/src/icons.storyHelpers.tsx +23 -0
  49. package/src/index.ts +8 -0
  50. package/src/sourceRules.test.ts +7 -1
  51. package/src/testing/expectAgentDrivable.ts +50 -20
@@ -0,0 +1,461 @@
1
+ import {
2
+ selectTabIndex,
3
+ useRovingFocus,
4
+ useVisibility,
5
+ } from "@charcuterie/logic"
6
+ import type { ControlSize } from "@charcuterie/tokens"
7
+ import type { ReactElement, ReactNode } from "react"
8
+ import { useCallback, useEffect, useRef } from "react"
9
+
10
+ import { Button } from "../Button/Button.tsx"
11
+ import { IconButton } from "../IconButton/IconButton.tsx"
12
+ import { Menu } from "../Menu/Menu.tsx"
13
+ import { Popover } from "../Popover/Popover.tsx"
14
+ import { toClassName } from "../toClassName.ts"
15
+ import { ToolbarSlot } from "./ToolbarSlot.tsx"
16
+ import { useToolbarOverflow } from "./useToolbarOverflow.ts"
17
+
18
+ /**
19
+ * Something the toolbar renders itself: a button that **does**
20
+ * something. The same shape as `MenuItem`, because a collapsed one
21
+ * becomes exactly that.
22
+ */
23
+ export type ToolbarAction = {
24
+ icon?: ReactNode
25
+ isDisabled?: boolean
26
+ key: string
27
+ /** The accessible name, and the label when it collapses. */
28
+ label: string
29
+ onSelect: () => void
30
+ /**
31
+ * The default kind, and optional so the common
32
+ * `{ key, label, onSelect }` still type-checks. Present only to
33
+ * discriminate this from a `control`.
34
+ */
35
+ type?: "action"
36
+ }
37
+
38
+ /**
39
+ * Something the **app** renders: a `Switch`, a `SegmentedControl`,
40
+ * a `ColorSchemeSwitcher` — a control with state, not an action.
41
+ *
42
+ * Exactly one instance of `element` is mounted at any width. It
43
+ * moves between the bar and the overflow panel; it is never
44
+ * rendered in both and hidden in one.
45
+ */
46
+ export type ToolbarControl = {
47
+ element: ReactElement
48
+ key: string
49
+ /**
50
+ * Optional row label, used **only** in the overflow panel and
51
+ * only when the control does not already show its own text. A
52
+ * `Switch` renders its label; an icon-only toggle does not.
53
+ */
54
+ label?: string
55
+ type: "control"
56
+ }
57
+
58
+ export type ToolbarItem = ToolbarAction | ToolbarControl
59
+
60
+ type ToolbarSharedProps = {
61
+ className?: string
62
+ /**
63
+ * **Initial** only, and the toolbar owns it from then on — the
64
+ * same uncontrolled contract as every other component here. It
65
+ * exists so a story (and a screenshot) can show the panel open;
66
+ * an app almost never passes it.
67
+ */
68
+ isOverflowVisible?: boolean
69
+ /**
70
+ * Required, and it becomes `aria-label`. A `role="toolbar"` takes
71
+ * **no accessible name from its content** — the same trap
72
+ * `Popover`'s `heading` and `Spinner`'s label exist for. mux-magic
73
+ * gets this one right and then gives the toolbar nothing else.
74
+ */
75
+ label: string
76
+ /**
77
+ * The overflow trigger's glyph. **No default** — the library
78
+ * ships no icons, and `⋮` renders as nothing where the font lacks
79
+ * it. With no icon the trigger is a text button reading
80
+ * `overflowLabel`, which works everywhere.
81
+ */
82
+ overflowIcon?: ReactNode
83
+ /**
84
+ * Names the overflow trigger, and through it the panel it opens —
85
+ * a menu is named by its trigger, and `Popover`'s `aria-label`
86
+ * takes the same string.
87
+ */
88
+ overflowLabel?: string
89
+ size?: ControlSize
90
+ }
91
+
92
+ /**
93
+ * **The overflow's role is a type, not a flag.**
94
+ *
95
+ * `role="menu"` permits only `menuitem`, `menuitemradio`,
96
+ * `menuitemcheckbox`, `group` and `separator` — so a header's
97
+ * scheme switcher inside one is invalid, which is what
98
+ * plex-channels ships today. The fix is not a runtime warning and
99
+ * not a boolean: with `overflow="menu"` the item type narrows to
100
+ * `ToolbarAction`, and a `control` in the array is a **compile
101
+ * error**. Mixed content declares `overflow="panel"` and gets a
102
+ * `Popover` — `role="dialog"`, `aria-haspopup="dialog"` — where a
103
+ * switch is perfectly legal content.
104
+ *
105
+ * Same instinct as
106
+ * [`Menu`'s items being a discriminated union](../../../../docs/decisions/2026-08-05-menu-items-is-a-discriminated-union.md):
107
+ * the kinds are told apart by the type system, in the data.
108
+ */
109
+ export type ToolbarProps = ToolbarSharedProps &
110
+ (
111
+ | {
112
+ items: readonly ToolbarAction[]
113
+ overflow: "menu"
114
+ }
115
+ | {
116
+ items: readonly ToolbarItem[]
117
+ overflow: "panel"
118
+ }
119
+ )
120
+
121
+ /**
122
+ * The roving group's last member. Not a key any item can collide
123
+ * with, because a caller's keys are its own strings and this one
124
+ * is namespaced.
125
+ */
126
+ const OVERFLOW_KEY = "charcuterie-toolbar-overflow"
127
+
128
+ const isToolbarAction = (
129
+ item: ToolbarItem,
130
+ ): item is ToolbarAction => item.type !== "control"
131
+
132
+ /**
133
+ * A row of controls that collapses into an overflow when it runs
134
+ * out of room — the fleet's fourth-largest duplication, and the one
135
+ * where the copies are worst.
136
+ *
137
+ * Four repos have a toolbar-with-overflow; mux-magic's `PageHeader`
138
+ * is the original and plex-channels' `Header` says in its own
139
+ * comments that it copied it. What they copied:
140
+ *
141
+ * - **`role="toolbar"` with no toolbar keyboard behaviour.** Six
142
+ * sequential tab stops where the APG requires one, with the
143
+ * arrow keys moving between members. Here that is `RovingFocus`,
144
+ * the same kind `Menu`, `Tabs` and `SegmentedControl` use, and
145
+ * the tab stop is read from `selectTabIndex` rather than
146
+ * restated.
147
+ * - **Triggers that advertise nothing.** No `aria-expanded`, no
148
+ * `aria-haspopup`, no `aria-controls` anywhere in the file. Here
149
+ * they come from `useRole` inside `Menu`/`Popover`, which cannot
150
+ * be forgotten because nothing writes them by hand.
151
+ * - **No focus management.** `Menu` moves focus to the first item
152
+ * on opening and `FloatingFocusManager` returns it to the
153
+ * trigger on close.
154
+ * - **Collapse by DOM duplication.** Both repos render every
155
+ * action twice and hide one set with a media query — mux-magic's
156
+ * is a documented CSS specificity coin-flip ("source order
157
+ * wins"). Every action here is mounted **exactly once** and
158
+ * moves.
159
+ * - **Nothing decides what collapses.** Two hardcoded media
160
+ * queries at 480/481px, and the ⋮ shows at every width, so it is
161
+ * not progressive collapse at all. Here the row is measured, the
162
+ * items are in priority order, and the trigger exists only when
163
+ * something actually overflowed.
164
+ *
165
+ * The toggles' missing `aria-pressed` is the one defect this
166
+ * component does not fix, because it cannot: a `control`'s state is
167
+ * the control's own, and `Switch` has carried `role="switch"` since
168
+ * M6a. The bar's job is to hold it once.
169
+ *
170
+ * ## Priority is the array order
171
+ *
172
+ * `items[0]` is the last thing to collapse. One axis, and it is
173
+ * also paint order — see `chooseVisibleCount`.
174
+ *
175
+ * ## Measured, not breakpointed
176
+ *
177
+ * There is no `collapseAt` prop and no media query. The bar reads
178
+ * the widths its items actually have, so a longer label or a
179
+ * `data-density="kiosk"` collapses at the width it should rather
180
+ * than at a number someone typed in 2024. `useMediaQuery` is still
181
+ * the right tool one level up — for **relocating** a whole toolbar
182
+ * out of a header on a phone, which is a layout decision and not a
183
+ * fit — and `Toolbar.stories.tsx` shows that composition.
184
+ *
185
+ * ⚠️ The root is `flex-1 min-w-0`: it must be sized by its
186
+ * container, never by its own contents, or collapsing an item
187
+ * narrows the box that decides whether to collapse.
188
+ */
189
+ export const Toolbar = ({
190
+ className,
191
+ isOverflowVisible = false,
192
+ items,
193
+ label,
194
+ overflow,
195
+ overflowIcon,
196
+ overflowLabel = "More actions",
197
+ size = "sm",
198
+ }: ToolbarProps): ReactNode => {
199
+ const focusableElements = useRef(
200
+ new Map<string, HTMLElement>(),
201
+ )
202
+
203
+ const focus = useRovingFocus<string>()
204
+
205
+ const { activeValue, register, setActiveValue } = focus
206
+
207
+ const itemKeys = items.map((item) => item.key)
208
+
209
+ const {
210
+ containerRef,
211
+ trackItem,
212
+ trackTrigger,
213
+ visibleCount,
214
+ } = useToolbarOverflow({ itemKeys })
215
+
216
+ const visibleItems = items.slice(0, visibleCount)
217
+
218
+ const collapsedItems = items.slice(visibleCount)
219
+
220
+ const hasOverflow = collapsedItems.length > 0
221
+
222
+ const { hide, isVisible, toggle } = useVisibility({
223
+ isVisible: isOverflowVisible,
224
+ })
225
+
226
+ const trackFocusable = useCallback(
227
+ (key: string, element: HTMLElement | null) => {
228
+ if (element) {
229
+ focusableElements.current.set(key, element)
230
+ } else {
231
+ focusableElements.current.delete(key)
232
+ }
233
+ },
234
+ [],
235
+ )
236
+
237
+ const trackOverflowElement = useCallback(
238
+ (_key: string, element: HTMLElement | null) => {
239
+ trackTrigger(element)
240
+ },
241
+ [trackTrigger],
242
+ )
243
+
244
+ /**
245
+ * Move the caret only when the toolbar already has it.
246
+ *
247
+ * Same guard as `SegmentedControl`: the active member changes
248
+ * whenever the row re-measures, and a bar that stole focus every
249
+ * time the window resized would be worse than one with no
250
+ * keyboard model at all.
251
+ */
252
+ useEffect(() => {
253
+ const container = containerRef.current
254
+
255
+ if (
256
+ !container ||
257
+ activeValue === null ||
258
+ !container.contains(document.activeElement)
259
+ ) {
260
+ return
261
+ }
262
+
263
+ focusableElements.current.get(activeValue)?.focus()
264
+ }, [activeValue, containerRef])
265
+
266
+ const renderAction = (action: ToolbarAction) =>
267
+ action.icon === undefined ? (
268
+ <Button
269
+ appearance="ghost"
270
+ intent="neutral"
271
+ isDisabled={action.isDisabled}
272
+ onClick={action.onSelect}
273
+ size={size}
274
+ >
275
+ {action.label}
276
+ </Button>
277
+ ) : (
278
+ <IconButton
279
+ appearance="ghost"
280
+ intent="neutral"
281
+ isDisabled={action.isDisabled}
282
+ label={action.label}
283
+ onClick={action.onSelect}
284
+ size={size}
285
+ >
286
+ {action.icon}
287
+ </IconButton>
288
+ )
289
+
290
+ const trigger =
291
+ overflowIcon === undefined ? (
292
+ <Button
293
+ appearance="ghost"
294
+ intent="neutral"
295
+ onClick={toggle}
296
+ size={size}
297
+ >
298
+ {overflowLabel}
299
+ </Button>
300
+ ) : (
301
+ <IconButton
302
+ appearance="ghost"
303
+ intent="neutral"
304
+ label={overflowLabel}
305
+ onClick={toggle}
306
+ size={size}
307
+ >
308
+ {overflowIcon}
309
+ </IconButton>
310
+ )
311
+
312
+ const overflowElement =
313
+ overflow === "menu" ? (
314
+ <Menu
315
+ isVisible={isVisible}
316
+ items={collapsedItems
317
+ .filter(isToolbarAction)
318
+ .map((action) => ({
319
+ icon: action.icon,
320
+ isDisabled: action.isDisabled,
321
+ key: action.key,
322
+ label: action.label,
323
+ onSelect: action.onSelect,
324
+ }))}
325
+ onDismiss={hide}
326
+ placement="bottom-end"
327
+ trigger={trigger}
328
+ />
329
+ ) : (
330
+ <Popover
331
+ heading={overflowLabel}
332
+ isVisible={isVisible}
333
+ onDismiss={hide}
334
+ placement="bottom-end"
335
+ trigger={trigger}
336
+ >
337
+ <div className="flex min-w-48 flex-col gap-1">
338
+ {collapsedItems.map((item) =>
339
+ isToolbarAction(item) ? (
340
+ <Button
341
+ appearance="ghost"
342
+ className="justify-start"
343
+ iconStart={item.icon}
344
+ intent="neutral"
345
+ isDisabled={item.isDisabled}
346
+ isFullWidth
347
+ key={item.key}
348
+ onClick={() => {
349
+ item.onSelect()
350
+
351
+ // Choosing dismisses, exactly as it does in a
352
+ // `Menu`. A panel that stays open after an action
353
+ // has fired is the one users read as "it didn't
354
+ // work".
355
+ hide()
356
+ }}
357
+ size={size}
358
+ >
359
+ {item.label}
360
+ </Button>
361
+ ) : (
362
+ <div
363
+ className="flex items-center justify-between gap-3"
364
+ key={item.key}
365
+ >
366
+ {item.label === undefined ? null : (
367
+ <span className="text-content-secondary text-sm">
368
+ {item.label}
369
+ </span>
370
+ )}
371
+
372
+ {item.element}
373
+ </div>
374
+ ),
375
+ )}
376
+ </div>
377
+ </Popover>
378
+ )
379
+
380
+ return (
381
+ <div
382
+ aria-label={label}
383
+ className={toClassName(
384
+ "flex min-w-0 flex-1 items-center gap-1",
385
+ className,
386
+ )}
387
+ /**
388
+ * Focus arriving from **outside** the keyboard model — a
389
+ * click, or Tab landing on the one member in the tab order —
390
+ * becomes the active member, so the next arrow key continues
391
+ * from where the user actually is rather than from wherever
392
+ * the group was last left. React's `onFocus` is `focusin`, so
393
+ * this hears a descendant.
394
+ */
395
+ onFocus={(focusEvent) => {
396
+ for (const [
397
+ key,
398
+ element,
399
+ ] of focusableElements.current) {
400
+ if (element.contains(focusEvent.target)) {
401
+ setActiveValue(key)
402
+
403
+ return
404
+ }
405
+ }
406
+ }}
407
+ onKeyDown={(keyEvent) => {
408
+ const commands: Record<string, () => void> = {
409
+ ArrowLeft: focus.previous,
410
+ ArrowRight: focus.next,
411
+ End: focus.last,
412
+ Home: focus.first,
413
+ }
414
+
415
+ const command = commands[keyEvent.key]
416
+
417
+ if (command) {
418
+ // Arrow keys scroll the page by default, and a toolbar
419
+ // that scrolls the document while moving focus is the
420
+ // hand-rolled version of this component.
421
+ keyEvent.preventDefault()
422
+
423
+ command()
424
+ }
425
+ }}
426
+ ref={containerRef}
427
+ role="toolbar"
428
+ >
429
+ {visibleItems.map((item) => (
430
+ <ToolbarSlot
431
+ isDisabled={
432
+ isToolbarAction(item) &&
433
+ item.isDisabled === true
434
+ }
435
+ itemKey={item.key}
436
+ key={item.key}
437
+ register={register}
438
+ tabIndex={selectTabIndex(focus, item.key)}
439
+ trackElement={trackItem}
440
+ trackFocusable={trackFocusable}
441
+ >
442
+ {isToolbarAction(item)
443
+ ? renderAction(item)
444
+ : item.element}
445
+ </ToolbarSlot>
446
+ ))}
447
+
448
+ {hasOverflow ? (
449
+ <ToolbarSlot
450
+ itemKey={OVERFLOW_KEY}
451
+ register={register}
452
+ tabIndex={selectTabIndex(focus, OVERFLOW_KEY)}
453
+ trackElement={trackOverflowElement}
454
+ trackFocusable={trackFocusable}
455
+ >
456
+ {overflowElement}
457
+ </ToolbarSlot>
458
+ ) : null}
459
+ </div>
460
+ )
461
+ }
@@ -0,0 +1,122 @@
1
+ import type { ReactNode } from "react"
2
+ import { useEffect, useRef } from "react"
3
+
4
+ /**
5
+ * Every focusable a toolbar knows how to hand a tab stop to.
6
+ *
7
+ * `[tabindex]` carries **no** `:not([tabindex="-1"])` clause, unlike
8
+ * the usual copy of this selector: the whole job here is to move a
9
+ * tab stop between elements, so an element this slot has already
10
+ * pushed out of the tab order still has to be findable to be let
11
+ * back in.
12
+ */
13
+ const FOCUSABLE_SELECTOR =
14
+ "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]"
15
+
16
+ export type ToolbarSlotProps = {
17
+ children: ReactNode
18
+ /** A disabled member never registers, so the arrows skip it. */
19
+ isDisabled?: boolean
20
+ itemKey: string
21
+ /** `RovingFocus.register` — membership of the arrow-key group. */
22
+ register: (value: string) => () => void
23
+ tabIndex: number
24
+ /** Hands the slot's box to the measuring hook. */
25
+ trackElement: (
26
+ key: string,
27
+ element: HTMLElement | null,
28
+ ) => void
29
+ /** Hands the slot's focusable to the toolbar, for `.focus()`. */
30
+ trackFocusable: (
31
+ key: string,
32
+ element: HTMLElement | null,
33
+ ) => void
34
+ }
35
+
36
+ /**
37
+ * One member of the toolbar's roving group: an action the toolbar
38
+ * rendered, or a control the caller handed in.
39
+ *
40
+ * Its own file because **registration is an effect**, and an effect
41
+ * cannot run in a loop — the same reason `MenuAction`, `TabTrigger`
42
+ * and `AccordionSection` are separate files.
43
+ *
44
+ * ### Why the tab stop is written imperatively
45
+ *
46
+ * Every other roving group in this library passes `tabIndex` as a
47
+ * prop, because it renders its own members. A toolbar does not: a
48
+ * `control` item is whatever the app already has — a `Switch`, a
49
+ * `ColorSchemeSwitcher`, a `SegmentedControl` — and most components
50
+ * in this library do not forward a `tabIndex` prop at all. Cloning
51
+ * one onto them would compile, do nothing, and leave the bar with
52
+ * one tab stop per control, which is precisely the APG violation
53
+ * this component exists to fix (mux-magic's `role="toolbar"` is six
54
+ * sequential tab stops).
55
+ *
56
+ * So the slot finds the one focusable in its subtree and writes
57
+ * `tabIndex` onto it. React never sets that attribute on these
58
+ * elements, so there is no second writer to fight with.
59
+ *
60
+ * A control holding **more than one** focusable is a nested
61
+ * composite the toolbar does not manage; only the first is given
62
+ * the tab stop, and `Toolbar.mdx` says so.
63
+ */
64
+ export const ToolbarSlot = ({
65
+ children,
66
+ isDisabled = false,
67
+ itemKey,
68
+ register,
69
+ tabIndex,
70
+ trackElement,
71
+ trackFocusable,
72
+ }: ToolbarSlotProps): ReactNode => {
73
+ const slotRef = useRef<HTMLDivElement>(null)
74
+
75
+ useEffect(() => {
76
+ if (isDisabled) {
77
+ return
78
+ }
79
+
80
+ return register(itemKey)
81
+ }, [isDisabled, itemKey, register])
82
+
83
+ useEffect(() => {
84
+ // The first focusable that is not an `aria-hidden` prop —
85
+ // floating-ui plants focus guards (`<span tabindex="0">`) either
86
+ // side of an overlay's reference element, and the overflow
87
+ // trigger *is* one, so a bare `querySelector` can hand back a
88
+ // guard and give the tab stop to something no user can see.
89
+ const focusable =
90
+ Array.from(
91
+ slotRef.current?.querySelectorAll<HTMLElement>(
92
+ FOCUSABLE_SELECTOR,
93
+ ) ?? [],
94
+ ).find(
95
+ (candidate) =>
96
+ candidate.getAttribute("aria-hidden") !== "true",
97
+ ) ?? null
98
+
99
+ trackFocusable(itemKey, focusable)
100
+
101
+ if (focusable && !isDisabled) {
102
+ focusable.tabIndex = tabIndex
103
+ }
104
+
105
+ return () => {
106
+ trackFocusable(itemKey, null)
107
+ }
108
+ }, [isDisabled, itemKey, tabIndex, trackFocusable])
109
+
110
+ return (
111
+ <div
112
+ className="flex shrink-0 items-center"
113
+ ref={(element) => {
114
+ slotRef.current = element
115
+
116
+ trackElement(itemKey, element)
117
+ }}
118
+ >
119
+ {children}
120
+ </div>
121
+ )
122
+ }
@@ -0,0 +1,92 @@
1
+ import { expect, test } from "vitest"
2
+
3
+ import { chooseVisibleCount } from "./chooseVisibleCount.ts"
4
+
5
+ /**
6
+ * The collapse rule, written down as arithmetic.
7
+ *
8
+ * Node, not the browser: there is nothing to render. The measuring
9
+ * half — a `ResizeObserver`, cached widths, one instance moving
10
+ * between the bar and the panel — is `Toolbar.test.tsx`.
11
+ *
12
+ * Four items of 40px with an 8px gap and a 40px trigger, which is
13
+ * near enough a row of icon buttons at `md` density:
14
+ *
15
+ * | Room | Fits | Why |
16
+ * | --- | --- | --- |
17
+ * | 184 | 4 | 4·40 + 3·8, exactly |
18
+ * | 183 | 3 | one short, so the trigger joins: 3·40 + 3·8 + 40 = 184 → no; 2 fits |
19
+ */
20
+ const ITEMS = [40, 40, 40, 40]
21
+
22
+ const fitsIn = (availableInlineSize: number) =>
23
+ chooseVisibleCount({
24
+ availableInlineSize,
25
+ gapInlineSize: 8,
26
+ itemInlineSizes: ITEMS,
27
+ triggerInlineSize: 40,
28
+ })
29
+
30
+ test("everything fits, and no trigger is costed", () => {
31
+ // 4·40 + 3·8 = 184, to the pixel.
32
+ expect(fitsIn(184)).toBe(4)
33
+ })
34
+
35
+ test("one pixel short collapses past the trigger's own width", () => {
36
+ // Not 3: three items plus the trigger is 3·40 + 3·8 + 40 = 184,
37
+ // which does not fit in 183 either. Two items plus the trigger is
38
+ // 2·40 + 2·8 + 40 = 136.
39
+ expect(fitsIn(183)).toBe(2)
40
+ })
41
+
42
+ test("the count falls one at a time as the room does", () => {
43
+ expect(fitsIn(136)).toBe(2)
44
+ expect(fitsIn(135)).toBe(1)
45
+ expect(fitsIn(88)).toBe(1)
46
+ expect(fitsIn(87)).toBe(0)
47
+ })
48
+
49
+ test("an unmeasured container keeps everything visible", () => {
50
+ // The safe direction for one frame, and the only way the first
51
+ // measurement can happen at all: an item that never mounted has
52
+ // no width to read.
53
+ expect(fitsIn(0)).toBe(4)
54
+ expect(fitsIn(-1)).toBe(4)
55
+ })
56
+
57
+ test("no items is no items", () => {
58
+ expect(
59
+ chooseVisibleCount({
60
+ availableInlineSize: 500,
61
+ gapInlineSize: 8,
62
+ itemInlineSizes: [],
63
+ triggerInlineSize: 40,
64
+ }),
65
+ ).toBe(0)
66
+ })
67
+
68
+ test("a trigger that has never been measured costs nothing", () => {
69
+ // The first collapse is decided before any trigger exists, so its
70
+ // width is zero and the answer is at worst one item too many —
71
+ // corrected on the next pass, once the trigger is in the row.
72
+ expect(
73
+ chooseVisibleCount({
74
+ availableInlineSize: 140,
75
+ gapInlineSize: 8,
76
+ itemInlineSizes: ITEMS,
77
+ triggerInlineSize: 0,
78
+ }),
79
+ ).toBe(3)
80
+ })
81
+
82
+ test("priority is the array order — the end collapses first", () => {
83
+ // A wide first item survives; the narrow ones after it go.
84
+ expect(
85
+ chooseVisibleCount({
86
+ availableInlineSize: 160,
87
+ gapInlineSize: 8,
88
+ itemInlineSizes: [100, 40, 40, 40],
89
+ triggerInlineSize: 40,
90
+ }),
91
+ ).toBe(1)
92
+ })