@charcuterie/ui 1.0.1 → 2.0.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 (134) hide show
  1. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
  2. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
  3. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
  4. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
  5. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
  6. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
  7. package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
  8. package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
  9. package/dist/Combobox/Combobox.d.ts +54 -0
  10. package/dist/Combobox/Combobox.d.ts.map +1 -0
  11. package/dist/Combobox/Combobox.js +216 -0
  12. package/dist/Combobox/Combobox.js.map +1 -0
  13. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  14. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  15. package/dist/Combobox/ComboboxOption.js +32 -0
  16. package/dist/Combobox/ComboboxOption.js.map +1 -0
  17. package/dist/Dialog/Dialog.d.ts +47 -0
  18. package/dist/Dialog/Dialog.d.ts.map +1 -0
  19. package/dist/Dialog/Dialog.js +52 -0
  20. package/dist/Dialog/Dialog.js.map +1 -0
  21. package/dist/Lightbox/Lightbox.d.ts +57 -0
  22. package/dist/Lightbox/Lightbox.d.ts.map +1 -0
  23. package/dist/Lightbox/Lightbox.js +50 -0
  24. package/dist/Lightbox/Lightbox.js.map +1 -0
  25. package/dist/Listbox/Listbox.d.ts +56 -0
  26. package/dist/Listbox/Listbox.d.ts.map +1 -0
  27. package/dist/Listbox/Listbox.js +151 -0
  28. package/dist/Listbox/Listbox.js.map +1 -0
  29. package/dist/Listbox/ListboxOption.d.ts +36 -0
  30. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  31. package/dist/Listbox/ListboxOption.js +42 -0
  32. package/dist/Listbox/ListboxOption.js.map +1 -0
  33. package/dist/Menu/Menu.d.ts.map +1 -1
  34. package/dist/Menu/Menu.js +47 -97
  35. package/dist/Menu/Menu.js.map +1 -1
  36. package/dist/Modal/Modal.d.ts +11 -59
  37. package/dist/Modal/Modal.d.ts.map +1 -1
  38. package/dist/Modal/Modal.js +28 -101
  39. package/dist/Modal/Modal.js.map +1 -1
  40. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  41. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  42. package/dist/Overlay/OverlayPanel.js +72 -0
  43. package/dist/Overlay/OverlayPanel.js.map +1 -0
  44. package/dist/Overlay/OverlayStack.d.ts +47 -0
  45. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  46. package/dist/Overlay/OverlayStack.js +46 -0
  47. package/dist/Overlay/OverlayStack.js.map +1 -0
  48. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  49. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  50. package/dist/Overlay/SharedBackdrop.js +28 -0
  51. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  52. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  53. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  54. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  55. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  56. package/dist/Overlay/overlayPanelClass.js +17 -0
  57. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  58. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  59. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  60. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  61. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  62. package/dist/Popover/Popover.d.ts +16 -24
  63. package/dist/Popover/Popover.d.ts.map +1 -1
  64. package/dist/Popover/Popover.js +32 -68
  65. package/dist/Popover/Popover.js.map +1 -1
  66. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  67. package/dist/Tooltip/Tooltip.js +25 -25
  68. package/dist/Tooltip/Tooltip.js.map +1 -1
  69. package/dist/index.d.ts +13 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +6 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/mountStory.testHelpers.d.ts +1 -0
  74. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  75. package/dist/mountStory.testHelpers.js +21 -1
  76. package/dist/mountStory.testHelpers.js.map +1 -1
  77. package/package.json +5 -4
  78. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
  79. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
  80. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
  81. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
  82. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
  83. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
  84. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
  85. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
  86. package/src/Combobox/Combobox.mdx +71 -0
  87. package/src/Combobox/Combobox.stories.tsx +245 -0
  88. package/src/Combobox/Combobox.test.tsx +289 -0
  89. package/src/Combobox/Combobox.tsx +513 -0
  90. package/src/Combobox/ComboboxOption.tsx +88 -0
  91. package/src/Dialog/Dialog.mdx +87 -0
  92. package/src/Dialog/Dialog.stories.tsx +347 -0
  93. package/src/Dialog/Dialog.test.tsx +312 -0
  94. package/src/Dialog/Dialog.tsx +127 -0
  95. package/src/Field/Field.test.tsx +6 -4
  96. package/src/Lightbox/Lightbox.mdx +63 -0
  97. package/src/Lightbox/Lightbox.stories.tsx +133 -0
  98. package/src/Lightbox/Lightbox.test.tsx +124 -0
  99. package/src/Lightbox/Lightbox.tsx +143 -0
  100. package/src/Listbox/Listbox.mdx +68 -0
  101. package/src/Listbox/Listbox.stories.tsx +205 -0
  102. package/src/Listbox/Listbox.test.tsx +198 -0
  103. package/src/Listbox/Listbox.tsx +295 -0
  104. package/src/Listbox/ListboxOption.tsx +104 -0
  105. package/src/Menu/Menu.mdx +11 -13
  106. package/src/Menu/Menu.test.tsx +47 -38
  107. package/src/Menu/Menu.tsx +81 -142
  108. package/src/Modal/Modal.mdx +29 -82
  109. package/src/Modal/Modal.stories.tsx +55 -188
  110. package/src/Modal/Modal.test.tsx +44 -177
  111. package/src/Modal/Modal.tsx +54 -204
  112. package/src/Overlay/OverlayPanel.tsx +195 -0
  113. package/src/Overlay/OverlayStack.test.tsx +235 -0
  114. package/src/Overlay/OverlayStack.tsx +132 -0
  115. package/src/Overlay/SharedBackdrop.tsx +40 -0
  116. package/src/Overlay/overlayPanelClass.ts +17 -0
  117. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  118. package/src/Popover/Popover.mdx +17 -20
  119. package/src/Popover/Popover.stories.tsx +33 -0
  120. package/src/Popover/Popover.test.tsx +68 -32
  121. package/src/Popover/Popover.tsx +56 -95
  122. package/src/Tooltip/Tooltip.mdx +12 -9
  123. package/src/Tooltip/Tooltip.test.tsx +17 -19
  124. package/src/Tooltip/Tooltip.tsx +35 -32
  125. package/src/icons.storyHelpers.tsx +22 -0
  126. package/src/index.ts +22 -4
  127. package/src/mountStory.testHelpers.ts +25 -1
  128. package/src/sourceRules.test.ts +26 -1
  129. package/src/storyControls.test.ts +25 -12
  130. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  131. package/dist/Modal/lockScrollBehind.js.map +0 -1
  132. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  133. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  134. /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
