@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,513 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FloatingFocusManager,
|
|
3
|
+
FloatingPortal,
|
|
4
|
+
} from "@floating-ui/react"
|
|
5
|
+
import { useVirtualizer } from "@tanstack/react-virtual"
|
|
6
|
+
import type { ReactElement, ReactNode } from "react"
|
|
7
|
+
import { useEffect, useRef, useState } from "react"
|
|
8
|
+
|
|
9
|
+
import type { ListboxItem } from "../Listbox/Listbox.tsx"
|
|
10
|
+
import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
|
|
11
|
+
import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
|
|
12
|
+
import { toClassName } from "../toClassName.ts"
|
|
13
|
+
import { ComboboxOption } from "./ComboboxOption.tsx"
|
|
14
|
+
|
|
15
|
+
export type ComboboxProps = {
|
|
16
|
+
className?: string
|
|
17
|
+
/** Shown when nothing matches. Default "No matches". */
|
|
18
|
+
emptyLabel?: string
|
|
19
|
+
/** The error panel state (`PathPicker`). */
|
|
20
|
+
error?: ReactNode
|
|
21
|
+
/** A sticky footer hint (`LinkPicker`'s "type a path directly"). */
|
|
22
|
+
footer?: ReactNode
|
|
23
|
+
/** Enter commits the raw query as a value (`AssFieldPicker`). */
|
|
24
|
+
isCreatable?: boolean
|
|
25
|
+
/** The loading panel state. */
|
|
26
|
+
isLoading?: boolean
|
|
27
|
+
/** Multi-select, rendered as removable chips (`LanguageCodeField`). */
|
|
28
|
+
isMultiple?: boolean
|
|
29
|
+
/** Windowing. Auto-on above ~100 options when left undefined. */
|
|
30
|
+
isVirtualized?: boolean
|
|
31
|
+
isVisible: boolean
|
|
32
|
+
onDismiss: () => void
|
|
33
|
+
onSelect: (value: string) => void
|
|
34
|
+
/**
|
|
35
|
+
* Omit to filter internally by `textValue`; supply to own the query
|
|
36
|
+
* (a react-query search, say), in which case `options` arrives
|
|
37
|
+
* pre-filtered.
|
|
38
|
+
*/
|
|
39
|
+
onQueryChange?: (query: string) => void
|
|
40
|
+
options: readonly ListboxItem[]
|
|
41
|
+
placeholder?: string
|
|
42
|
+
/** Controlled query; uncontrolled when absent. */
|
|
43
|
+
query?: string
|
|
44
|
+
selectedValue?: readonly string[] | string
|
|
45
|
+
/** The control the popup hangs off. **Cloned, not wrapped.** */
|
|
46
|
+
trigger: ReactElement
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const AUTO_VIRTUALIZE_THRESHOLD = 100
|
|
50
|
+
|
|
51
|
+
const getOptionText = (item: ListboxItem) =>
|
|
52
|
+
(
|
|
53
|
+
item.textValue ??
|
|
54
|
+
(typeof item.label === "string" ? item.label : "")
|
|
55
|
+
).toLowerCase()
|
|
56
|
+
|
|
57
|
+
const nextEnabledIndex = (
|
|
58
|
+
options: readonly ListboxItem[],
|
|
59
|
+
from: number,
|
|
60
|
+
step: number,
|
|
61
|
+
) => {
|
|
62
|
+
for (
|
|
63
|
+
let index = from + step;
|
|
64
|
+
index >= 0 && index < options.length;
|
|
65
|
+
index += step
|
|
66
|
+
) {
|
|
67
|
+
if (!options[index]?.isDisabled) {
|
|
68
|
+
return index
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return from
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The searchable, filtering, virtualized picker — the other half of
|
|
77
|
+
* the picker family, and the one that consolidates the fleet's
|
|
78
|
+
* hand-rolled dropdowns.
|
|
79
|
+
*
|
|
80
|
+
* The ARIA contract differs from `Listbox` and the difference is
|
|
81
|
+
* forced, not stylistic: **focus stays in the text input**, so the
|
|
82
|
+
* active option is tracked with `aria-activedescendant`, not roving
|
|
83
|
+
* tabindex. The input is the `role="combobox"`; the popup is a
|
|
84
|
+
* `role="listbox"`. Arrows move the active descendant while the caret
|
|
85
|
+
* stays put; Enter selects; Escape clears the query before it closes.
|
|
86
|
+
*
|
|
87
|
+
* Filtering is the consumer's when `onQueryChange` is supplied (the
|
|
88
|
+
* `options` then arrive pre-filtered — a react-query search owns the
|
|
89
|
+
* debounce, the request token, the cache), and internal on `textValue`
|
|
90
|
+
* otherwise.
|
|
91
|
+
*/
|
|
92
|
+
export const Combobox = ({
|
|
93
|
+
className,
|
|
94
|
+
emptyLabel = "No matches",
|
|
95
|
+
error,
|
|
96
|
+
footer,
|
|
97
|
+
isCreatable = false,
|
|
98
|
+
isLoading = false,
|
|
99
|
+
isMultiple = false,
|
|
100
|
+
isVirtualized,
|
|
101
|
+
isVisible,
|
|
102
|
+
onDismiss,
|
|
103
|
+
onQueryChange,
|
|
104
|
+
onSelect,
|
|
105
|
+
options,
|
|
106
|
+
placeholder = "Search…",
|
|
107
|
+
query,
|
|
108
|
+
selectedValue,
|
|
109
|
+
trigger,
|
|
110
|
+
}: ComboboxProps): ReactNode => {
|
|
111
|
+
const inputElement = useRef<HTMLInputElement>(null)
|
|
112
|
+
|
|
113
|
+
const listElement = useRef<HTMLDivElement>(null)
|
|
114
|
+
|
|
115
|
+
const isQueryControlled = query !== undefined
|
|
116
|
+
|
|
117
|
+
const [internalQuery, setInternalQuery] = useState("")
|
|
118
|
+
|
|
119
|
+
const currentQuery = query ?? internalQuery
|
|
120
|
+
|
|
121
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
122
|
+
|
|
123
|
+
const [selected, setSelected] = useState<string[]>(
|
|
124
|
+
selectedValue === undefined
|
|
125
|
+
? []
|
|
126
|
+
: typeof selectedValue === "string"
|
|
127
|
+
? [selectedValue]
|
|
128
|
+
: [...selectedValue],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
// The consumer owns filtering when it owns the query; otherwise it is
|
|
132
|
+
// a plain `textValue` contains-match.
|
|
133
|
+
const filtered =
|
|
134
|
+
onQueryChange === undefined
|
|
135
|
+
? options.filter((option) =>
|
|
136
|
+
getOptionText(option).includes(
|
|
137
|
+
currentQuery.toLowerCase(),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
: options
|
|
141
|
+
|
|
142
|
+
const isWindowed =
|
|
143
|
+
isVirtualized ??
|
|
144
|
+
filtered.length > AUTO_VIRTUALIZE_THRESHOLD
|
|
145
|
+
|
|
146
|
+
const {
|
|
147
|
+
clonedTrigger,
|
|
148
|
+
context,
|
|
149
|
+
floatingStyles,
|
|
150
|
+
getFloatingProps,
|
|
151
|
+
setFloating,
|
|
152
|
+
triggerId,
|
|
153
|
+
} = useAnchoredOverlay({
|
|
154
|
+
// Escape is handled here so it can clear the query before closing.
|
|
155
|
+
isEscapeDismissable: false,
|
|
156
|
+
isVisible,
|
|
157
|
+
maxHeightPx: 384,
|
|
158
|
+
offsetValue: 4,
|
|
159
|
+
onDismiss,
|
|
160
|
+
role: "listbox",
|
|
161
|
+
trigger,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// The panel wraps an input *and* a listbox, so the wrapper is a
|
|
165
|
+
// plain div: the `role="listbox"`/`id` `useRole` produced go on the
|
|
166
|
+
// inner list, which is what the trigger's and input's `aria-controls`
|
|
167
|
+
// then resolve to.
|
|
168
|
+
const {
|
|
169
|
+
id: listboxId,
|
|
170
|
+
role: _discardedPanelRole,
|
|
171
|
+
...floatingProps
|
|
172
|
+
} = getFloatingProps() as {
|
|
173
|
+
id: string
|
|
174
|
+
role?: string
|
|
175
|
+
} & Record<string, unknown>
|
|
176
|
+
|
|
177
|
+
const setQuery = (nextQuery: string) => {
|
|
178
|
+
if (!isQueryControlled) {
|
|
179
|
+
setInternalQuery(nextQuery)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setActiveIndex(0)
|
|
183
|
+
|
|
184
|
+
onQueryChange?.(nextQuery)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (isVisible) {
|
|
189
|
+
inputElement.current?.focus()
|
|
190
|
+
}
|
|
191
|
+
}, [isVisible])
|
|
192
|
+
|
|
193
|
+
const rowVirtualizer = useVirtualizer({
|
|
194
|
+
count: filtered.length,
|
|
195
|
+
estimateSize: () => 36,
|
|
196
|
+
getScrollElement: () => listElement.current,
|
|
197
|
+
overscan: 8,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const optionDomId = (value: string) =>
|
|
201
|
+
`${listboxId}-opt-${value}`
|
|
202
|
+
|
|
203
|
+
// `activeIndex` is seeded at 0 on open and on every query reset, which
|
|
204
|
+
// can land on a disabled option; arrowing skips disabled but the seed
|
|
205
|
+
// does not. Resolve to the nearest enabled row and drive the highlight,
|
|
206
|
+
// `aria-activedescendant`, arrow moves and commit off *this* — never the
|
|
207
|
+
// raw index — so a disabled option is neither announced nor committable.
|
|
208
|
+
const resolvedActiveIndex =
|
|
209
|
+
filtered.length === 0
|
|
210
|
+
? -1
|
|
211
|
+
: filtered[activeIndex] !== undefined &&
|
|
212
|
+
!filtered[activeIndex]?.isDisabled
|
|
213
|
+
? activeIndex
|
|
214
|
+
: nextEnabledIndex(filtered, -1, 1)
|
|
215
|
+
|
|
216
|
+
const activeValue =
|
|
217
|
+
resolvedActiveIndex < 0
|
|
218
|
+
? undefined
|
|
219
|
+
: filtered[resolvedActiveIndex]?.value
|
|
220
|
+
|
|
221
|
+
const commitValue = (value: string) => {
|
|
222
|
+
if (isMultiple) {
|
|
223
|
+
setSelected((previous) =>
|
|
224
|
+
previous.includes(value)
|
|
225
|
+
? previous.filter((one) => one !== value)
|
|
226
|
+
: [...previous, value],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
onSelect(value)
|
|
230
|
+
|
|
231
|
+
// Multi-select stays open and clears the query for the next one.
|
|
232
|
+
setQuery("")
|
|
233
|
+
|
|
234
|
+
inputElement.current?.focus()
|
|
235
|
+
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
setSelected([value])
|
|
240
|
+
|
|
241
|
+
onSelect(value)
|
|
242
|
+
|
|
243
|
+
onDismiss()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const handleKeyDown = (
|
|
247
|
+
keyEvent: React.KeyboardEvent<HTMLInputElement>,
|
|
248
|
+
) => {
|
|
249
|
+
if (keyEvent.key === "ArrowDown") {
|
|
250
|
+
keyEvent.preventDefault()
|
|
251
|
+
|
|
252
|
+
setActiveIndex(
|
|
253
|
+
nextEnabledIndex(filtered, resolvedActiveIndex, 1),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (keyEvent.key === "ArrowUp") {
|
|
260
|
+
keyEvent.preventDefault()
|
|
261
|
+
|
|
262
|
+
setActiveIndex(
|
|
263
|
+
nextEnabledIndex(filtered, resolvedActiveIndex, -1),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (
|
|
270
|
+
keyEvent.key === "Enter" ||
|
|
271
|
+
// Tab-to-accept (`PathPicker`): the active suggestion is taken.
|
|
272
|
+
// Single-select only — in multi-select `commitValue` refocuses the
|
|
273
|
+
// input, so accepting on Tab would trap forward focus in the field.
|
|
274
|
+
(keyEvent.key === "Tab" &&
|
|
275
|
+
!keyEvent.shiftKey &&
|
|
276
|
+
!isMultiple &&
|
|
277
|
+
activeValue !== undefined)
|
|
278
|
+
) {
|
|
279
|
+
if (activeValue !== undefined) {
|
|
280
|
+
keyEvent.preventDefault()
|
|
281
|
+
|
|
282
|
+
commitValue(activeValue)
|
|
283
|
+
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Creatable: Enter on a query with no match commits it raw.
|
|
288
|
+
if (
|
|
289
|
+
isCreatable &&
|
|
290
|
+
keyEvent.key === "Enter" &&
|
|
291
|
+
currentQuery.trim().length > 0
|
|
292
|
+
) {
|
|
293
|
+
keyEvent.preventDefault()
|
|
294
|
+
|
|
295
|
+
commitValue(currentQuery.trim())
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (keyEvent.key === "Escape") {
|
|
302
|
+
// Clears the query first, closes only when it is already empty.
|
|
303
|
+
if (currentQuery.length > 0) {
|
|
304
|
+
keyEvent.preventDefault()
|
|
305
|
+
|
|
306
|
+
setQuery("")
|
|
307
|
+
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
onDismiss()
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Keep the active row in view — a real bug in every fleet picker:
|
|
316
|
+
// arrowing past the panel edge did not scroll.
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (!isVisible) {
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (isWindowed) {
|
|
323
|
+
rowVirtualizer.scrollToIndex(resolvedActiveIndex)
|
|
324
|
+
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (activeValue !== undefined) {
|
|
329
|
+
listElement.current
|
|
330
|
+
?.querySelector(
|
|
331
|
+
`#${CSS.escape(`${listboxId}-opt-${activeValue}`)}`,
|
|
332
|
+
)
|
|
333
|
+
?.scrollIntoView({ block: "nearest" })
|
|
334
|
+
}
|
|
335
|
+
}, [
|
|
336
|
+
resolvedActiveIndex,
|
|
337
|
+
activeValue,
|
|
338
|
+
isVisible,
|
|
339
|
+
isWindowed,
|
|
340
|
+
listboxId,
|
|
341
|
+
rowVirtualizer.scrollToIndex,
|
|
342
|
+
])
|
|
343
|
+
|
|
344
|
+
const renderOption = (
|
|
345
|
+
option: ListboxItem,
|
|
346
|
+
index: number,
|
|
347
|
+
) => (
|
|
348
|
+
<ComboboxOption
|
|
349
|
+
id={optionDomId(option.value)}
|
|
350
|
+
isActive={index === resolvedActiveIndex}
|
|
351
|
+
isSelected={selected.includes(option.value)}
|
|
352
|
+
item={option}
|
|
353
|
+
key={option.value}
|
|
354
|
+
onSelect={commitValue}
|
|
355
|
+
posInSet={isWindowed ? index + 1 : undefined}
|
|
356
|
+
setSize={isWindowed ? filtered.length : undefined}
|
|
357
|
+
/>
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<>
|
|
362
|
+
{clonedTrigger}
|
|
363
|
+
|
|
364
|
+
{isVisible ? (
|
|
365
|
+
<FloatingPortal>
|
|
366
|
+
<FloatingFocusManager
|
|
367
|
+
context={context}
|
|
368
|
+
initialFocus={inputElement}
|
|
369
|
+
modal={false}
|
|
370
|
+
>
|
|
371
|
+
<div
|
|
372
|
+
{...floatingProps}
|
|
373
|
+
className={toClassName(
|
|
374
|
+
PANEL_SURFACE_CLASS,
|
|
375
|
+
"z-[var(--layer-modal)] flex min-w-64 flex-col overflow-hidden",
|
|
376
|
+
className,
|
|
377
|
+
)}
|
|
378
|
+
ref={setFloating}
|
|
379
|
+
style={floatingStyles}
|
|
380
|
+
>
|
|
381
|
+
<div className="flex flex-wrap items-center gap-1 border-border-subtle border-b p-2">
|
|
382
|
+
{isMultiple
|
|
383
|
+
? selected.map((value) => {
|
|
384
|
+
const chip = options.find(
|
|
385
|
+
(option) => option.value === value,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<span
|
|
390
|
+
className="inline-flex items-center gap-1 rounded-sm bg-intent-neutral-surface px-1.5 py-0.5 text-content-primary text-xs"
|
|
391
|
+
key={value}
|
|
392
|
+
>
|
|
393
|
+
{chip?.textValue ?? value}
|
|
394
|
+
|
|
395
|
+
<button
|
|
396
|
+
aria-label={`Remove ${chip?.textValue ?? value}`}
|
|
397
|
+
className="cursor-pointer text-content-secondary hover:text-content-primary"
|
|
398
|
+
onClick={() => {
|
|
399
|
+
setSelected((previous) =>
|
|
400
|
+
previous.filter(
|
|
401
|
+
(one) => one !== value,
|
|
402
|
+
),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
// Mirror the list toggle-off: the parent
|
|
406
|
+
// tracks selection through `onSelect`, so a
|
|
407
|
+
// chip removal must report the same value.
|
|
408
|
+
onSelect(value)
|
|
409
|
+
}}
|
|
410
|
+
type="button"
|
|
411
|
+
>
|
|
412
|
+
✕
|
|
413
|
+
</button>
|
|
414
|
+
</span>
|
|
415
|
+
)
|
|
416
|
+
})
|
|
417
|
+
: null}
|
|
418
|
+
|
|
419
|
+
<input
|
|
420
|
+
aria-activedescendant={
|
|
421
|
+
activeValue === undefined
|
|
422
|
+
? undefined
|
|
423
|
+
: optionDomId(activeValue)
|
|
424
|
+
}
|
|
425
|
+
aria-autocomplete="list"
|
|
426
|
+
aria-controls={listboxId}
|
|
427
|
+
aria-expanded="true"
|
|
428
|
+
aria-labelledby={triggerId}
|
|
429
|
+
className="min-w-24 flex-1 bg-transparent text-content-primary text-sm outline-none placeholder:text-content-muted"
|
|
430
|
+
onChange={(changeEvent) => {
|
|
431
|
+
setQuery(changeEvent.target.value)
|
|
432
|
+
}}
|
|
433
|
+
onKeyDown={handleKeyDown}
|
|
434
|
+
placeholder={placeholder}
|
|
435
|
+
ref={inputElement}
|
|
436
|
+
role="combobox"
|
|
437
|
+
type="text"
|
|
438
|
+
value={currentQuery}
|
|
439
|
+
/>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
<div
|
|
443
|
+
aria-labelledby={triggerId}
|
|
444
|
+
className="min-h-0 flex-1 overflow-auto p-1"
|
|
445
|
+
id={listboxId}
|
|
446
|
+
ref={listElement}
|
|
447
|
+
role="listbox"
|
|
448
|
+
>
|
|
449
|
+
{isLoading ? (
|
|
450
|
+
<p className="px-2 py-3 text-center text-content-secondary text-sm">
|
|
451
|
+
Loading…
|
|
452
|
+
</p>
|
|
453
|
+
) : error ? (
|
|
454
|
+
<div className="px-2 py-3 text-center text-intent-danger-content text-sm">
|
|
455
|
+
{error}
|
|
456
|
+
</div>
|
|
457
|
+
) : filtered.length === 0 ? (
|
|
458
|
+
<p className="px-2 py-3 text-center text-content-secondary text-sm">
|
|
459
|
+
{emptyLabel}
|
|
460
|
+
</p>
|
|
461
|
+
) : isWindowed ? (
|
|
462
|
+
<div
|
|
463
|
+
className="relative w-full"
|
|
464
|
+
style={{
|
|
465
|
+
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
466
|
+
}}
|
|
467
|
+
>
|
|
468
|
+
{rowVirtualizer
|
|
469
|
+
.getVirtualItems()
|
|
470
|
+
.map((virtualRow) => {
|
|
471
|
+
const option =
|
|
472
|
+
filtered[virtualRow.index]
|
|
473
|
+
|
|
474
|
+
if (!option) {
|
|
475
|
+
return null
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return (
|
|
479
|
+
<div
|
|
480
|
+
className="absolute start-0 top-0 w-full"
|
|
481
|
+
key={option.value}
|
|
482
|
+
style={{
|
|
483
|
+
height: `${virtualRow.size}px`,
|
|
484
|
+
transform: `translateY(${virtualRow.start}px)`,
|
|
485
|
+
}}
|
|
486
|
+
>
|
|
487
|
+
{renderOption(
|
|
488
|
+
option,
|
|
489
|
+
virtualRow.index,
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
)
|
|
493
|
+
})}
|
|
494
|
+
</div>
|
|
495
|
+
) : (
|
|
496
|
+
filtered.map((option, index) =>
|
|
497
|
+
renderOption(option, index),
|
|
498
|
+
)
|
|
499
|
+
)}
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
{footer ? (
|
|
503
|
+
<div className="shrink-0 border-border-subtle border-t p-2 text-content-secondary text-xs">
|
|
504
|
+
{footer}
|
|
505
|
+
</div>
|
|
506
|
+
) : null}
|
|
507
|
+
</div>
|
|
508
|
+
</FloatingFocusManager>
|
|
509
|
+
</FloatingPortal>
|
|
510
|
+
) : null}
|
|
511
|
+
</>
|
|
512
|
+
)
|
|
513
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import type { ListboxItem } from "../Listbox/Listbox.tsx"
|
|
4
|
+
import { toClassName } from "../toClassName.ts"
|
|
5
|
+
|
|
6
|
+
export type ComboboxOptionProps = {
|
|
7
|
+
/** The stable DOM id `aria-activedescendant` points at. */
|
|
8
|
+
id: string
|
|
9
|
+
isActive: boolean
|
|
10
|
+
isSelected: boolean
|
|
11
|
+
item: ListboxItem
|
|
12
|
+
onSelect: (value: string) => void
|
|
13
|
+
/**
|
|
14
|
+
* `aria-posinset`/`aria-setsize`, required when the list is
|
|
15
|
+
* virtualized: most of it is not in the DOM, so a screen reader
|
|
16
|
+
* would otherwise announce "2 of 12" for a 4,000-item list.
|
|
17
|
+
*/
|
|
18
|
+
posInSet?: number
|
|
19
|
+
setSize?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `role="option"` with **`tabIndex={-1}`**, and both halves matter.
|
|
24
|
+
* The APG combobox keeps focus in the text input and tracks the
|
|
25
|
+
* active option with `aria-activedescendant`, so no option is ever a
|
|
26
|
+
* tab stop — `tabIndex={-1}` is that. It is a `<button>` (which
|
|
27
|
+
* ARIA-in-HTML allows `role="option"` on, and `Listbox` uses too)
|
|
28
|
+
* rather than a `<div>`, so `onClick` carries its own keyboard route
|
|
29
|
+
* and the package keeps its no-Biome-suppression record.
|
|
30
|
+
*
|
|
31
|
+
* The one caveat is not to run `expectAgentDrivable` on an individual
|
|
32
|
+
* option: its roving-tabindex rule would (correctly) reject a native
|
|
33
|
+
* control with `tabIndex={-1}`, and a combobox legitimately has zero
|
|
34
|
+
* tab stops in its popup. Tests query the options with `getByRole`.
|
|
35
|
+
*
|
|
36
|
+
* "Active" is a visual state only — the option the arrow keys have
|
|
37
|
+
* moved to, highlighted because nothing here holds real focus.
|
|
38
|
+
*/
|
|
39
|
+
export const ComboboxOption = ({
|
|
40
|
+
id,
|
|
41
|
+
isActive,
|
|
42
|
+
isSelected,
|
|
43
|
+
item,
|
|
44
|
+
onSelect,
|
|
45
|
+
posInSet,
|
|
46
|
+
setSize,
|
|
47
|
+
}: ComboboxOptionProps): ReactNode => {
|
|
48
|
+
const { isDisabled = false, label, value } = item
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
aria-posinset={posInSet}
|
|
53
|
+
aria-selected={isSelected}
|
|
54
|
+
aria-setsize={setSize}
|
|
55
|
+
className={toClassName(
|
|
56
|
+
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm bg-transparent px-2 py-1.5 text-start text-content-primary text-sm",
|
|
57
|
+
isActive && "bg-intent-neutral-surface",
|
|
58
|
+
isSelected && "bg-intent-accent-surface",
|
|
59
|
+
isDisabled &&
|
|
60
|
+
"cursor-not-allowed text-content-disabled",
|
|
61
|
+
)}
|
|
62
|
+
disabled={isDisabled}
|
|
63
|
+
id={id}
|
|
64
|
+
// `onMouseDown` rather than `onClick`: the input holds focus, and
|
|
65
|
+
// a click would blur it first. Prevented default keeps the caret
|
|
66
|
+
// in the input while the choice is made.
|
|
67
|
+
onMouseDown={(mouseEvent) => {
|
|
68
|
+
mouseEvent.preventDefault()
|
|
69
|
+
|
|
70
|
+
onSelect(value)
|
|
71
|
+
}}
|
|
72
|
+
role="option"
|
|
73
|
+
tabIndex={-1}
|
|
74
|
+
type="button"
|
|
75
|
+
>
|
|
76
|
+
{label}
|
|
77
|
+
|
|
78
|
+
{isSelected ? (
|
|
79
|
+
<span
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
className="shrink-0 text-intent-accent-content"
|
|
82
|
+
>
|
|
83
|
+
✓
|
|
84
|
+
</span>
|
|
85
|
+
) : null}
|
|
86
|
+
</button>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as DialogStories from "./Dialog.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={DialogStories} />
|
|
6
|
+
|
|
7
|
+
# Dialog
|
|
8
|
+
|
|
9
|
+
`Dialog` is [`Modal`](?path=/docs/components-modal--docs) plus the chrome: a heading, a
|
|
10
|
+
Close button, a scrolling body, an optional footer, and five sizes. This is what `Modal`
|
|
11
|
+
*was* before the M8 rebuild — a dialog is one kind of modal, so it takes the specific name
|
|
12
|
+
and hands the generic one back to the base layer
|
|
13
|
+
([decision](../../../docs/decisions/2026-08-03-modal-is-the-base-layer-and-dialog-is-the-component.md)).
|
|
14
|
+
|
|
15
|
+
<Canvas of={DialogStories.Default} />
|
|
16
|
+
|
|
17
|
+
## Props
|
|
18
|
+
|
|
19
|
+
<Controls />
|
|
20
|
+
|
|
21
|
+
## Everything modal is `Modal`'s
|
|
22
|
+
|
|
23
|
+
The portal, the scrim, the focus trap, the scroll lock, Escape, an outside press, and the
|
|
24
|
+
stacking all live in `Modal` (via `OverlayPanel`). `Dialog` is just the box's contents and
|
|
25
|
+
the size it wants — `SIZE_CLASS` composed over `Modal`'s default surface, which is why
|
|
26
|
+
`full` carries `rounded-none` to square off the base's `rounded-lg` at the viewport edge.
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
const { hide, isVisible, show } = useVisibility()
|
|
30
|
+
|
|
31
|
+
<Button onClick={show}>Stop the rip</Button>
|
|
32
|
+
|
|
33
|
+
<Dialog heading="Stop the rip?" isVisible={isVisible} onClose={hide}>
|
|
34
|
+
…
|
|
35
|
+
</Dialog>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`onClose` is every route out — the close button, Escape, a backdrop press — so there is
|
|
39
|
+
one place to wire and no way to end up with the element shut and the state still open.
|
|
40
|
+
`heading` is required: it names both the visible header and, through `aria-labelledby`,
|
|
41
|
+
the dialog itself.
|
|
42
|
+
|
|
43
|
+
<Canvas of={DialogStories.AllVariants} />
|
|
44
|
+
|
|
45
|
+
## Escape is pressable now
|
|
46
|
+
|
|
47
|
+
The old `Modal` was a native `<dialog>`, whose trusted `cancel` a story's synthetic
|
|
48
|
+
`userEvent` could not fire — so its tests dispatched a fake close request. `useDismiss`
|
|
49
|
+
listens for a plain keydown instead, so `userEvent.keyboard("{Escape}")` is a real close
|
|
50
|
+
request. `isDismissable={false}` disables it at the dismiss layer, so a dialog the app
|
|
51
|
+
still owns cannot be closed behind its back. `AllStates` pairs the refusal with a
|
|
52
|
+
differential — the dialog that ignores Escape is then closed by its own button — because
|
|
53
|
+
"still open" is also true of a dialog nothing can close at all.
|
|
54
|
+
|
|
55
|
+
<Canvas of={DialogStories.AllStates} />
|
|
56
|
+
|
|
57
|
+
## Stacking, and the one shared scrim
|
|
58
|
+
|
|
59
|
+
A lone dialog needs no setup. Stack one over another and an `OverlayStackProvider` at the
|
|
60
|
+
app root renders a **single** scrim behind both, keeps the second on top, marks the first
|
|
61
|
+
`inert`, and closes them top-first on Escape or a backdrop press. The scroll lock
|
|
62
|
+
ref-counts through the stack, so the page frees only when the last closes.
|
|
63
|
+
|
|
64
|
+
<Canvas of={DialogStories.Stacked} />
|
|
65
|
+
|
|
66
|
+
## `dvh`, and the scrim token
|
|
67
|
+
|
|
68
|
+
Every size clamps at `85dvh`; `full` takes the lot. `dvh` rather than `vh` because on a
|
|
69
|
+
phone the two differ by the address bar, and a dialog whose footer is under it is a dialog
|
|
70
|
+
with no buttons.
|
|
71
|
+
|
|
72
|
+
The scrim reads `--color-scrim`, a **variant** token: `legible` dims at 0.84 and
|
|
73
|
+
`daylight` at 0.66, because how hard a direction separates a dialog from the page is
|
|
74
|
+
exactly the sort of thing a visual direction decides. ePaper's is `transparent` — a panel
|
|
75
|
+
with no opacity cannot dim, so it says so instead of faking it. `Responsive` asserts the
|
|
76
|
+
computed scrim colour is really there, since a `var()` that failed to resolve would just
|
|
77
|
+
look like a design choice.
|
|
78
|
+
|
|
79
|
+
<Canvas of={DialogStories.Responsive} />
|
|
80
|
+
|
|
81
|
+
## A dialog is not a container-query citizen
|
|
82
|
+
|
|
83
|
+
It gets no `ContainerBoard`. Its size is measured against the viewport by definition — it
|
|
84
|
+
is the thing covering the viewport — so boarding it at three container widths would be
|
|
85
|
+
measuring something with no bearing on it.
|
|
86
|
+
|
|
87
|
+
<Canvas of={DialogStories.Interactive} />
|