@lovett/ui 0.0.9 → 0.0.11

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 (55) hide show
  1. package/dist/index.d.ts +823 -135
  2. package/dist/index.js +2048 -358
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +44 -2
  5. package/dist/theme-v2.css +228 -0
  6. package/dist/tokens.css +123 -8
  7. package/package.json +1 -1
  8. package/src/__tests__/anchor.test.tsx +422 -0
  9. package/src/__tests__/combobox.test.tsx +677 -0
  10. package/src/__tests__/dropdown-menu.test.tsx +418 -0
  11. package/src/__tests__/helpers/geometry.ts +58 -0
  12. package/src/__tests__/layer-stack.test.tsx +228 -0
  13. package/src/__tests__/modal.test.tsx +180 -6
  14. package/src/__tests__/popover.test.tsx +460 -0
  15. package/src/__tests__/select.test.tsx +543 -0
  16. package/src/__tests__/tooltip.test.tsx +355 -0
  17. package/src/calculator-shell-v2.tsx +19 -39
  18. package/src/code-block.tsx +15 -26
  19. package/src/combobox.tsx +796 -0
  20. package/src/dropdown-menu.tsx +142 -152
  21. package/src/icons/brand.tsx +81 -2
  22. package/src/index.ts +111 -0
  23. package/src/lib/anchor.ts +427 -0
  24. package/src/lib/focus.ts +32 -0
  25. package/src/lib/layer-stack.ts +188 -0
  26. package/src/lib/refs.ts +31 -0
  27. package/src/metric-card.tsx +57 -22
  28. package/src/modal.tsx +149 -9
  29. package/src/page-shell.tsx +91 -2
  30. package/src/popover.tsx +407 -0
  31. package/src/segmented-pill.tsx +33 -10
  32. package/src/select.tsx +646 -0
  33. package/src/stat-row.tsx +108 -70
  34. package/src/styles.css +44 -2
  35. package/src/theme-v2.css +7 -245
  36. package/src/tokens.css +123 -8
  37. package/src/tooltip.tsx +297 -0
  38. package/src/react-syntax-highlighter-prism.d.ts +0 -34
  39. package/src/v2/README.md +0 -208
  40. package/src/v2/__demo__/showcase.tsx +0 -1045
  41. package/src/v2/action.tsx +0 -91
  42. package/src/v2/callout.tsx +0 -76
  43. package/src/v2/document-section.tsx +0 -82
  44. package/src/v2/document-shell.tsx +0 -0
  45. package/src/v2/field-row.tsx +0 -113
  46. package/src/v2/icons.tsx +0 -165
  47. package/src/v2/index.ts +0 -147
  48. package/src/v2/layout.tsx +0 -293
  49. package/src/v2/progress-track.tsx +0 -89
  50. package/src/v2/stat-tile.tsx +0 -129
  51. package/src/v2/states.tsx +0 -271
  52. package/src/v2/status-pill.tsx +0 -74
  53. package/src/v2/theme.css +0 -1861
  54. package/src/v2/timeline.tsx +0 -81
  55. package/src/v2/tokens.ts +0 -228