@@ -0,0 +1,161 @@
1
+ import type { ColorSchemeMode } from "@charcuterie/logic"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { useState } from "react"
4
+
5
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
6
+ import {
7
+ ContainerBoard,
8
+ StoryCell,
9
+ StoryGrid,
10
+ StoryRow,
11
+ StorySection,
12
+ } from "../board.storyHelpers.tsx"
13
+ import {
14
+ MonitorIcon,
15
+ MoonIcon,
16
+ SunIcon,
17
+ } from "../icons.storyHelpers.tsx"
18
+ import type { ColorSchemeIcons } from "./ColorSchemeToggle.tsx"
19
+ import { ColorSchemeToggle } from "./ColorSchemeToggle.tsx"
20
+
21
+ /**
22
+ * The library ships **no icons** — these are the story-only
23
+ * hand-drawn SVGs an app would replace with lucide `Sun` / `Moon` /
24
+ * `Monitor`.
25
+ */
26
+ const ICONS: ColorSchemeIcons = {
27
+ dark: <MoonIcon />,
28
+ light: <SunIcon />,
29
+ system: <MonitorIcon />,
30
+ }
31
+
32
+ const meta = {
33
+ title: "Components/ColorSchemeToggle",
34
+ component: ColorSchemeToggle,
35
+ parameters: { layout: "padded" },
36
+ argTypes: {
37
+ // `mode` is `ColorSchemeMode` from @charcuterie/logic — a bare
38
+ // specifier docgen cannot enumerate, so it gets an explicit
39
+ // radio rather than falling through to the object control.
40
+ mode: {
41
+ control: "radio",
42
+ options: ["light", "dark", "system"],
43
+ },
44
+ size: controlSizeArgType,
45
+ },
46
+ args: {
47
+ appearance: "ghost",
48
+ icons: ICONS,
49
+ isDisabled: false,
50
+ mode: "system",
51
+ onCycle: () => {},
52
+ size: "md",
53
+ },
54
+ } satisfies Meta<typeof ColorSchemeToggle>
55
+
56
+ export default meta
57
+
58
+ type Story = StoryObj<typeof meta>
59
+
60
+ /**
61
+ * Controlled and self-contained: this story owns the mode in local
62
+ * state and advances it on each press, which is what a plain
63
+ * consumer wires up. `<ColorSchemeSwitcher>` wires the same
64
+ * `onCycle` to the `useColorScheme` hook instead.
65
+ *
66
+ * The accessible name states the current mode and the next one, so
67
+ * it changes as the button cycles — that is what the test asserts.
68
+ */
69
+ export const Default: Story = {
70
+ render: (toggleProps) => {
71
+ const [mode, setMode] =
72
+ useState<ColorSchemeMode>("system")
73
+
74
+ return (
75
+ <ColorSchemeToggle
76
+ {...toggleProps}
77
+ mode={mode}
78
+ onCycle={setMode}
79
+ />
80
+ )
81
+ },
82
+ }
83
+
84
+ /**
85
+ * Each mode's icon, held fixed so the three glyphs are visible at
86
+ * once — light is the sun, dark the moon, system the monitor.
87
+ */
88
+ export const AllModes: Story = {
89
+ render: (toggleProps) => (
90
+ <StorySection title="light → dark → system, the order a press cycles through.">
91
+ <StoryGrid columns={3}>
92
+ <StoryCell label="light">
93
+ <ColorSchemeToggle
94
+ {...toggleProps}
95
+ mode="light"
96
+ onCycle={() => {}}
97
+ />
98
+ </StoryCell>
99
+
100
+ <StoryCell label="dark">
101
+ <ColorSchemeToggle
102
+ {...toggleProps}
103
+ mode="dark"
104
+ onCycle={() => {}}
105
+ />
106
+ </StoryCell>
107
+
108
+ <StoryCell label="system">
109
+ <ColorSchemeToggle
110
+ {...toggleProps}
111
+ mode="system"
112
+ onCycle={() => {}}
113
+ />
114
+ </StoryCell>
115
+ </StoryGrid>
116
+ </StorySection>
117
+ ),
118
+ }
119
+
120
+ export const Sizes: Story = {
121
+ render: (toggleProps) => (
122
+ <StorySection title="Square on the control height, so it lines up with the buttons beside it in a toolbar.">
123
+ <StoryRow>
124
+ <ColorSchemeToggle
125
+ {...toggleProps}
126
+ mode="light"
127
+ onCycle={() => {}}
128
+ size="sm"
129
+ />
130
+
131
+ <ColorSchemeToggle
132
+ {...toggleProps}
133
+ mode="dark"
134
+ onCycle={() => {}}
135
+ size="md"
136
+ />
137
+
138
+ <ColorSchemeToggle
139
+ {...toggleProps}
140
+ mode="system"
141
+ onCycle={() => {}}
142
+ size="lg"
143
+ />
144
+ </StoryRow>
145
+ </StorySection>
146
+ ),
147
+ }
148
+
149
+ export const Responsive: Story = {
150
+ render: (toggleProps) => (
151
+ <ContainerBoard>
152
+ <StoryRow>
153
+ <ColorSchemeToggle
154
+ {...toggleProps}
155
+ mode="system"
156
+ onCycle={() => {}}
157
+ />
158
+ </StoryRow>
159
+ </ContainerBoard>
160
+ ),
161
+ }
@@ -0,0 +1,80 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { userEvent } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./ColorSchemeToggle.stories.tsx"
9
+
10
+ const { AllModes, Default } = composeStories(stories)
11
+
12
+ /**
13
+ * The name states the current mode — a screen reader that lands on
14
+ * the control hears "Colour scheme: system", not a nameless button,
15
+ * and an agent can find it.
16
+ */
17
+ test("the toggle is named for its current mode", async () => {
18
+ const { canvas } = await mountStory(Default)
19
+
20
+ expectAgentDrivable(canvas, {
21
+ name: /colour scheme: system/i,
22
+ role: "button",
23
+ })
24
+ })
25
+
26
+ test("a press cycles the mode and updates the name", async () => {
27
+ const { canvas } = await mountStory(Default)
28
+
29
+ const button = expectAgentDrivable(canvas, {
30
+ name: /colour scheme: system/i,
31
+ role: "button",
32
+ })
33
+
34
+ // system → light
35
+ await userEvent.click(button)
36
+
37
+ expectAgentDrivable(canvas, {
38
+ name: /colour scheme: light/i,
39
+ role: "button",
40
+ })
41
+
42
+ // light → dark
43
+ await userEvent.click(button)
44
+
45
+ expectAgentDrivable(canvas, {
46
+ name: /colour scheme: dark/i,
47
+ role: "button",
48
+ })
49
+ })
50
+
51
+ test("the icon is hidden from assistive tech; the name carries it", async () => {
52
+ const { canvas, canvasElement } =
53
+ await mountStory(Default)
54
+
55
+ expectAgentDrivable(canvas, {
56
+ name: /colour scheme/i,
57
+ role: "button",
58
+ })
59
+
60
+ await expectNoAxeViolations(canvasElement)
61
+ })
62
+
63
+ test("each mode renders its own control", async () => {
64
+ const { canvas } = await mountStory(AllModes)
65
+
66
+ expectAgentDrivable(canvas, {
67
+ name: /colour scheme: light/i,
68
+ role: "button",
69
+ })
70
+
71
+ expectAgentDrivable(canvas, {
72
+ name: /colour scheme: dark/i,
73
+ role: "button",
74
+ })
75
+
76
+ expectAgentDrivable(canvas, {
77
+ name: /colour scheme: system/i,
78
+ role: "button",
79
+ })
80
+ })
@@ -0,0 +1,107 @@
1
+ import type { ColorSchemeMode } from "@charcuterie/logic"
2
+ import {
3
+ DEFAULT_COLOR_SCHEME_ORDER,
4
+ nextColorSchemeMode,
5
+ } from "@charcuterie/logic"
6
+ import type { ControlSize } from "@charcuterie/tokens"
7
+ import type { ReactNode } from "react"
8
+
9
+ import { IconButton } from "../IconButton/IconButton.tsx"
10
+ import type { IntentAppearance } from "../intentStyles.ts"
11
+
12
+ /**
13
+ * One `ReactNode` per mode. The library ships **no icons** — lucide
14
+ * (ISC) is the fleet recommendation — so an app passes its own
15
+ * `Sun` / `Moon` / `Monitor`, exactly as `IconButton` takes its
16
+ * glyph as `children`
17
+ * ([decision](../../../../docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md)).
18
+ */
19
+ export type ColorSchemeIcons = Record<
20
+ ColorSchemeMode,
21
+ ReactNode
22
+ >
23
+
24
+ export type ColorSchemeToggleProps = {
25
+ appearance?: IntentAppearance
26
+ className?: string
27
+ icons: ColorSchemeIcons
28
+ isDisabled?: boolean
29
+ /**
30
+ * Builds the accessible name from the current and next mode.
31
+ * Default announces the current mode and where a press goes —
32
+ * "Colour scheme: dark. Activate to switch to system." — so the
33
+ * name updates as the control cycles and
34
+ * `getByRole("button", { name: /colour scheme/i })` resolves.
35
+ */
36
+ label?: (
37
+ mode: ColorSchemeMode,
38
+ nextMode: ColorSchemeMode,
39
+ ) => string
40
+ mode: ColorSchemeMode
41
+ /**
42
+ * Called with the next mode on activation. **Controlled**: this
43
+ * component stores nothing — it renders `mode` and reports intent,
44
+ * which is what keeps it free of `matchMedia`/`document` and
45
+ * usable in any runtime. `<ColorSchemeSwitcher>` wires this to the
46
+ * `useColorScheme` hook; a plain consumer wires it to its own
47
+ * state.
48
+ */
49
+ onCycle: (nextMode: ColorSchemeMode) => void
50
+ order?: readonly ColorSchemeMode[]
51
+ size?: ControlSize
52
+ }
53
+
54
+ const MODE_LABEL: Record<ColorSchemeMode, string> = {
55
+ dark: "Dark",
56
+ light: "Light",
57
+ system: "System",
58
+ }
59
+
60
+ const defaultLabel = (
61
+ mode: ColorSchemeMode,
62
+ nextMode: ColorSchemeMode,
63
+ ) =>
64
+ `Colour scheme: ${MODE_LABEL[mode]}. Activate to switch to ${MODE_LABEL[nextMode]}.`
65
+
66
+ /**
67
+ * The cycling colour-scheme button — light → dark → system → light.
68
+ *
69
+ * Layer 2 of the three: presentational and controlled, with no
70
+ * browser coupling. It is an `IconButton`, so it inherits the one
71
+ * rule that makes the fleet agent-drivable — a glyph is not a name,
72
+ * and this control's name states the current mode rather than
73
+ * leaving a screen reader to read a sun path as nothing.
74
+ *
75
+ * A *segmented* all-three-visible form was considered and deferred
76
+ * (owner chose cycling-only, 2026-08-03); `icons` and `order` are
77
+ * shaped so a `ColorSchemePicker` could join later without changing
78
+ * this API.
79
+ */
80
+ export const ColorSchemeToggle = ({
81
+ appearance = "ghost",
82
+ className,
83
+ icons,
84
+ isDisabled = false,
85
+ label = defaultLabel,
86
+ mode,
87
+ onCycle,
88
+ order = DEFAULT_COLOR_SCHEME_ORDER,
89
+ size = "md",
90
+ }: ColorSchemeToggleProps): ReactNode => {
91
+ const nextMode = nextColorSchemeMode(mode, order)
92
+
93
+ return (
94
+ <IconButton
95
+ appearance={appearance}
96
+ className={className}
97
+ isDisabled={isDisabled}
98
+ label={label(mode, nextMode)}
99
+ onClick={() => {
100
+ onCycle(nextMode)
101
+ }}
102
+ size={size}
103
+ >
104
+ {icons[mode]}
105
+ </IconButton>
106
+ )
107
+ }
@@ -0,0 +1,71 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ComboboxStories from "./Combobox.stories";
4
+
5
+ <Meta of={ComboboxStories} />
6
+
7
+ # Combobox
8
+
9
+ The searchable, filtering, virtualized picker — the other half of the picker family, and
10
+ the one that consolidates the fleet's hand-rolled dropdowns
11
+ ([decision](../../../docs/decisions/2026-08-03-listbox-and-combobox-are-siblings-of-select.md)).
12
+ Use it when the list is long enough to search or filter; reach for
13
+ [`Listbox`](?path=/docs/components-listbox--docs) for a short rich-option list, and the
14
+ native [`Select`](?path=/docs/components-select--docs) for a plain one.
15
+
16
+ <Canvas of={ComboboxStories.Default} />
17
+
18
+ ## Props
19
+
20
+ <Controls />
21
+
22
+ ## The ARIA is forced, not stylistic
23
+
24
+ Unlike `Listbox`, **focus stays in the text input**, so the active option is tracked with
25
+ `aria-activedescendant`, not roving tabindex. The input is the `role="combobox"`
26
+ (`aria-autocomplete="list"`, `aria-expanded`, `aria-controls`); the popup is a
27
+ `role="listbox"`. Arrows move the active descendant while the caret stays put; Enter
28
+ selects; Escape clears the query before it closes. The options are `<div>`-shaped
29
+ `role="option"`s with **zero** tab stops — a combobox popup legitimately has none.
30
+
31
+ ## Filtering is the consumer's, or the component's
32
+
33
+ Omit `onQueryChange` and the list filters internally by `textValue`. Supply it — a
34
+ react-query search, say — and you own the query: the `options` then arrive pre-filtered,
35
+ and the debounce, the request token, and the per-parent cache stay yours, exactly the way
36
+ `PathPicker` already splits async work.
37
+
38
+ ```tsx
39
+ <Combobox
40
+ isVisible={isVisible}
41
+ onDismiss={hide}
42
+ onQueryChange={setQuery} // omit to filter internally
43
+ onSelect={choose}
44
+ options={results} // pre-filtered when you own the query
45
+ trigger={<Button onClick={toggle}>Search</Button>}
46
+ />
47
+ ```
48
+
49
+ ## Loading, error, empty, footer, creatable, multiple
50
+
51
+ `isLoading` and `error` are panel states (only `PathPicker` had all of them); an empty
52
+ result shows `emptyLabel` and stays open. `footer` is a sticky hint (`LinkPicker`'s "type a
53
+ path directly"). `isCreatable` lets Enter commit the raw query (`AssFieldPicker`), and Tab
54
+ accepts the active suggestion (`PathPicker`). `isMultiple` renders the chosen values as
55
+ removable chips rather than filling the input, and keeps the popup open.
56
+
57
+ <Canvas of={ComboboxStories.AllVariants} />
58
+
59
+ <Canvas of={ComboboxStories.AllStates} />
60
+
61
+ ## Virtualization, and why `aria-setsize` is not optional
62
+
63
+ Above ~100 options (or with `isVirtualized`), the list is windowed with
64
+ `@tanstack/react-virtual` — the second runtime dependency this package has ever taken (MIT,
65
+ US-origin, ~4 KB gz). Because most of the list is not in the DOM, every windowed option
66
+ **must** carry `aria-setsize` and `aria-posinset`, or a screen reader announces "2 of 12"
67
+ for a 4,000-item list. Below the threshold the list is plain DOM.
68
+
69
+ <Canvas of={ComboboxStories.Virtualized} />
70
+
71
+ <Canvas of={ComboboxStories.Interactive} />
@@ -0,0 +1,245 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import type { ReactNode } from "react"
4
+ import { useState } from "react"
5
+
6
+ import { Button } from "../Button/Button.tsx"
7
+ import type { ListboxItem } from "../Listbox/Listbox.tsx"
8
+ import { Combobox } from "./Combobox.tsx"
9
+
10
+ const LANGUAGES: ListboxItem[] = [
11
+ { label: "English", value: "eng" },
12
+ { label: "Spanish", value: "spa" },
13
+ { label: "French", value: "fra" },
14
+ { label: "German", value: "deu" },
15
+ { label: "Japanese", value: "jpn" },
16
+ { label: "Korean", value: "kor" },
17
+ { label: "Portuguese", value: "por" },
18
+ ]
19
+
20
+ const LANGUAGES_DISABLED_FIRST: ListboxItem[] = [
21
+ { isDisabled: true, label: "English", value: "eng" },
22
+ { label: "Spanish", value: "spa" },
23
+ { label: "French", value: "fra" },
24
+ ]
25
+
26
+ // A long list, to trip the auto-virtualization threshold.
27
+ const MANY: ListboxItem[] = Array.from(
28
+ { length: 500 },
29
+ (_unused, index) => ({
30
+ label: `Track ${index + 1}`,
31
+ textValue: `Track ${index + 1}`,
32
+ value: `track-${index + 1}`,
33
+ }),
34
+ )
35
+
36
+ const meta = {
37
+ title: "Components/Combobox",
38
+ component: Combobox,
39
+ parameters: { layout: "padded" },
40
+ } satisfies Meta<typeof Combobox>
41
+
42
+ export default meta
43
+
44
+ type Story = StoryObj<typeof meta>
45
+
46
+ const ComboboxHarness = ({
47
+ emptyLabel,
48
+ error,
49
+ footer,
50
+ isCreatable,
51
+ isInitiallyVisible = false,
52
+ isLoading,
53
+ isMultiple,
54
+ isVirtualized,
55
+ options,
56
+ triggerLabel,
57
+ }: {
58
+ emptyLabel?: string
59
+ error?: ReactNode
60
+ footer?: ReactNode
61
+ isCreatable?: boolean
62
+ isInitiallyVisible?: boolean
63
+ isLoading?: boolean
64
+ isMultiple?: boolean
65
+ isVirtualized?: boolean
66
+ options: ListboxItem[]
67
+ triggerLabel: string
68
+ }): ReactNode => {
69
+ const { hide, isVisible, toggle } = useVisibility({
70
+ isVisible: isInitiallyVisible,
71
+ })
72
+
73
+ const [chosen, setChosen] = useState<string[]>([])
74
+
75
+ return (
76
+ <>
77
+ <Combobox
78
+ emptyLabel={emptyLabel}
79
+ error={error}
80
+ footer={footer}
81
+ isCreatable={isCreatable}
82
+ isLoading={isLoading}
83
+ isMultiple={isMultiple}
84
+ isVirtualized={isVirtualized}
85
+ isVisible={isVisible}
86
+ onDismiss={hide}
87
+ onSelect={(value) => {
88
+ setChosen((previous) =>
89
+ previous.includes(value)
90
+ ? previous.filter((one) => one !== value)
91
+ : [...previous, value],
92
+ )
93
+ }}
94
+ options={options}
95
+ trigger={
96
+ <Button appearance="outline" onClick={toggle}>
97
+ {triggerLabel}
98
+ </Button>
99
+ }
100
+ />
101
+
102
+ <p className="mt-2 text-content-muted text-xs">
103
+ Chosen: {chosen.join(", ") || "—"}
104
+ </p>
105
+ </>
106
+ )
107
+ }
108
+
109
+ export const Default: Story = {
110
+ args: {
111
+ isVisible: false,
112
+ onDismiss: () => {},
113
+ onSelect: () => {},
114
+ options: LANGUAGES,
115
+ trigger: <Button>Search languages</Button>,
116
+ },
117
+ render: () => (
118
+ <ComboboxHarness
119
+ options={LANGUAGES}
120
+ triggerLabel="Search languages"
121
+ />
122
+ ),
123
+ }
124
+
125
+ /**
126
+ * The loading and error panel states — the ones only `PathPicker` had
127
+ * all of, and every fleet picker did differently. Both keep the popup
128
+ * open.
129
+ */
130
+ export const AllStates: Story = {
131
+ args: {
132
+ isVisible: false,
133
+ onDismiss: () => {},
134
+ onSelect: () => {},
135
+ options: LANGUAGES,
136
+ trigger: <Button>Search</Button>,
137
+ },
138
+ render: () => (
139
+ <div className="flex flex-col gap-8">
140
+ <ComboboxHarness
141
+ isLoading
142
+ options={LANGUAGES}
143
+ triggerLabel="Loading state"
144
+ />
145
+
146
+ <ComboboxHarness
147
+ error="Could not reach the server."
148
+ options={LANGUAGES}
149
+ triggerLabel="Error state"
150
+ />
151
+ </div>
152
+ ),
153
+ }
154
+
155
+ /**
156
+ * Multi-select renders the chosen values as removable chips rather
157
+ * than filling the input, with a sticky footer hint — the
158
+ * `LanguageCodeField` and `LinkPicker` shapes. `isCreatable` lets Enter
159
+ * commit the raw query.
160
+ */
161
+ export const AllVariants: Story = {
162
+ args: {
163
+ isVisible: false,
164
+ onDismiss: () => {},
165
+ onSelect: () => {},
166
+ options: LANGUAGES,
167
+ trigger: <Button>Pick languages</Button>,
168
+ },
169
+ render: () => (
170
+ <ComboboxHarness
171
+ footer="Type a code and press Enter to add it."
172
+ isCreatable
173
+ isMultiple
174
+ options={LANGUAGES}
175
+ triggerLabel="Pick languages"
176
+ />
177
+ ),
178
+ }
179
+
180
+ /**
181
+ * Five hundred options, windowed with `@tanstack/react-virtual`: only
182
+ * the visible rows are in the DOM, so each carries `aria-setsize` and
183
+ * `aria-posinset` — otherwise a screen reader announces "2 of 12" for
184
+ * a list that is nothing of the sort.
185
+ */
186
+ export const Virtualized: Story = {
187
+ args: {
188
+ isVisible: false,
189
+ onDismiss: () => {},
190
+ onSelect: () => {},
191
+ options: MANY,
192
+ trigger: <Button>Search 500 tracks</Button>,
193
+ },
194
+ render: () => (
195
+ <ComboboxHarness
196
+ isInitiallyVisible
197
+ options={MANY}
198
+ triggerLabel="Search 500 tracks"
199
+ />
200
+ ),
201
+ }
202
+
203
+ /**
204
+ * Open it and type: the list filters as you go, arrows move the active
205
+ * option while the caret stays in the input, Enter chooses, and Escape
206
+ * clears the query before it closes.
207
+ */
208
+ export const Interactive: Story = {
209
+ args: {
210
+ isVisible: false,
211
+ onDismiss: () => {},
212
+ onSelect: () => {},
213
+ options: LANGUAGES,
214
+ trigger: <Button>Search languages</Button>,
215
+ },
216
+ render: () => (
217
+ <ComboboxHarness
218
+ options={LANGUAGES}
219
+ triggerLabel="Search languages"
220
+ />
221
+ ),
222
+ }
223
+
224
+ /**
225
+ * A disabled option in the seed position. Opening seeds the active row at
226
+ * index 0, so the resolved active descendant must skip past the disabled
227
+ * first option to the first enabled one — and Enter must never commit the
228
+ * disabled value.
229
+ */
230
+ export const DisabledFirstOption: Story = {
231
+ args: {
232
+ isVisible: false,
233
+ onDismiss: () => {},
234
+ onSelect: () => {},
235
+ options: LANGUAGES_DISABLED_FIRST,
236
+ trigger: <Button>Pick a language</Button>,
237
+ },
238
+ render: () => (
239
+ <ComboboxHarness
240
+ isInitiallyVisible
241
+ options={LANGUAGES_DISABLED_FIRST}
242
+ triggerLabel="Pick a language"
243
+ />
244
+ ),
245
+ }