@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
|
@@ -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
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 (
|
|
140
|
+
if (
|
|
141
|
+
isTriggerWidthMatched ||
|
|
142
|
+
maxHeightPx !== undefined ||
|
|
143
|
+
maxWidthPx !== undefined
|
|
144
|
+
) {
|
|
132
145
|
middleware.push(
|
|
133
146
|
size({
|
|
134
147
|
padding: 8,
|
|
135
|
-
apply({
|
|
136
|
-
|
|
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 = `${
|
|
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} />
|