@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.
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
- package/dist/Combobox/Combobox.d.ts +54 -0
- package/dist/Combobox/Combobox.d.ts.map +1 -0
- package/dist/Combobox/Combobox.js +216 -0
- package/dist/Combobox/Combobox.js.map +1 -0
- package/dist/Combobox/ComboboxOption.d.ts +36 -0
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
- package/dist/Combobox/ComboboxOption.js +32 -0
- package/dist/Combobox/ComboboxOption.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +47 -0
- package/dist/Dialog/Dialog.d.ts.map +1 -0
- package/dist/Dialog/Dialog.js +52 -0
- package/dist/Dialog/Dialog.js.map +1 -0
- package/dist/Lightbox/Lightbox.d.ts +57 -0
- package/dist/Lightbox/Lightbox.d.ts.map +1 -0
- package/dist/Lightbox/Lightbox.js +50 -0
- package/dist/Lightbox/Lightbox.js.map +1 -0
- package/dist/Listbox/Listbox.d.ts +56 -0
- package/dist/Listbox/Listbox.d.ts.map +1 -0
- package/dist/Listbox/Listbox.js +151 -0
- package/dist/Listbox/Listbox.js.map +1 -0
- package/dist/Listbox/ListboxOption.d.ts +36 -0
- package/dist/Listbox/ListboxOption.d.ts.map +1 -0
- package/dist/Listbox/ListboxOption.js +42 -0
- package/dist/Listbox/ListboxOption.js.map +1 -0
- package/dist/Menu/Menu.d.ts.map +1 -1
- package/dist/Menu/Menu.js +47 -97
- package/dist/Menu/Menu.js.map +1 -1
- package/dist/Modal/Modal.d.ts +11 -59
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +28 -101
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +50 -0
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
- package/dist/Overlay/OverlayPanel.js +72 -0
- package/dist/Overlay/OverlayPanel.js.map +1 -0
- package/dist/Overlay/OverlayStack.d.ts +47 -0
- package/dist/Overlay/OverlayStack.d.ts.map +1 -0
- package/dist/Overlay/OverlayStack.js +46 -0
- package/dist/Overlay/OverlayStack.js.map +1 -0
- package/dist/Overlay/SharedBackdrop.d.ts +24 -0
- package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
- package/dist/Overlay/SharedBackdrop.js +28 -0
- package/dist/Overlay/SharedBackdrop.js.map +1 -0
- package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
- package/dist/Overlay/lockScrollBehind.js.map +1 -0
- package/dist/Overlay/overlayPanelClass.d.ts +17 -0
- package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
- package/dist/Overlay/overlayPanelClass.js +17 -0
- package/dist/Overlay/overlayPanelClass.js.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.js +81 -0
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
- package/dist/Popover/Popover.d.ts +16 -24
- package/dist/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +32 -68
- package/dist/Popover/Popover.js.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +25 -25
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +1 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +21 -1
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +5 -4
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
- package/src/Combobox/Combobox.mdx +71 -0
- package/src/Combobox/Combobox.stories.tsx +245 -0
- package/src/Combobox/Combobox.test.tsx +289 -0
- package/src/Combobox/Combobox.tsx +513 -0
- package/src/Combobox/ComboboxOption.tsx +88 -0
- package/src/Dialog/Dialog.mdx +87 -0
- package/src/Dialog/Dialog.stories.tsx +347 -0
- package/src/Dialog/Dialog.test.tsx +312 -0
- package/src/Dialog/Dialog.tsx +127 -0
- package/src/Field/Field.test.tsx +6 -4
- package/src/Lightbox/Lightbox.mdx +63 -0
- package/src/Lightbox/Lightbox.stories.tsx +133 -0
- package/src/Lightbox/Lightbox.test.tsx +124 -0
- package/src/Lightbox/Lightbox.tsx +143 -0
- package/src/Listbox/Listbox.mdx +68 -0
- package/src/Listbox/Listbox.stories.tsx +205 -0
- package/src/Listbox/Listbox.test.tsx +198 -0
- package/src/Listbox/Listbox.tsx +295 -0
- package/src/Listbox/ListboxOption.tsx +104 -0
- package/src/Menu/Menu.mdx +11 -13
- package/src/Menu/Menu.test.tsx +47 -38
- package/src/Menu/Menu.tsx +81 -142
- package/src/Modal/Modal.mdx +29 -82
- package/src/Modal/Modal.stories.tsx +55 -188
- package/src/Modal/Modal.test.tsx +44 -177
- package/src/Modal/Modal.tsx +54 -204
- package/src/Overlay/OverlayPanel.tsx +195 -0
- package/src/Overlay/OverlayStack.test.tsx +235 -0
- package/src/Overlay/OverlayStack.tsx +132 -0
- package/src/Overlay/SharedBackdrop.tsx +40 -0
- package/src/Overlay/overlayPanelClass.ts +17 -0
- package/src/Overlay/useAnchoredOverlay.ts +197 -0
- package/src/Popover/Popover.mdx +17 -20
- package/src/Popover/Popover.stories.tsx +33 -0
- package/src/Popover/Popover.test.tsx +68 -32
- package/src/Popover/Popover.tsx +56 -95
- package/src/Tooltip/Tooltip.mdx +12 -9
- package/src/Tooltip/Tooltip.test.tsx +17 -19
- package/src/Tooltip/Tooltip.tsx +35 -32
- package/src/icons.storyHelpers.tsx +22 -0
- package/src/index.ts +22 -4
- package/src/mountStory.testHelpers.ts +25 -1
- package/src/sourceRules.test.ts +26 -1
- package/src/storyControls.test.ts +25 -12
- package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
- package/dist/Modal/lockScrollBehind.js.map +0 -1
- /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
- /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
- /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
|
+
}
|