@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
@@ -54,6 +54,7 @@ const ComboboxHarness = ({
54
54
  isMultiple,
55
55
  isVirtualized,
56
56
  options,
57
+ selectedValue,
57
58
  triggerLabel,
58
59
  }: {
59
60
  className?: string
@@ -66,13 +67,20 @@ const ComboboxHarness = ({
66
67
  isMultiple?: boolean
67
68
  isVirtualized?: boolean
68
69
  options: ListboxItem[]
70
+ selectedValue?: readonly string[] | string
69
71
  triggerLabel: string
70
72
  }): ReactNode => {
71
73
  const { hide, isVisible, toggle } = useVisibility({
72
74
  isVisible: isInitiallyVisible,
73
75
  })
74
76
 
75
- const [chosen, setChosen] = useState<string[]>([])
77
+ const [chosen, setChosen] = useState<string[]>(
78
+ selectedValue === undefined
79
+ ? []
80
+ : typeof selectedValue === "string"
81
+ ? [selectedValue]
82
+ : [...selectedValue],
83
+ )
76
84
 
77
85
  return (
78
86
  <>
@@ -104,6 +112,7 @@ const ComboboxHarness = ({
104
112
  })
105
113
  }}
106
114
  options={options}
115
+ selectedValue={selectedValue}
107
116
  trigger={
108
117
  <Button appearance="outline" onClick={toggle}>
109
118
  {triggerLabel}
@@ -134,6 +143,76 @@ export const Default: Story = {
134
143
  ),
135
144
  }
136
145
 
146
+ // Rich labels that pin a trailing category tag to the row's right edge:
147
+ // the label is a full-width flex row, so its tag sits just inside the
148
+ // always-reserved ✓ gutter. Selecting a row must not shift the tag — the
149
+ // checkmark is always laid out, only made `invisible` when unselected.
150
+ const TAGGED_LANGUAGES: ListboxItem[] = [
151
+ { name: "English", tag: "Germanic", value: "eng" },
152
+ { name: "Spanish", tag: "Romance", value: "spa" },
153
+ { name: "French", tag: "Romance", value: "fra" },
154
+ { name: "German", tag: "Germanic", value: "deu" },
155
+ ].map(({ name, tag, value }) => ({
156
+ label: (
157
+ <span className="flex flex-1 items-center justify-between gap-2">
158
+ <span>{name}</span>
159
+
160
+ <span className="rounded-sm bg-intent-neutral-surface-hover px-1.5 py-0.5 text-content-secondary text-xs">
161
+ {tag}
162
+ </span>
163
+ </span>
164
+ ),
165
+ textValue: name,
166
+ value,
167
+ }))
168
+
169
+ /**
170
+ * Rich option rows that pin a **trailing category tag** to the right
171
+ * edge, with the first option pre-selected. The ✓ gutter is always laid
172
+ * out (only made `invisible` when a row is unselected), so selecting a
173
+ * row is a paint-only change and the trailing tag never jumps left.
174
+ */
175
+ export const TrailingElement: Story = {
176
+ args: {
177
+ isVisible: false,
178
+ onDismiss: () => {},
179
+ onSelect: () => {},
180
+ options: TAGGED_LANGUAGES,
181
+ trigger: <Button>Search languages</Button>,
182
+ },
183
+ render: () => (
184
+ <ComboboxHarness
185
+ isInitiallyVisible
186
+ options={TAGGED_LANGUAGES}
187
+ selectedValue="eng"
188
+ triggerLabel="Search languages"
189
+ />
190
+ ),
191
+ }
192
+
193
+ /**
194
+ * A very long, full-sentence `footer`. The panel is width-capped
195
+ * (`maxWidthPx`), so the footer **wraps** to multiple lines instead of
196
+ * stretching the popup to the width of the sentence.
197
+ */
198
+ export const LongFooter: Story = {
199
+ args: {
200
+ isVisible: false,
201
+ onDismiss: () => {},
202
+ onSelect: () => {},
203
+ options: LANGUAGES,
204
+ trigger: <Button>Search languages</Button>,
205
+ },
206
+ render: () => (
207
+ <ComboboxHarness
208
+ footer="Type a BCP-47 language code and press Enter to add it directly — this hint is a full sentence on purpose, to prove the panel caps its width and wraps the footer instead of stretching to fit it."
209
+ isInitiallyVisible
210
+ options={LANGUAGES}
211
+ triggerLabel="Search languages"
212
+ />
213
+ ),
214
+ }
215
+
137
216
  /**
138
217
  * The loading and error panel states — the ones only `PathPicker` had
139
218
  * all of, and every fleet picker did differently. Both keep the popup
@@ -13,6 +13,8 @@ const {
13
13
  Default,
14
14
  DisabledFirstOption,
15
15
  Interactive,
16
+ LongFooter,
17
+ TrailingElement,
16
18
  Virtualized,
17
19
  } = composeStories(stories)
18
20
 
@@ -388,6 +390,59 @@ test("attached mode: Escape closes the popup outright", async () => {
388
390
  )
389
391
  })
390
392
 
393
+ test("the ✓ gutter is always laid out, only hidden when a row is unselected", async () => {
394
+ const { body } = await mountStory(TrailingElement)
395
+
396
+ const options = body.getAllByRole("option")
397
+
398
+ // Every option — selected or not — renders the ✓ span, so the label's
399
+ // width (and any trailing element it pins) never changes on selection.
400
+ for (const option of options) {
401
+ const check = option.querySelector(
402
+ '[aria-hidden="true"]',
403
+ )
404
+
405
+ expect(check).not.toBeNull()
406
+ expect(check).toHaveTextContent("✓")
407
+ }
408
+
409
+ // The pre-selected first row (English) shows its ✓; the rest keep the
410
+ // gutter but mark it `invisible`.
411
+ const selected = options.find((option: HTMLElement) =>
412
+ option.textContent?.includes("English"),
413
+ )
414
+
415
+ expect(selected).toHaveAttribute("aria-selected", "true")
416
+
417
+ expect(
418
+ selected?.querySelector('[aria-hidden="true"]'),
419
+ ).not.toHaveClass("invisible")
420
+
421
+ const unselected = options.find((option: HTMLElement) =>
422
+ option.textContent?.includes("Spanish"),
423
+ )
424
+
425
+ expect(unselected).toHaveAttribute(
426
+ "aria-selected",
427
+ "false",
428
+ )
429
+
430
+ expect(
431
+ unselected?.querySelector('[aria-hidden="true"]'),
432
+ ).toHaveClass("invisible")
433
+ })
434
+
435
+ test("a long footer is allowed to wrap rather than force the panel wide", async () => {
436
+ const { body } = await mountStory(LongFooter)
437
+
438
+ const footer = body.getByText(/BCP-47 language code/)
439
+
440
+ // The footer container permits wrapping (paired with the panel's
441
+ // `maxWidthPx` cap) instead of laying the sentence out on one line.
442
+ expect(footer).toHaveClass("whitespace-normal")
443
+ expect(footer).toHaveClass("break-words")
444
+ })
445
+
391
446
  test("a windowed list carries aria-setsize and aria-posinset", async () => {
392
447
  const { body } = await mountStory(Virtualized)
393
448
 
@@ -195,6 +195,10 @@ export const Combobox = ({
195
195
  isEscapeDismissable: false,
196
196
  isVisible,
197
197
  maxHeightPx: 384,
198
+ // Cap the width so an overlong footer sentence or option label wraps
199
+ // instead of dragging the whole panel absurdly wide; the `min-w-64`
200
+ // floor still holds the low end. Content wider than this wraps.
201
+ maxWidthPx: 384,
198
202
  offsetValue: 4,
199
203
  onDismiss,
200
204
  role: "listbox",
@@ -716,7 +720,7 @@ export const Combobox = ({
716
720
  </div>
717
721
 
718
722
  {footer ? (
719
- <div className="shrink-0 border-border-subtle border-t p-2 text-content-secondary text-xs">
723
+ <div className="shrink-0 whitespace-normal break-words border-border-subtle border-t p-2 text-content-secondary text-xs [overflow-wrap:anywhere]">
720
724
  {footer}
721
725
  </div>
722
726
  ) : null}
@@ -100,14 +100,25 @@ export const ComboboxOption = ({
100
100
  >
101
101
  {label}
102
102
 
103
- {isSelected ? (
104
- <span
105
- aria-hidden="true"
106
- className="shrink-0 text-intent-accent-content"
107
- >
108
- ✓
109
- </span>
110
- ) : null}
103
+ {/*
104
+ The ✓ is rendered **always**, only hidden with `invisible` when
105
+ the row is unselected — never unmounted. If it appeared only on
106
+ selection the label's available width would change the instant a
107
+ row became selected, and a consumer whose label pins a trailing
108
+ element (a category tag) to the row's right edge would see it jump
109
+ left. A fixed-width, always-present gutter keeps selection a
110
+ paint-only change. `aria-hidden` throughout: selection is conveyed
111
+ by the button's `aria-selected`, so the glyph is decoration.
112
+ */}
113
+ <span
114
+ aria-hidden="true"
115
+ className={toClassName(
116
+ "flex w-4 shrink-0 justify-center text-intent-accent-content",
117
+ !isSelected && "invisible",
118
+ )}
119
+ >
120
+ ✓
121
+ </span>
111
122
  </button>
112
123
  )
