@charcuterie/ui 2.2.0 → 2.3.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 +22 -4
- package/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +142 -24
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
- package/dist/Combobox/ComboboxOption.js +22 -1
- package/dist/Combobox/ComboboxOption.js.map +1 -1
- package/dist/Listbox/ListboxOption.d.ts.map +1 -1
- package/dist/Listbox/ListboxOption.js +12 -2
- package/dist/Listbox/ListboxOption.js.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.d.ts +17 -5
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.js +23 -3
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
- package/dist/Select/Select.d.ts.map +1 -1
- package/dist/Select/Select.js +8 -2
- package/dist/Select/Select.js.map +1 -1
- package/dist/controlStyles.d.ts +8 -0
- package/dist/controlStyles.d.ts.map +1 -1
- package/dist/controlStyles.js +8 -0
- package/dist/controlStyles.js.map +1 -1
- package/package.json +1 -1
- package/src/Combobox/Combobox.mdx +30 -0
- package/src/Combobox/Combobox.stories.tsx +150 -5
- package/src/Combobox/Combobox.test.tsx +120 -1
- package/src/Combobox/Combobox.tsx +266 -72
- package/src/Combobox/ComboboxOption.tsx +22 -3
- package/src/Listbox/ListboxOption.tsx +11 -3
- package/src/Overlay/useAnchoredOverlay.ts +49 -8
- package/src/Select/Select.tsx +6 -5
- package/src/controlStyles.ts +8 -0
package/dist/controlStyles.js
CHANGED
|
@@ -35,6 +35,14 @@ export const ICON_CONTROL_SIZE_CLASS = {
|
|
|
35
35
|
* density — a finger does not get smaller because the list is
|
|
36
36
|
* dense — so this is opt-in per control rather than baked into the
|
|
37
37
|
* size classes, which would make `compact` a lie.
|
|
38
|
+
*
|
|
39
|
+
* **Do NOT add this to a control that shares a form row** (`Button`,
|
|
40
|
+
* `Select`, a `Listbox`/`Combobox` trigger, `SegmentedControl`, …). It
|
|
41
|
+
* overrides the shared `CONTROL_SIZE_CLASS` height and makes that one
|
|
42
|
+
* control taller than its neighbours at desktop density — touch sizing is
|
|
43
|
+
* the density axis's job. Reserve this for a genuinely standalone tap
|
|
44
|
+
* target. See
|
|
45
|
+
* `docs/decisions/2026-08-05-controls-share-one-height-no-per-component-touch-floor.md`.
|
|
38
46
|
*/
|
|
39
47
|
export const MIN_TOUCH_TARGET_CLASS = "min-h-(--control-min-touch-target) min-w-(--control-min-touch-target)";
|
|
40
48
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"controlStyles.js","sourceRoot":"","sources":["../src/controlStyles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,MAAM,CAAC,MAAM,kBAAkB,GAG3B;IACF,EAAE,EAAE,yFAAyF;IAC7F,EAAE,EAAE,yFAAyF;IAC7F,EAAE,EAAE,yFAAyF;CAC9F,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAGhC;IACF,EAAE,EAAE,yCAAyC;IAC7C,EAAE,EAAE,yCAAyC;IAC7C,EAAE,EAAE,yCAAyC;CAC9C,CAAA;AAED
|
|
1
|
+
{"version":3,"file":"controlStyles.js","sourceRoot":"","sources":["../src/controlStyles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,MAAM,CAAC,MAAM,kBAAkB,GAG3B;IACF,EAAE,EAAE,yFAAyF;IAC7F,EAAE,EAAE,yFAAyF;IAC7F,EAAE,EAAE,yFAAyF;CAC9F,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAGhC;IACF,EAAE,EAAE,yCAAyC;IAC7C,EAAE,EAAE,yCAAyC;IAC7C,EAAE,EAAE,yCAAyC;CAC9C,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,sBAAsB,GACjC,uEAAuE,CAAA;AAEzE;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC9B,EAAE,EAAE,2BAA2B;IAC/B,EAAE,EAAE,6BAA6B;CACzB,CAAA;AAIV,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,EAAE,EAAE,UAAU;IACd,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,UAAU;CACN,CAAA;AAEV,MAAM,CAAC,MAAM,kBAAkB,GAG3B;IACF,EAAE,EAAE,mBAAmB;IACvB,EAAE,EAAE,iBAAiB;IACrB,EAAE,EAAE,iBAAiB;CACtB,CAAA"}
|
package/package.json
CHANGED
|
@@ -58,6 +58,36 @@ removable chips rather than filling the input, and keeps the popup open.
|
|
|
58
58
|
|
|
59
59
|
<Canvas of={ComboboxStories.AllStates} />
|
|
60
60
|
|
|
61
|
+
## Attached input & drill-down
|
|
62
|
+
|
|
63
|
+
Supply `inputRef` and Combobox stops rendering its own input: it anchors a **list-only**
|
|
64
|
+
popup to the consumer's `<input>`, mirrors the combobox ARIA (`role`, `aria-controls`,
|
|
65
|
+
`aria-activedescendant`) onto it, and drives arrows/Enter/Tab/Escape off it while focus
|
|
66
|
+
stays put. Use it when the field is **both the value and the query** — a path field whose
|
|
67
|
+
text you type *and* filter by, the shape `PathPicker` needs.
|
|
68
|
+
|
|
69
|
+
The consumer owns the input's `value`/`onChange` (wire them to the controlled `query`,
|
|
70
|
+
which is then required) **and** `isVisible`. Because of that, **a select does not
|
|
71
|
+
auto-dismiss** in this mode: `onSelect` fires and the consumer decides what happens next —
|
|
72
|
+
append the folder and leave `isVisible` on to drill deeper (the popup re-queries from the
|
|
73
|
+
new path and stays open), or call its own `close()` to finish. Enter and Tab both accept
|
|
74
|
+
the active option; Escape closes outright. Single-select only (`isMultiple` is classic-mode
|
|
75
|
+
only, and it stays the only mode that keeps the popup open on a normal select).
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
<input ref={inputRef} value={path} onChange={(e) => setPath(e.target.value)} />
|
|
79
|
+
<Combobox
|
|
80
|
+
inputRef={inputRef}
|
|
81
|
+
isVisible={isVisible}
|
|
82
|
+
query={path}
|
|
83
|
+
options={folders} // pre-filtered by the trailing segment
|
|
84
|
+
onSelect={(name) => setPath(`${parentPath}${name}/`)} // drills; stays open
|
|
85
|
+
onDismiss={() => setIsVisible(false)}
|
|
86
|
+
/>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
<Canvas of={ComboboxStories.AttachedInputDrillDown} />
|
|
90
|
+
|
|
61
91
|
## Virtualization, and why `aria-setsize` is not optional
|
|
62
92
|
|
|
63
93
|
Above ~100 options (or with `isVirtualized`), the list is windowed with
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useVisibility } from "@charcuterie/logic"
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
3
|
import type { ReactNode } from "react"
|
|
4
|
-
import { useState } from "react"
|
|
4
|
+
import { useRef, useState } from "react"
|
|
5
5
|
|
|
6
6
|
import { Button } from "../Button/Button.tsx"
|
|
7
7
|
import type { ListboxItem } from "../Listbox/Listbox.tsx"
|
|
@@ -153,10 +153,11 @@ export const AllStates: Story = {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
|
-
* Multi-select renders the chosen values as
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
156
|
+
* Multi-select renders the chosen values as an always-visible row of
|
|
157
|
+
* removable chips **above the trigger** — they stay on screen after the
|
|
158
|
+
* popup closes, and each carries its option's human label (not its raw
|
|
159
|
+
* value) and an ✕ to remove it. The `LanguageCodeField` and `LinkPicker`
|
|
160
|
+
* shapes. `isCreatable` lets Enter commit the raw query.
|
|
160
161
|
*/
|
|
161
162
|
export const AllVariants: Story = {
|
|
162
163
|
args: {
|
|
@@ -227,6 +228,150 @@ export const Interactive: Story = {
|
|
|
227
228
|
* first option to the first enabled one — and Enter must never commit the
|
|
228
229
|
* disabled value.
|
|
229
230
|
*/
|
|
231
|
+
// A tiny in-memory directory tree for the attached-input demo: keys are
|
|
232
|
+
// parent paths (with trailing slash), values their child folders.
|
|
233
|
+
const TREE: Record<string, string[]> = {
|
|
234
|
+
"/": ["apps", "config", "media"],
|
|
235
|
+
"/apps/": ["mux-magic", "charcuterie"],
|
|
236
|
+
"/config/": ["hosts", "secrets"],
|
|
237
|
+
"/media/": ["movies", "music"],
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const AttachedInputHarness = (): ReactNode => {
|
|
241
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
242
|
+
|
|
243
|
+
const [value, setValue] = useState("/")
|
|
244
|
+
|
|
245
|
+
const [isVisible, setIsVisible] = useState(true)
|
|
246
|
+
|
|
247
|
+
const lastSlash = value.lastIndexOf("/")
|
|
248
|
+
|
|
249
|
+
const parentPath = value.slice(0, lastSlash + 1) || "/"
|
|
250
|
+
|
|
251
|
+
const tail = value.slice(lastSlash + 1)
|
|
252
|
+
|
|
253
|
+
const options: ListboxItem[] = (TREE[parentPath] ?? [])
|
|
254
|
+
.filter((name) => name.startsWith(tail))
|
|
255
|
+
.map((name) => ({
|
|
256
|
+
label: `📁 ${name}`,
|
|
257
|
+
textValue: name,
|
|
258
|
+
value: name,
|
|
259
|
+
}))
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<>
|
|
263
|
+
<input
|
|
264
|
+
aria-label="Path"
|
|
265
|
+
className="w-80 rounded-md border border-border-default bg-surface-raised px-2 py-1.5 font-mono text-content-primary text-sm outline-none"
|
|
266
|
+
onChange={(changeEvent) => {
|
|
267
|
+
setValue(changeEvent.target.value)
|
|
268
|
+
|
|
269
|
+
setIsVisible(true)
|
|
270
|
+
}}
|
|
271
|
+
ref={inputRef}
|
|
272
|
+
value={value}
|
|
273
|
+
/>
|
|
274
|
+
|
|
275
|
+
<Combobox
|
|
276
|
+
emptyLabel="No matching entries."
|
|
277
|
+
inputRef={inputRef}
|
|
278
|
+
isVisible={isVisible}
|
|
279
|
+
onDismiss={() => {
|
|
280
|
+
setIsVisible(false)
|
|
281
|
+
}}
|
|
282
|
+
onSelect={(name) => {
|
|
283
|
+
// Drill in: append the picked folder + separator. The changed
|
|
284
|
+
// query refetches the new directory; the popup stays open
|
|
285
|
+
// because we never flip `isVisible`.
|
|
286
|
+
setValue(`${parentPath}${name}/`)
|
|
287
|
+
}}
|
|
288
|
+
options={options}
|
|
289
|
+
query={value}
|
|
290
|
+
/>
|
|
291
|
+
</>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Attached-input mode: the consumer renders its own `<input>` (both the
|
|
297
|
+
* committed value and the query) and Combobox anchors a list-only popup
|
|
298
|
+
* to it. Type a path; picking a folder appends it and re-queries that
|
|
299
|
+
* folder's children **without closing** — a shell-style drill-down. This
|
|
300
|
+
* is `PathPicker`'s shape.
|
|
301
|
+
*/
|
|
302
|
+
export const AttachedInputDrillDown: Story = {
|
|
303
|
+
args: {
|
|
304
|
+
inputRef: { current: null },
|
|
305
|
+
isVisible: true,
|
|
306
|
+
onDismiss: () => {},
|
|
307
|
+
onSelect: () => {},
|
|
308
|
+
options: [],
|
|
309
|
+
query: "/",
|
|
310
|
+
},
|
|
311
|
+
render: () => <AttachedInputHarness />,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Long, space-separated names — the real-world case (a media library of
|
|
315
|
+
// long folder names) the short demo tree does not show.
|
|
316
|
+
const LONG_OPTIONS: ListboxItem[] = [
|
|
317
|
+
"Armored Trooper Votoms - Brilliantly Shining Heresy [anidb-7910]",
|
|
318
|
+
"Armored Trooper Votoms - Case;Irvine [anidb-7911]",
|
|
319
|
+
"Armored Trooper Votoms - Chirico's Return [anidb-1234]",
|
|
320
|
+
"Armored Trooper Votoms - Deadworld Sunsa [anidb-5678]",
|
|
321
|
+
"Armored Trooper Votoms - Pailsen Files - The Movie [anidb-9012]",
|
|
322
|
+
].map((name) => ({
|
|
323
|
+
label: `📁 ${name}`,
|
|
324
|
+
textValue: name,
|
|
325
|
+
value: name,
|
|
326
|
+
}))
|
|
327
|
+
|
|
328
|
+
const LongOptionsHarness = (): ReactNode => {
|
|
329
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
330
|
+
|
|
331
|
+
const [isVisible, setIsVisible] = useState(true)
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<>
|
|
335
|
+
<input
|
|
336
|
+
aria-label="Path"
|
|
337
|
+
className="w-80 rounded-md border border-border-default bg-surface-raised px-2 py-1.5 font-mono text-content-primary text-sm outline-none"
|
|
338
|
+
readOnly
|
|
339
|
+
ref={inputRef}
|
|
340
|
+
value="/media/Media-Storage/Anime/"
|
|
341
|
+
/>
|
|
342
|
+
|
|
343
|
+
<Combobox
|
|
344
|
+
emptyLabel="No matching entries."
|
|
345
|
+
inputRef={inputRef}
|
|
346
|
+
isVisible={isVisible}
|
|
347
|
+
onDismiss={() => {
|
|
348
|
+
setIsVisible(false)
|
|
349
|
+
}}
|
|
350
|
+
onSelect={() => {}}
|
|
351
|
+
options={LONG_OPTIONS}
|
|
352
|
+
query="/media/Media-Storage/Anime/"
|
|
353
|
+
/>
|
|
354
|
+
</>
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* How the attached-input popup handles **long option labels** — a media
|
|
360
|
+
* library of long folder names, the case the short drill-down demo hides.
|
|
361
|
+
* Shows how the panel sizes and how each option row treats overflow.
|
|
362
|
+
*/
|
|
363
|
+
export const AttachedInputLongOptions: Story = {
|
|
364
|
+
args: {
|
|
365
|
+
inputRef: { current: null },
|
|
366
|
+
isVisible: true,
|
|
367
|
+
onDismiss: () => {},
|
|
368
|
+
onSelect: () => {},
|
|
369
|
+
options: LONG_OPTIONS,
|
|
370
|
+
query: "/media/Media-Storage/Anime/",
|
|
371
|
+
},
|
|
372
|
+
render: () => <LongOptionsHarness />,
|
|
373
|
+
}
|
|
374
|
+
|
|
230
375
|
export const DisabledFirstOption: Story = {
|
|
231
376
|
args: {
|
|
232
377
|
isVisible: false,
|
|
@@ -9,6 +9,7 @@ import * as stories from "./Combobox.stories.tsx"
|
|
|
9
9
|
|
|
10
10
|
const {
|
|
11
11
|
AllVariants,
|
|
12
|
+
AttachedInputDrillDown,
|
|
12
13
|
Default,
|
|
13
14
|
DisabledFirstOption,
|
|
14
15
|
Interactive,
|
|
@@ -224,8 +225,10 @@ test("removing a chip reports the removal through onSelect", async () => {
|
|
|
224
225
|
).toBeInTheDocument()
|
|
225
226
|
})
|
|
226
227
|
|
|
228
|
+
// The chip's face and accessible name are the option's human label,
|
|
229
|
+
// not its `value` — "English", not "eng".
|
|
227
230
|
await userEvent.click(
|
|
228
|
-
body.getByRole("button", { name: "Remove
|
|
231
|
+
body.getByRole("button", { name: "Remove English" }),
|
|
229
232
|
)
|
|
230
233
|
|
|
231
234
|
// The removal flowed back through `onSelect`, so the parent cleared it
|
|
@@ -269,6 +272,122 @@ test("a disabled first option is skipped, never active or committed", async () =
|
|
|
269
272
|
).toBeInTheDocument()
|
|
270
273
|
})
|
|
271
274
|
|
|
275
|
+
test("attached mode: the consumer input is the combobox and options are shown", async () => {
|
|
276
|
+
const { body, canvas } = await mountStory(
|
|
277
|
+
AttachedInputDrillDown,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
const input = canvas.getByLabelText("Path")
|
|
281
|
+
|
|
282
|
+
// The a11y is mirrored onto the consumer's own input.
|
|
283
|
+
await waitFor(() => {
|
|
284
|
+
expect(input).toHaveAttribute("role", "combobox")
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
await expect(input).toHaveAttribute(
|
|
288
|
+
"aria-autocomplete",
|
|
289
|
+
"list",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
const listbox = body.getByRole("listbox")
|
|
293
|
+
|
|
294
|
+
await expect(input).toHaveAttribute(
|
|
295
|
+
"aria-controls",
|
|
296
|
+
listbox.id,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
// The three top-level folders, from the pre-filtered options.
|
|
300
|
+
await expect(body.getAllByRole("option")).toHaveLength(3)
|
|
301
|
+
|
|
302
|
+
await expectNoAxeViolations(listbox)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test("attached mode: selecting a folder drills in and keeps the popup open", async () => {
|
|
306
|
+
const { body, canvas } = await mountStory(
|
|
307
|
+
AttachedInputDrillDown,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
const input = canvas.getByLabelText("Path")
|
|
311
|
+
|
|
312
|
+
await userEvent.click(input)
|
|
313
|
+
|
|
314
|
+
await waitFor(() => {
|
|
315
|
+
expect(input).toHaveFocus()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// Enter commits the active option ("apps") — but does not close.
|
|
319
|
+
await userEvent.keyboard("{Enter}")
|
|
320
|
+
|
|
321
|
+
await waitFor(() => {
|
|
322
|
+
expect(
|
|
323
|
+
body.getByRole("option", { name: /mux-magic/ }),
|
|
324
|
+
).toBeInTheDocument()
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// The field drilled in, the popup is still open, and focus never left.
|
|
328
|
+
await expect(input).toHaveValue("/apps/")
|
|
329
|
+
|
|
330
|
+
await expect(
|
|
331
|
+
body.getByRole("listbox"),
|
|
332
|
+
).toBeInTheDocument()
|
|
333
|
+
|
|
334
|
+
await expect(input).toHaveFocus()
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test("attached mode: Tab also accepts the active option and drills", async () => {
|
|
338
|
+
const { body, canvas } = await mountStory(
|
|
339
|
+
AttachedInputDrillDown,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
const input = canvas.getByLabelText("Path")
|
|
343
|
+
|
|
344
|
+
await userEvent.click(input)
|
|
345
|
+
|
|
346
|
+
await waitFor(() => {
|
|
347
|
+
expect(input).toHaveFocus()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
await userEvent.keyboard("{Tab}")
|
|
351
|
+
|
|
352
|
+
await waitFor(() => {
|
|
353
|
+
expect(input).toHaveValue("/apps/")
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
// Tab drilled rather than moving focus out; the popup stays open.
|
|
357
|
+
await expect(
|
|
358
|
+
body.getByRole("listbox"),
|
|
359
|
+
).toBeInTheDocument()
|
|
360
|
+
|
|
361
|
+
await expect(input).toHaveFocus()
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test("attached mode: Escape closes the popup outright", async () => {
|
|
365
|
+
const { body, canvas } = await mountStory(
|
|
366
|
+
AttachedInputDrillDown,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
const input = canvas.getByLabelText("Path")
|
|
370
|
+
|
|
371
|
+
await userEvent.click(input)
|
|
372
|
+
|
|
373
|
+
await waitFor(() => {
|
|
374
|
+
expect(input).toHaveFocus()
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
await userEvent.keyboard("{Escape}")
|
|
378
|
+
|
|
379
|
+
await waitFor(() => {
|
|
380
|
+
expect(body.queryByRole("listbox")).toBeNull()
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// The consumer's input persists (Combobox never rendered it); it just
|
|
384
|
+
// reports itself collapsed.
|
|
385
|
+
await expect(input).toHaveAttribute(
|
|
386
|
+
"aria-expanded",
|
|
387
|
+
"false",
|
|
388
|
+
)
|
|
389
|
+
})
|
|
390
|
+
|
|
272
391
|
test("a windowed list carries aria-setsize and aria-posinset", async () => {
|
|
273
392
|
const { body } = await mountStory(Virtualized)
|
|
274
393
|
|