@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,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} />