113
124
  }
@@ -91,6 +91,14 @@ export type UseAnchoredOverlayOptions = {
91
91
  isVisible: boolean
92
92
  /** Clamps the panel to `min(this, available viewport space)`. */
93
93
  maxHeightPx?: number
94
+ /**
95
+ * Caps the panel's width at `min(this, available viewport space)`,
96
+ * symmetric with `maxHeightPx`. Without it a picker grows to its
97
+ * widest content — a long `Combobox` footer sentence or option label
98
+ * drags the whole panel absurdly wide; the cap lets that content wrap
99
+ * instead. Omit for width-follows-content (the default).
100
+ */
101
+ maxWidthPx?: number
94
102
  /** Popover 8, Menu/Listbox/Combobox 4. */
95
103
  offsetValue?: number
96
104
  onDismiss: () => void
@@ -116,6 +124,7 @@ export const useAnchoredOverlay = ({
116
124
  isTriggerWidthMatched = false,
117
125
  isVisible,
118
126
  maxHeightPx,
127
+ maxWidthPx,
119
128
  offsetValue = 8,
120
129
  onDismiss,
121
130
  placement = "bottom-start",
@@ -128,17 +137,35 @@ export const useAnchoredOverlay = ({
128
137
  shift({ padding: 8 }),
129
138
  ]
130
139
 
131
- if (isTriggerWidthMatched || maxHeightPx !== undefined) {
140
+ if (
141
+ isTriggerWidthMatched ||
142
+ maxHeightPx !== undefined ||
143
+ maxWidthPx !== undefined
144
+ ) {
132
145
  middleware.push(
133
146
  size({
134
147
  padding: 8,
135
- apply({ availableHeight, elements, rects }) {
136
- const cap =
148
+ apply({
149
+ availableHeight,
150
+ availableWidth,
151
+ elements,
152
+ rects,
153
+ }) {
154
+ const heightCap =
137
155
  maxHeightPx === undefined
138
156
  ? availableHeight
139
157
  : Math.min(maxHeightPx, availableHeight)
140
158
 
141
- elements.floating.style.maxHeight = `${cap}px`
159
+ elements.floating.style.maxHeight = `${heightCap}px`
160
+
161
+ if (maxWidthPx !== undefined) {
162
+ const widthCap = Math.min(
163
+ maxWidthPx,
164
+ availableWidth,
165
+ )
166
+
167
+ elements.floating.style.maxWidth = `${widthCap}px`
168
+ }
142
169
 
143
170
  if (isTriggerWidthMatched) {
144
171
  elements.floating.style.width = `${rects.reference.width}px`
@@ -0,0 +1,126 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ToolbarStories from "./Toolbar.stories";
4
+
5
+ <Meta of={ToolbarStories} />
6
+
7
+ # Toolbar
8
+
9
+ A row of controls that collapses into an overflow when it runs out of room — measured, not
10
+ breakpointed, with **exactly one instance** of every control in the document.
11
+
12
+ <Canvas of={ToolbarStories.Default} />
13
+
14
+ ## Props
15
+
16
+ <Controls />
17
+
18
+ ## What four repos got wrong
19
+
20
+ mux-magic's `PageHeader` is the fleet's original, and plex-channels' `Header` names it in
21
+ its own comments as the thing it copied. Both inherit all of this:
22
+
23
+ | Defect | There | Here |
24
+ | --- | --- | --- |
25
+ | `role="toolbar"`, no toolbar keyboard model | six sequential tab stops | one tab stop, arrow keys, `RovingFocus` |
26
+ | Toggles with no state | `Switch` is an `aria-hidden` `<span>`; Dry Run announces identically on and off | the control is the app's own, and `Switch` carries `role="switch"` |
27
+ | Triggers advertise nothing | zero `aria-expanded` / `aria-haspopup` / `aria-controls` in the file | written by `useRole`, inside `Menu` / `Popover` |
28
+ | No focus management | no focus-on-open, no return-to-trigger | `Menu` seeds the first item; `FloatingFocusManager` returns focus |
29
+ | Collapse by DOM duplication | every action rendered twice, one copy hidden by a documented CSS specificity coin-flip | one instance, relocated |
30
+ | Nothing decides what collapses | two hardcoded media queries at 480/481px, ⋮ shown at every width | measured, priority-ordered, trigger only when something overflowed |
31
+
32
+ ## The overflow's role is a **type**, not a flag
33
+
34
+ `role="menu"` permits only `menuitem`, `menuitemradio`, `menuitemcheckbox`, `group` and
35
+ `separator`. A header overflow usually is not a menu — it holds toggles — and
36
+ plex-channels puts a `ColorSchemeSwitcher` inside one today, which is invalid ARIA that no
37
+ gate catches.
38
+
39
+ So the caller cannot get it wrong:
40
+
41
+ ```tsx
42
+ // Every row is an action → a real `role="menu"`.
43
+ <Toolbar overflow="menu" items={[{ key: "retry", label: "Retry", onSelect }]} label="Deck actions" />
44
+
45
+ // A control in the array → this is a compile error with `overflow="menu"`,
46
+ // and requires `overflow="panel"`, which opens a `Popover`
47
+ // (`role="dialog"`, `aria-haspopup="dialog"`).
48
+ <Toolbar
49
+ overflow="panel"
50
+ items={[
51
+ { key: "retry", label: "Retry", onSelect },
52
+ { element: <DryRunSwitch />, key: "dry-run", type: "control" },
53
+ ]}
54
+ label="Deck controls"
55
+ />
56
+ ```
57
+
58
+ Same instinct as
59
+ [`Menu`'s items being a discriminated union](../../../docs/decisions/2026-08-05-menu-items-is-a-discriminated-union.md):
60
+ the kinds are told apart by the type system, in the data, rather than by a boolean prop or
61
+ a runtime warning.
62
+
63
+ <Canvas of={ToolbarStories.AllVariants} />
64
+
65
+ ## Priority is the array order
66
+
67
+ `items[0]` is the last thing to collapse, and it is also paint order. One axis rather than
68
+ two, because a separate `priority` number beside a separate display order is a pair a
69
+ caller can put out of step.
70
+
71
+ <Canvas of={ToolbarStories.Responsive} />
72
+
73
+ ## Measured, not breakpointed
74
+
75
+ There is no `collapseAt` prop. The bar reads the widths its items actually have, so a
76
+ longer label, a different font, or `data-density="kiosk"` moves the collapse point on its
77
+ own — where `480px` is wrong the moment any of those change.
78
+
79
+ Two consequences worth knowing:
80
+
81
+ - **The root is `flex-1 min-w-0`, and must stay sized by its container.** A bar that
82
+ shrink-wraps its items would narrow when one collapsed, which would collapse another —
83
+ a loop with no error anywhere. `useAdaptiveColumns` documents the same trap from the
84
+ other end.
85
+ - **A collapsed item's width is remembered, not re-read.** It is not in the DOM to
86
+ measure. The first paint mounts everything, measures it, and collapses *before paint* in
87
+ a layout effect.
88
+
89
+ ## The keyboard model
90
+
91
+ Tab **once** to enter, then:
92
+
93
+ | Key | Does |
94
+ | --- | --- |
95
+ | `ArrowRight` / `ArrowLeft` | move to the next / previous member |
96
+ | `Home` / `End` | jump to the first / last member |
97
+ | `Tab` | leaves the toolbar entirely |
98
+
99
+ The overflow trigger is the group's last member, so the arrow keys reach it. Clicking a
100
+ member, or tabbing into the group, makes that member active — so the next arrow key
101
+ continues from where the user is.
102
+
103
+ A **disabled** action is simply not registered with the roving group, so the arrows skip it
104
+ while it stays in the DOM and stays announced — the same mechanism `Menu` and
105
+ `SegmentedControl` use.
106
+
107
+ <Canvas of={ToolbarStories.AllStates} />
108
+
109
+ ## Controls the toolbar does not render
110
+
111
+ A `control` item is the app's own element, and the tab stop is written onto the first
112
+ focusable in its subtree rather than cloned in as a prop: most components in this library
113
+ forward no `tabIndex`, so a clone would compile, do nothing, and leave the bar with one tab
114
+ stop per control.
115
+
116
+ A control holding **more than one** focusable is a nested composite this component does not
117
+ manage — only the first gets the tab stop. Put a `SegmentedControl` in a toolbar and it
118
+ brings its own arrow-key model; that is the APG's expectation too.
119
+
120
+ ## Relocating is a different job
121
+
122
+ Progressive collapse answers *what fits*. On a phone the whole bar often wants to leave the
123
+ header, which is a layout decision — `useMediaQuery` from `@charcuterie/logic`, over the
124
+ `matchMediaMatcher` seam, and one instance mounted in one place or the other.
125
+
126
+ <Canvas of={ToolbarStories.InAppShell} />