@@ -0,0 +1,677 @@
1
+ /**
2
+ * Combobox — APG editable combobox on lib/anchor + lib/layer-stack.
3
+ *
4
+ * Contract under test: input ARIA (role=combobox, aria-autocomplete=list,
5
+ * expanded, controls, activedescendant), listbox / option / group roles,
6
+ * client-side filtering that starts only on typing (an open single-select
7
+ * shows the whole list), `shouldFilter={false}` + `loading` for async, the
8
+ * keyboard model (arrows skipping disabled, Enter, Escape, Tab, Backspace
9
+ * removing chips), focus never leaving the input, single-mode revert /
10
+ * clear-on-empty, multi-mode chips and toggling, grouped options, the
11
+ * count and renderOption slots, controlled / uncontrolled value, input
12
+ * value and open state, layer-aware dismissal (Popover, Modal), and
13
+ * positioning with mocked geometry.
14
+ */
15
+ import { createRef, useState } from 'react'
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
17
+ import { render, screen } from '@testing-library/react'
18
+ import userEvent from '@testing-library/user-event'
19
+ import Modal from '../modal'
20
+ import { Popover } from '../popover'
21
+ import {
22
+ Combobox,
23
+ type ComboboxMultipleProps,
24
+ type ComboboxOption,
25
+ type ComboboxProps,
26
+ } from '../combobox'
27
+ import { mockGeometry, setViewport } from './helpers/geometry'
28
+
29
+ const PEOPLE: ComboboxOption[] = [
30
+ { value: 'ada', label: 'Ada Lovelace', description: 'ada@example.com', count: 4 },
31
+ { value: 'grace', label: 'Grace Hopper', count: 1234 },
32
+ { value: 'linus', label: 'Linus Torvalds', disabled: true },
33
+ { value: 'margaret', label: 'Margaret Hamilton', count: 0 },
34
+ ]
35
+
36
+ function Single(props: Partial<ComboboxProps>) {
37
+ return <Combobox aria-label="Assignee" options={PEOPLE} {...props} />
38
+ }
39
+
40
+ const input = () => screen.getByRole('combobox', { name: 'Assignee' })
41
+ const listbox = () => screen.getByRole('listbox')
42
+ const option = (name: string | RegExp) => screen.getByRole('option', { name })
43
+ const shell = () => document.querySelector<HTMLElement>('[data-slot="combobox"]')!
44
+
45
+ describe('Combobox', () => {
46
+ describe('rendering', () => {
47
+ it('renders a closed combobox input inside the input-shell', () => {
48
+ render(<Single placeholder="Search people…" id="assignee" />)
49
+ const i = input()
50
+ expect(i.tagName).toBe('INPUT')
51
+ expect(i).toHaveAttribute('role', 'combobox')
52
+ expect(i).toHaveAttribute('aria-autocomplete', 'list')
53
+ expect(i).toHaveAttribute('aria-haspopup', 'listbox')
54
+ expect(i).toHaveAttribute('aria-expanded', 'false')
55
+ expect(i).not.toHaveAttribute('aria-controls')
56
+ expect(i).toHaveAttribute('id', 'assignee')
57
+ expect(i).toHaveAttribute('placeholder', 'Search people…')
58
+ expect(i).toHaveAttribute('autocomplete', 'off')
59
+ expect(shell()).toHaveClass('input-shell')
60
+ expect(shell()).toHaveAttribute('data-state', 'closed')
61
+ expect(screen.queryByRole('listbox')).toBeNull()
62
+ })
63
+
64
+ it('size="sm" / error / disabled land on the shell; error also sets aria-invalid on the input', () => {
65
+ render(<Single size="sm" error disabled className="w-72" />)
66
+ expect(shell()).toHaveClass('size-sm', 'is-error', 'is-disabled', 'w-72')
67
+ expect(shell()).toHaveAttribute('data-size', 'sm')
68
+ expect(input()).toBeDisabled()
69
+ expect(input()).toHaveAttribute('aria-invalid', 'true')
70
+ })
71
+
72
+ it('without error the input carries no aria-invalid', () => {
73
+ render(<Single />)
74
+ expect(input()).not.toHaveAttribute('aria-invalid')
75
+ })
76
+
77
+ it('forwards a ref to the input', () => {
78
+ const ref = createRef<HTMLInputElement>()
79
+ render(<Combobox ref={ref} aria-label="Assignee" options={PEOPLE} />)
80
+ expect(ref.current).toBe(input())
81
+ })
82
+
83
+ it('autoFocus focuses the input on mount and marks it data-autofocus for Popover', () => {
84
+ render(<Single autoFocus />)
85
+ expect(document.activeElement).toBe(input())
86
+ expect(input()).toHaveAttribute('data-autofocus')
87
+ })
88
+
89
+ it('shows the selected label in single mode', () => {
90
+ render(<Single value="grace" />)
91
+ expect(input()).toHaveValue('Grace Hopper')
92
+ })
93
+ })
94
+
95
+ describe('opening + filtering', () => {
96
+ it('typing opens the list and filters by label, case-insensitively', async () => {
97
+ render(<Single />)
98
+ await userEvent.type(input(), 'gra')
99
+ expect(input()).toHaveAttribute('aria-expanded', 'true')
100
+ expect(input()).toHaveAttribute('aria-controls', listbox().id)
101
+ expect(screen.getAllByRole('option')).toHaveLength(1)
102
+ expect(option(/^Grace Hopper/)).toBeInTheDocument()
103
+ })
104
+
105
+ it('the default filter also matches the description', async () => {
106
+ render(<Single />)
107
+ await userEvent.type(input(), 'ada@')
108
+ expect(screen.getAllByRole('option')).toHaveLength(1)
109
+ expect(option(/^Ada Lovelace/)).toBeInTheDocument()
110
+ })
111
+
112
+ it('shows the empty message (default and custom) when nothing matches', async () => {
113
+ const { unmount } = render(<Single />)
114
+ await userEvent.type(input(), 'zzz')
115
+ expect(listbox()).toHaveTextContent('No results')
116
+ expect(screen.queryByRole('option')).toBeNull()
117
+ unmount()
118
+
119
+ render(<Single emptyMessage="Nobody here" />)
120
+ await userEvent.type(input(), 'zzz')
121
+ expect(listbox()).toHaveTextContent('Nobody here')
122
+ })
123
+
124
+ it('shouldFilter={false} shows the options exactly as given', async () => {
125
+ render(<Single shouldFilter={false} />)
126
+ await userEvent.type(input(), 'zzz')
127
+ expect(screen.getAllByRole('option')).toHaveLength(4)
128
+ })
129
+
130
+ it('a custom filter replaces the default', async () => {
131
+ render(<Single filter={(o, q) => o.value.startsWith(q)} />)
132
+ await userEvent.type(input(), 'mar')
133
+ expect(screen.getAllByRole('option')).toHaveLength(1)
134
+ expect(option(/^Margaret/)).toBeInTheDocument()
135
+ })
136
+
137
+ it('clicking the input opens the full list; the chevron toggles it without taking focus', async () => {
138
+ render(<Single />)
139
+ await userEvent.click(input())
140
+ expect(screen.getAllByRole('option')).toHaveLength(4)
141
+ expect(document.activeElement).toBe(input())
142
+ const chevron = screen.getByRole('button', { name: 'Close options' })
143
+ await userEvent.click(chevron)
144
+ expect(screen.queryByRole('listbox')).toBeNull()
145
+ expect(document.activeElement).toBe(input())
146
+ await userEvent.click(screen.getByRole('button', { name: 'Open options' }))
147
+ expect(listbox()).toBeInTheDocument()
148
+ })
149
+
150
+ it('an open single-select with a value shows the whole list, not the one label match', async () => {
151
+ render(<Single value="grace" />)
152
+ await userEvent.click(input())
153
+ expect(screen.getAllByRole('option')).toHaveLength(4)
154
+ expect(option(/^Grace Hopper/)).toHaveAttribute('aria-selected', 'true')
155
+ expect(input()).toHaveAttribute('aria-activedescendant', option(/^Grace Hopper/).id)
156
+ })
157
+
158
+ it('loading: aria-busy on the listbox, spinner in the shell, loading message while empty', async () => {
159
+ const { unmount } = render(<Single loading options={[]} />)
160
+ await userEvent.click(input())
161
+ expect(listbox()).toHaveAttribute('aria-busy', 'true')
162
+ expect(listbox()).toHaveTextContent('Loading…')
163
+ expect(shell().querySelector('.animate-spin')).not.toBeNull()
164
+ unmount()
165
+
166
+ render(<Single loading loadingMessage="Fetching people" options={[]} />)
167
+ await userEvent.click(input())
168
+ expect(listbox()).toHaveTextContent('Fetching people')
169
+ })
170
+
171
+ it('reports the query through onInputValueChange and follows a controlled inputValue', async () => {
172
+ const onInputValueChange = vi.fn()
173
+ const { rerender } = render(
174
+ <Single inputValue="" onInputValueChange={onInputValueChange} />,
175
+ )
176
+ await userEvent.type(input(), 'g')
177
+ expect(onInputValueChange).toHaveBeenCalledWith('g')
178
+ // The parent did not adopt it: the input stays as controlled.
179
+ expect(input()).toHaveValue('')
180
+ rerender(<Single inputValue="gra" onInputValueChange={onInputValueChange} />)
181
+ expect(input()).toHaveValue('gra')
182
+ expect(screen.getAllByRole('option')).toHaveLength(1)
183
+ })
184
+ })
185
+
186
+ describe('rows', () => {
187
+ it('renders the count slot with locale formatting and the description line', async () => {
188
+ render(<Single />)
189
+ await userEvent.click(input())
190
+ expect(option(/^Grace Hopper/)).toHaveTextContent('1,234')
191
+ expect(option(/^Grace Hopper/).querySelector('.tabular-nums')).toHaveTextContent('1,234')
192
+ expect(option(/^Ada Lovelace/)).toHaveTextContent('ada@example.com')
193
+ expect(option(/^Ada Lovelace/)).toHaveTextContent('4')
194
+ expect(option(/^Margaret/)).toHaveTextContent('0')
195
+ })
196
+
197
+ it('renderOption replaces the row body and receives the row state + query', async () => {
198
+ const renderOption = vi.fn((o: ComboboxOption, state: { selected: boolean; highlighted: boolean; query: string }) => (
199
+ <span data-testid={`row-${o.value}`}>
200
+ {o.label.toUpperCase()} {state.selected ? 'selected' : ''} {state.highlighted ? 'hl' : ''} q={state.query}
201
+ </span>
202
+ ))
203
+ render(<Single value="grace" renderOption={renderOption} />)
204
+ await userEvent.type(input(), 'gr')
205
+ const row = screen.getByTestId('row-grace')
206
+ expect(row).toHaveTextContent('GRACE HOPPER selected hl q=gr')
207
+ // The default body (count) is gone.
208
+ expect(row.closest('[role="option"]')).not.toHaveTextContent('1,234')
209
+ })
210
+
211
+ it('groups: `groups` fixes order + labels, ungrouped first, unknown keys trail', async () => {
212
+ const options: ComboboxOption[] = [
213
+ { value: 'a', label: 'Archived', group: 'closed' },
214
+ { value: 'n', label: 'None' },
215
+ { value: 't', label: 'To do', group: 'open' },
216
+ { value: 'd', label: 'Done', group: 'closed' },
217
+ { value: 'x', label: 'Odd', group: 'mystery' },
218
+ ]
219
+ render(
220
+ <Single
221
+ options={options}
222
+ groups={[
223
+ { id: 'open', label: 'Open' },
224
+ { id: 'closed', label: 'Closed' },
225
+ ]}
226
+ />,
227
+ )
228
+ await userEvent.click(input())
229
+ const groups = screen.getAllByRole('group')
230
+ expect(groups.map((g) => g.getAttribute('aria-labelledby'))).toHaveLength(3)
231
+ expect(screen.getByRole('group', { name: 'Open' })).toBeInTheDocument()
232
+ expect(screen.getByRole('group', { name: 'Closed' })).toBeInTheDocument()
233
+ expect(screen.getByRole('group', { name: 'mystery' })).toBeInTheDocument()
234
+ const names = screen.getAllByRole('option').map((o) => o.textContent)
235
+ expect(names).toEqual(['None', 'To do', 'Archived', 'Done', 'Odd'])
236
+ })
237
+
238
+ it('groups: derived from the options in first-appearance order when `groups` is omitted', async () => {
239
+ render(
240
+ <Single
241
+ options={[
242
+ { value: 'b1', label: 'B one', group: 'B' },
243
+ { value: 'a1', label: 'A one', group: 'A' },
244
+ { value: 'b2', label: 'B two', group: 'B' },
245
+ ]}
246
+ />,
247
+ )
248
+ await userEvent.click(input())
249
+ expect(screen.getAllByRole('group').map((g) => g.getAttribute('aria-labelledby')?.length)).toHaveLength(2)
250
+ expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['B one', 'B two', 'A one'])
251
+ })
252
+
253
+ it('arrow keys walk grouped options in visual order', async () => {
254
+ render(
255
+ <Single
256
+ options={[
257
+ { value: 'n', label: 'None' },
258
+ { value: 't', label: 'To do', group: 'open' },
259
+ { value: 'd', label: 'Done', group: 'closed' },
260
+ ]}
261
+ />,
262
+ )
263
+ await userEvent.click(input())
264
+ expect(input()).toHaveAttribute('aria-activedescendant', option('None').id)
265
+ await userEvent.keyboard('{ArrowDown}')
266
+ expect(input()).toHaveAttribute('aria-activedescendant', option('To do').id)
267
+ await userEvent.keyboard('{ArrowDown}')
268
+ expect(input()).toHaveAttribute('aria-activedescendant', option('Done').id)
269
+ })
270
+ })
271
+
272
+ describe('keyboard (single)', () => {
273
+ it('ArrowDown opens on the first enabled option; arrows skip disabled and stop at the ends', async () => {
274
+ render(<Single />)
275
+ input().focus()
276
+ await userEvent.keyboard('{ArrowDown}')
277
+ expect(listbox()).toBeInTheDocument()
278
+ expect(input()).toHaveAttribute('aria-activedescendant', option(/^Ada/).id)
279
+ await userEvent.keyboard('{ArrowDown}')
280
+ expect(input()).toHaveAttribute('aria-activedescendant', option(/^Grace/).id)
281
+ // Linus is disabled — skipped.
282
+ await userEvent.keyboard('{ArrowDown}')
283
+ expect(input()).toHaveAttribute('aria-activedescendant', option(/^Margaret/).id)
284
+ await userEvent.keyboard('{ArrowDown}')
285
+ expect(input()).toHaveAttribute('aria-activedescendant', option(/^Margaret/).id)
286
+ await userEvent.keyboard('{ArrowUp}{ArrowUp}')
287
+ expect(input()).toHaveAttribute('aria-activedescendant', option(/^Ada/).id)
288
+ await userEvent.keyboard('{ArrowUp}')
289
+ expect(input()).toHaveAttribute('aria-activedescendant', option(/^Ada/).id)
290
+ expect(document.activeElement).toBe(input())
291
+ })
292
+
293
+ it('ArrowUp also opens the list', async () => {
294
+ render(<Single />)
295
+ input().focus()
296
+ await userEvent.keyboard('{ArrowUp}')
297
+ expect(listbox()).toBeInTheDocument()
298
+ })
299
+
300
+ it('Enter picks the highlighted option: value, label in the input, list closed, focus kept', async () => {
301
+ const onValueChange = vi.fn()
302
+ render(<Single onValueChange={onValueChange} />)
303
+ await userEvent.type(input(), 'gr')
304
+ await userEvent.keyboard('{Enter}')
305
+ expect(onValueChange).toHaveBeenCalledWith('grace')
306
+ expect(onValueChange).toHaveBeenCalledTimes(1)
307
+ expect(input()).toHaveValue('Grace Hopper')
308
+ expect(input()).toHaveAttribute('aria-expanded', 'false')
309
+ expect(screen.queryByRole('listbox')).toBeNull()
310
+ expect(document.activeElement).toBe(input())
311
+ })
312
+
313
+ it('Enter while open keeps an enclosing form from submitting', async () => {
314
+ const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault())
315
+ render(
316
+ <form onSubmit={onSubmit}>
317
+ <Single />
318
+ </form>,
319
+ )
320
+ await userEvent.type(input(), 'gr{Enter}')
321
+ expect(onSubmit).not.toHaveBeenCalled()
322
+ })
323
+
324
+ it('Escape closes and REVERTS an edited query to the selected label; no value change', async () => {
325
+ const onValueChange = vi.fn()
326
+ render(<Single value="grace" onValueChange={onValueChange} />)
327
+ await userEvent.click(input())
328
+ await userEvent.clear(input())
329
+ await userEvent.keyboard('{Escape}')
330
+ expect(screen.queryByRole('listbox')).toBeNull()
331
+ expect(input()).toHaveValue('Grace Hopper')
332
+ expect(onValueChange).not.toHaveBeenCalled()
333
+ expect(document.activeElement).toBe(input())
334
+ })
335
+
336
+ it('leaving the field with the text emptied clears the value; leaving with text reverts', async () => {
337
+ const onValueChange = vi.fn()
338
+ const { unmount } = render(
339
+ <>
340
+ <Single value="grace" onValueChange={onValueChange} />
341
+ <button type="button">Next</button>
342
+ </>,
343
+ )
344
+ await userEvent.click(input())
345
+ await userEvent.clear(input())
346
+ await userEvent.tab()
347
+ expect(onValueChange).toHaveBeenCalledWith(null)
348
+ expect(screen.queryByRole('listbox')).toBeNull()
349
+ unmount()
350
+
351
+ const onRevert = vi.fn()
352
+ render(
353
+ <>
354
+ <Single value="grace" onValueChange={onRevert} />
355
+ <button type="button">Next</button>
356
+ </>,
357
+ )
358
+ await userEvent.type(input(), 'xyz')
359
+ await userEvent.tab()
360
+ expect(onRevert).not.toHaveBeenCalled()
361
+ expect(input()).toHaveValue('Grace Hopper')
362
+ })
363
+
364
+ it('focusing selects the label text so typing replaces it', async () => {
365
+ render(
366
+ <>
367
+ <button type="button">Before</button>
368
+ <Single value="grace" />
369
+ </>,
370
+ )
371
+ screen.getByRole('button', { name: 'Before' }).focus()
372
+ await userEvent.tab()
373
+ const i = input() as HTMLInputElement
374
+ expect(document.activeElement).toBe(i)
375
+ expect(i.selectionStart).toBe(0)
376
+ expect(i.selectionEnd).toBe('Grace Hopper'.length)
377
+ await userEvent.keyboard('marg')
378
+ expect(i).toHaveValue('marg')
379
+ expect(screen.getAllByRole('option')).toHaveLength(1)
380
+ expect(option(/^Margaret/)).toBeInTheDocument()
381
+ })
382
+
383
+ it('does not listen for Escape while closed', async () => {
384
+ const onOpenChange = vi.fn()
385
+ render(<Single onOpenChange={onOpenChange} />)
386
+ input().focus()
387
+ await userEvent.keyboard('{Escape}')
388
+ expect(onOpenChange).not.toHaveBeenCalled()
389
+ })
390
+ })
391
+
392
+ describe('mouse (single)', () => {
393
+ it('clicking an option picks it and keeps focus in the input', async () => {
394
+ const onValueChange = vi.fn()
395
+ render(<Single onValueChange={onValueChange} />)
396
+ await userEvent.click(input())
397
+ await userEvent.click(option(/^Margaret/))
398
+ expect(onValueChange).toHaveBeenCalledWith('margaret')
399
+ expect(input()).toHaveValue('Margaret Hamilton')
400
+ expect(document.activeElement).toBe(input())
401
+ expect(screen.queryByRole('listbox')).toBeNull()
402
+ })
403
+
404
+ it('hovering moves the highlight; a disabled option cannot be picked and does not steal focus', async () => {
405
+ const onValueChange = vi.fn()
406
+ render(<Single onValueChange={onValueChange} />)
407
+ await userEvent.click(input())
408
+ await userEvent.hover(option(/^Margaret/))
409
+ expect(input()).toHaveAttribute('aria-activedescendant', option(/^Margaret/).id)
410
+ expect(option(/^Linus/)).toHaveAttribute('aria-disabled', 'true')
411
+ // Hovering a disabled row leaves the highlight where it was.
412
+ await userEvent.hover(option(/^Linus/))
413
+ expect(input()).toHaveAttribute('aria-activedescendant', option(/^Margaret/).id)
414
+ await userEvent.click(option(/^Linus/))
415
+ expect(onValueChange).not.toHaveBeenCalled()
416
+ expect(listbox()).toBeInTheDocument()
417
+ expect(document.activeElement).toBe(input())
418
+ })
419
+
420
+ it('closes on an outside click', async () => {
421
+ render(
422
+ <>
423
+ <Single />
424
+ <button type="button">Elsewhere</button>
425
+ </>,
426
+ )
427
+ await userEvent.click(input())
428
+ await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' }))
429
+ expect(screen.queryByRole('listbox')).toBeNull()
430
+ })
431
+ })
432
+
433
+ describe('multiple', () => {
434
+ type MultiRest = Omit<
435
+ Partial<ComboboxMultipleProps>,
436
+ 'multiple' | 'value' | 'defaultValue' | 'onValueChange'
437
+ >
438
+ function Multi(props: MultiRest & { onChange?: (v: string[]) => void }) {
439
+ const { onChange, ...rest } = props
440
+ const [value, setValue] = useState<string[]>(['ada'])
441
+ return (
442
+ <>
443
+ <span data-testid="value">{value.join(',')}</span>
444
+ <Combobox
445
+ multiple
446
+ aria-label="Assignee"
447
+ options={PEOPLE}
448
+ value={value}
449
+ onValueChange={(next) => {
450
+ setValue(next)
451
+ onChange?.(next)
452
+ }}
453
+ {...rest}
454
+ />
455
+ </>
456
+ )
457
+ }
458
+ const chip = (label: string) => screen.getByRole('button', { name: `Remove ${label}` })
459
+
460
+ it('renders chips for the selection and marks the listbox multiselectable', async () => {
461
+ render(<Multi />)
462
+ expect(chip('Ada Lovelace')).toBeInTheDocument()
463
+ expect(shell()).toHaveClass('has-leading')
464
+ expect(shell().style.height).toBe('auto')
465
+ await userEvent.click(input())
466
+ expect(listbox()).toHaveAttribute('aria-multiselectable', 'true')
467
+ expect(option(/^Ada/)).toHaveAttribute('aria-selected', 'true')
468
+ expect(option(/^Grace/)).toHaveAttribute('aria-selected', 'false')
469
+ })
470
+
471
+ it('picking toggles membership and keeps the list open; the query is kept', async () => {
472
+ const onChange = vi.fn()
473
+ render(<Multi onChange={onChange} />)
474
+ await userEvent.type(input(), 'a')
475
+ await userEvent.click(option(/^Grace/))
476
+ expect(onChange).toHaveBeenLastCalledWith(['ada', 'grace'])
477
+ expect(listbox()).toBeInTheDocument()
478
+ expect(input()).toHaveValue('a')
479
+ expect(chip('Grace Hopper')).toBeInTheDocument()
480
+ await userEvent.click(option(/^Ada/))
481
+ expect(onChange).toHaveBeenLastCalledWith(['grace'])
482
+ expect(screen.queryByRole('button', { name: 'Remove Ada Lovelace' })).toBeNull()
483
+ expect(document.activeElement).toBe(input())
484
+ })
485
+
486
+ it('Enter toggles the highlighted option', async () => {
487
+ render(<Multi />)
488
+ input().focus()
489
+ await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}')
490
+ expect(screen.getByTestId('value')).toHaveTextContent('ada,grace')
491
+ expect(listbox()).toBeInTheDocument()
492
+ })
493
+
494
+ it('Backspace on an empty input removes the last chip', async () => {
495
+ render(<Multi />)
496
+ input().focus()
497
+ await userEvent.keyboard('{Backspace}')
498
+ expect(screen.getByTestId('value')).toHaveTextContent('')
499
+ expect(screen.queryByRole('button', { name: /Remove/ })).toBeNull()
500
+ })
501
+
502
+ it('Backspace with text in the input edits the text, not the chips', async () => {
503
+ render(<Multi />)
504
+ await userEvent.type(input(), 'ab')
505
+ await userEvent.keyboard('{Backspace}')
506
+ expect(input()).toHaveValue('a')
507
+ expect(chip('Ada Lovelace')).toBeInTheDocument()
508
+ })
509
+
510
+ it('the chip remove button removes it and refocuses the input', async () => {
511
+ render(<Multi />)
512
+ await userEvent.click(chip('Ada Lovelace'))
513
+ expect(screen.getByTestId('value')).toHaveTextContent('')
514
+ expect(document.activeElement).toBe(input())
515
+ })
516
+
517
+ it('closing clears the query text', async () => {
518
+ render(<Multi />)
519
+ await userEvent.type(input(), 'gr')
520
+ await userEvent.keyboard('{Escape}')
521
+ expect(input()).toHaveValue('')
522
+ })
523
+
524
+ it('falls back to the raw value for a chip whose option is absent', () => {
525
+ render(<Combobox multiple aria-label="Assignee" options={[]} value={['ghost']} />)
526
+ expect(screen.getByRole('button', { name: 'Remove ghost' })).toBeInTheDocument()
527
+ })
528
+
529
+ it('uncontrolled multi: defaultValue seeds and picks accumulate', async () => {
530
+ render(<Combobox multiple aria-label="Assignee" options={PEOPLE} defaultValue={['grace']} />)
531
+ expect(chip('Grace Hopper')).toBeInTheDocument()
532
+ await userEvent.click(input())
533
+ await userEvent.click(option(/^Margaret/))
534
+ expect(chip('Margaret Hamilton')).toBeInTheDocument()
535
+ expect(chip('Grace Hopper')).toBeInTheDocument()
536
+ })
537
+ })
538
+
539
+ describe('controlled / uncontrolled (single)', () => {
540
+ it('uncontrolled: defaultValue seeds the label and picks update it', async () => {
541
+ render(<Single defaultValue="ada" />)
542
+ expect(input()).toHaveValue('Ada Lovelace')
543
+ await userEvent.click(input())
544
+ await userEvent.click(option(/^Grace/))
545
+ expect(input()).toHaveValue('Grace Hopper')
546
+ })
547
+
548
+ it('controlled: a parent that ignores onValueChange keeps the old label', async () => {
549
+ const onValueChange = vi.fn()
550
+ render(<Single value="ada" onValueChange={onValueChange} />)
551
+ await userEvent.click(input())
552
+ await userEvent.click(option(/^Grace/))
553
+ expect(onValueChange).toHaveBeenCalledWith('grace')
554
+ expect(input()).toHaveValue('Ada Lovelace')
555
+ })
556
+
557
+ it('controlled open: reports through onOpenChange and follows `open`', async () => {
558
+ function ControlledOpen() {
559
+ const [open, setOpen] = useState(false)
560
+ return (
561
+ <>
562
+ <span data-testid="state">{open ? 'open' : 'closed'}</span>
563
+ <Single open={open} onOpenChange={setOpen} />
564
+ </>
565
+ )
566
+ }
567
+ render(<ControlledOpen />)
568
+ await userEvent.click(input())
569
+ expect(screen.getByTestId('state')).toHaveTextContent('open')
570
+ await userEvent.keyboard('{Escape}')
571
+ expect(screen.getByTestId('state')).toHaveTextContent('closed')
572
+ expect(screen.queryByRole('listbox')).toBeNull()
573
+ })
574
+ })
575
+
576
+ describe('layers', () => {
577
+ it('inside a Modal, Escape closes only the list; the next Escape closes the modal', async () => {
578
+ const onClose = vi.fn()
579
+ render(
580
+ <Modal isOpen onClose={onClose} title="Settings">
581
+ <Single />
582
+ </Modal>,
583
+ )
584
+ await userEvent.click(input())
585
+ await userEvent.keyboard('{Escape}')
586
+ expect(screen.queryByRole('listbox')).toBeNull()
587
+ expect(onClose).not.toHaveBeenCalled()
588
+ await userEvent.keyboard('{Escape}')
589
+ expect(onClose).toHaveBeenCalledTimes(1)
590
+ })
591
+
592
+ it('inside a Popover, picking an option does not close the popover; autoFocus lands on the input', async () => {
593
+ const onValueChange = vi.fn()
594
+ render(
595
+ <Popover defaultOpen>
596
+ <Popover.Trigger>Open</Popover.Trigger>
597
+ <Popover.Content aria-label="Assign">
598
+ <button type="button">Decoy</button>
599
+ <Single autoFocus onValueChange={onValueChange} />
600
+ </Popover.Content>
601
+ </Popover>,
602
+ )
603
+ expect(document.activeElement).toBe(input())
604
+ await userEvent.type(input(), 'mar')
605
+ await userEvent.click(option(/^Margaret/))
606
+ expect(onValueChange).toHaveBeenCalledWith('margaret')
607
+ expect(screen.getByRole('dialog', { name: 'Assign' })).toBeInTheDocument()
608
+ await userEvent.keyboard('{Escape}')
609
+ expect(screen.queryByRole('dialog')).toBeNull()
610
+ })
611
+
612
+ it('inside a Popover, autoFocus waits for the popover to be positioned - the parked frame cannot take focus in a browser', () => {
613
+ // The Combobox mount effect runs BEFORE the popover has measured; in a
614
+ // browser that focus() is refused (visibility:hidden), so it must not
615
+ // fire, and the popover's own positioned focus lands instead. jsdom
616
+ // would honour the early call, hence recording the state at focus time.
617
+ const atFocus: string[] = []
618
+ const onFocus = (event: FocusEvent) => {
619
+ const target = event.target
620
+ if (target instanceof HTMLElement && target.getAttribute('role') === 'combobox') {
621
+ atFocus.push(
622
+ target.closest('[data-slot="popover-content"]')?.getAttribute('data-positioned') ??
623
+ 'missing',
624
+ )
625
+ }
626
+ }
627
+ document.addEventListener('focus', onFocus, true)
628
+ try {
629
+ render(
630
+ <Popover defaultOpen>
631
+ <Popover.Trigger>Open</Popover.Trigger>
632
+ <Popover.Content aria-label="Assign">
633
+ <button type="button">Decoy</button>
634
+ <Single autoFocus />
635
+ </Popover.Content>
636
+ </Popover>,
637
+ )
638
+ expect(document.activeElement).toBe(input())
639
+ expect(atFocus).toEqual(['true'])
640
+ } finally {
641
+ document.removeEventListener('focus', onFocus, true)
642
+ }
643
+ })
644
+ })
645
+
646
+ describe('positioning (mocked geometry)', () => {
647
+ let restore: () => void
648
+ beforeEach(() => {
649
+ restore = mockGeometry()
650
+ setViewport(1000, 800)
651
+ })
652
+ afterEach(() => restore())
653
+
654
+ it('opens under the shell, at least as wide as it', async () => {
655
+ render(<Single listboxClassName="w-80" />)
656
+ shell().setAttribute('data-rect', '400,300,240,40')
657
+ shell().setAttribute('data-size', '240,40')
658
+ await userEvent.click(input())
659
+ const list = listbox()
660
+ expect(list.style.position).toBe('fixed')
661
+ expect(list.style.left).toBe('400px')
662
+ expect(list.style.top).toBe('346px')
663
+ expect(list.style.minWidth).toBe('240px')
664
+ expect(list).toHaveAttribute('data-side', 'bottom')
665
+ expect(list).toHaveAttribute('data-positioned', 'true')
666
+ expect(list).toHaveClass('ds-enter-pop', 'w-80')
667
+ expect(list.style.background).toContain('--popover')
668
+ })
669
+
670
+ it('flips above the shell at the bottom of the viewport', async () => {
671
+ render(<Single />)
672
+ shell().setAttribute('data-rect', '400,780,240,40')
673
+ await userEvent.click(input())
674
+ expect(listbox()).toHaveAttribute('data-side', 'top')
675
+ })
676
+ })
677
+ })