@open-mercato/ui 0.6.3-develop.3838.1.7f2657a47c → 0.6.3-develop.3857.1.da89d7530c

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.
@@ -1,8 +1,10 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
+ import { X } from 'lucide-react'
4
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
5
6
  import { Button } from '../../primitives/button'
7
+ import { IconButton } from '../../primitives/icon-button'
6
8
 
7
9
  export type ComboboxOption = {
8
10
  value: string
@@ -28,6 +30,8 @@ export type ComboboxInputProps = {
28
30
  autoFocus?: boolean
29
31
  disabled?: boolean
30
32
  allowCustomValues?: boolean
33
+ clearable?: boolean
34
+ clearLabel?: string
31
35
  }
32
36
 
33
37
  function normalizeOptions(input?: Array<string | ComboboxOption>): ComboboxOption[] {
@@ -72,10 +76,15 @@ export function ComboboxInput({
72
76
  autoFocus,
73
77
  disabled = false,
74
78
  allowCustomValues = true,
79
+ clearable = false,
80
+ clearLabel,
75
81
  }: ComboboxInputProps) {
76
82
  const t = useT()
77
83
  const resolvedPlaceholder = placeholder ?? t('ui.inputs.comboboxInput.placeholder', 'Type to search...')
78
84
  const loadingLabel = t('ui.inputs.comboboxInput.loading', 'Loading suggestions…')
85
+ const resolvedClearLabel = clearLabel ?? t('ui.inputs.comboboxInput.clear', 'Clear value')
86
+ const blurCloseDelayMs = 250
87
+ const blurCloseMaxDelayMs = 1000
79
88
  const [input, setInput] = React.useState('')
80
89
  const [asyncOptions, setAsyncOptions] = React.useState<ComboboxOption[]>([])
81
90
  const [resolvedOptions, setResolvedOptions] = React.useState<ComboboxOption[]>([])
@@ -84,6 +93,9 @@ export function ComboboxInput({
84
93
  const [showSuggestions, setShowSuggestions] = React.useState(false)
85
94
  const [selectedIndex, setSelectedIndex] = React.useState(-1)
86
95
  const inputRef = React.useRef<HTMLInputElement>(null)
96
+ const loadingRef = React.useRef(false)
97
+ const blurCloseTimerRef = React.useRef<number | null>(null)
98
+ const blurClosePendingRef = React.useRef(false)
87
99
  const suppressOpenOnFocusRef = React.useRef(Boolean(autoFocus && !disabled))
88
100
  const eagerFallbackLoadedValueRef = React.useRef<string | null>(null)
89
101
 
@@ -92,22 +104,18 @@ export function ComboboxInput({
92
104
  [seedOptions, suggestions]
93
105
  )
94
106
 
95
- // Options whose label is genuinely known (not a self-mapping `value === label`
96
- // placeholder). Used to decide whether eager resolution still needs to run.
97
- const knownLabelValues = React.useMemo(() => {
98
- const set = new Set<string>()
99
- for (const option of [...staticOptions, ...asyncOptions, ...resolvedOptions]) {
100
- if (option.label && option.label !== option.value) set.add(option.value)
107
+ // Single pass over all option sources to build both coverage sets at once.
108
+ // knownLabelValues: values with a genuine label (not a self-mapping placeholder) —
109
+ // used to decide whether eager resolution still needs to run.
110
+ // coveredOptionValues: all values present in any source (even self-mapped).
111
+ const { knownLabelValues, coveredOptionValues } = React.useMemo(() => {
112
+ const known = new Set<string>()
113
+ const covered = new Set<string>()
114
+ for (const opt of [...staticOptions, ...asyncOptions, ...resolvedOptions]) {
115
+ covered.add(opt.value)
116
+ if (opt.label && opt.label !== opt.value) known.add(opt.value)
101
117
  }
102
- return set
103
- }, [staticOptions, asyncOptions, resolvedOptions])
104
-
105
- const coveredOptionValues = React.useMemo(() => {
106
- const set = new Set<string>()
107
- for (const option of [...staticOptions, ...asyncOptions, ...resolvedOptions]) {
108
- set.add(option.value)
109
- }
110
- return set
118
+ return { knownLabelValues: known, coveredOptionValues: covered }
111
119
  }, [staticOptions, asyncOptions, resolvedOptions])
112
120
 
113
121
  const optionMap = React.useMemo(() => {
@@ -139,6 +147,23 @@ export function ComboboxInput({
139
147
  return Array.from(optionMap.values())
140
148
  }, [optionMap])
141
149
 
150
+ React.useEffect(() => {
151
+ loadingRef.current = loading
152
+ }, [loading])
153
+
154
+ const clearBlurCloseTimer = React.useCallback(() => {
155
+ if (blurCloseTimerRef.current === null) return
156
+ window.clearTimeout(blurCloseTimerRef.current)
157
+ blurCloseTimerRef.current = null
158
+ }, [])
159
+
160
+ const resetBlurCloseState = React.useCallback(() => {
161
+ blurClosePendingRef.current = false
162
+ clearBlurCloseTimer()
163
+ }, [clearBlurCloseTimer])
164
+
165
+ React.useEffect(() => resetBlurCloseState, [resetBlurCloseState])
166
+
142
167
  const filteredSuggestions = React.useMemo(() => {
143
168
  const query = input.toLowerCase().trim()
144
169
  if (!query) return availableOptions
@@ -153,17 +178,20 @@ export function ComboboxInput({
153
178
  if (!loadSuggestions || !touched || disabled) return
154
179
  const query = input.trim()
155
180
  let cancelled = false
156
- const handle = window.setTimeout(async () => {
181
+ const handle = window.setTimeout(() => {
157
182
  setLoading(true)
158
- try {
159
- const items = await loadSuggestions(query)
160
- if (!cancelled) {
161
- const normalized = normalizeOptions(items)
162
- setAsyncOptions((prev) => areOptionsEqual(prev, normalized) ? prev : normalized)
163
- }
164
- } finally {
165
- if (!cancelled) setLoading(false)
166
- }
183
+ Promise.resolve()
184
+ .then(() => loadSuggestions(query))
185
+ .then((items) => {
186
+ if (!cancelled) {
187
+ const normalized = normalizeOptions(items)
188
+ setAsyncOptions((prev) => areOptionsEqual(prev, normalized) ? prev : normalized)
189
+ }
190
+ })
191
+ .catch(() => {})
192
+ .finally(() => {
193
+ if (!cancelled) setLoading(false)
194
+ })
167
195
  }, 200)
168
196
  return () => {
169
197
  cancelled = true
@@ -198,6 +226,8 @@ export function ComboboxInput({
198
226
  eagerFallbackLoadedValueRef.current = value
199
227
  // Fallback: pull the first page of async suggestions so a remount that lost
200
228
  // its option cache can still recover the label without user interaction.
229
+ // Note: if the loader is paginated and the value falls outside the first page,
230
+ // the fallback silently fails and the raw value remains visible.
201
231
  if (loadSuggestions) {
202
232
  setLoading(true)
203
233
  Promise.resolve()
@@ -211,7 +241,8 @@ export function ComboboxInput({
211
241
  .finally(() => { if (!cancelled) setLoading(false) })
212
242
  }
213
243
  return () => { cancelled = true }
214
- // eslint-disable-next-line react-hooks/exhaustive-deps
244
+ // eslint-disable-next-line react-hooks/exhaustive-deps — resolveDescription intentionally excluded:
245
+ // including it would re-run the effect on every render when the prop is an inline function
215
246
  }, [value, disabled, knownLabelValues, coveredOptionValues, eagerResolveLabel, loadSuggestions])
216
247
 
217
248
  // Sync input with value when value changes externally and input is not focused.
@@ -225,6 +256,7 @@ export function ComboboxInput({
225
256
  const selectValue = React.useCallback(
226
257
  (nextValue: string) => {
227
258
  if (disabled) return
259
+ resetBlurCloseState()
228
260
  const trimmed = nextValue.trim()
229
261
  onChange(trimmed)
230
262
  const option = optionMap.get(trimmed)
@@ -232,7 +264,7 @@ export function ComboboxInput({
232
264
  setShowSuggestions(false)
233
265
  setSelectedIndex(-1)
234
266
  },
235
- [disabled, onChange, optionMap]
267
+ [disabled, onChange, optionMap, resetBlurCloseState]
236
268
  )
237
269
 
238
270
  const findOptionForInput = React.useCallback(
@@ -251,6 +283,10 @@ export function ComboboxInput({
251
283
  const confirmSelection = React.useCallback(
252
284
  (raw: string) => {
253
285
  if (disabled) return
286
+ if (clearable && raw.trim() === '') {
287
+ selectValue('')
288
+ return
289
+ }
254
290
  const option = findOptionForInput(raw)
255
291
  if (option) {
256
292
  selectValue(option.value)
@@ -271,9 +307,41 @@ export function ComboboxInput({
271
307
  }
272
308
  selectValue(raw)
273
309
  },
274
- [allowCustomValues, disabled, findOptionForInput, optionMap, selectValue, value]
310
+ [allowCustomValues, clearable, disabled, findOptionForInput, optionMap, selectValue, value]
275
311
  )
276
312
 
313
+ const handleClear = React.useCallback(() => {
314
+ if (disabled) return
315
+ selectValue('')
316
+ inputRef.current?.focus()
317
+ }, [disabled, selectValue])
318
+
319
+ const closeAfterBlur = React.useCallback(() => {
320
+ blurCloseTimerRef.current = null
321
+ if (disabled) return
322
+ blurClosePendingRef.current = false
323
+ confirmSelection(input)
324
+ setShowSuggestions(false)
325
+ setSelectedIndex(-1)
326
+ }, [confirmSelection, disabled, input])
327
+
328
+ const attemptBlurClose = React.useCallback(() => {
329
+ blurCloseTimerRef.current = null
330
+ if (disabled) return
331
+ if (loadingRef.current) {
332
+ blurCloseTimerRef.current = window.setTimeout(closeAfterBlur, blurCloseMaxDelayMs)
333
+ return
334
+ }
335
+ closeAfterBlur()
336
+ }, [blurCloseMaxDelayMs, closeAfterBlur, disabled])
337
+
338
+ React.useEffect(() => {
339
+ if (!blurClosePendingRef.current) return
340
+ if (loading) return
341
+ clearBlurCloseTimer()
342
+ closeAfterBlur()
343
+ }, [clearBlurCloseTimer, closeAfterBlur, loading])
344
+
277
345
  const handleKeyDown = React.useCallback(
278
346
  (event: React.KeyboardEvent<HTMLInputElement>) => {
279
347
  if (disabled) return
@@ -305,6 +373,8 @@ export function ComboboxInput({
305
373
  [confirmSelection, disabled, filteredSuggestions, input, selectValue, selectedIndex, showSuggestions]
306
374
  )
307
375
 
376
+ const showClearButton = clearable && !disabled && (value !== '' || input !== '')
377
+
308
378
  return (
309
379
  <div className="relative w-full">
310
380
  {/* Use raw <input> here instead of the DS Input primitive: ComboboxInput's
@@ -314,7 +384,12 @@ export function ComboboxInput({
314
384
  <input
315
385
  ref={inputRef}
316
386
  type="text"
317
- className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground focus-visible:shadow-focus focus-visible:border-foreground disabled:bg-bg-disabled disabled:border-border-disabled disabled:text-muted-foreground disabled:cursor-not-allowed"
387
+ className={[
388
+ 'w-full h-9 rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground focus-visible:shadow-focus focus-visible:border-foreground disabled:bg-bg-disabled disabled:border-border-disabled disabled:text-muted-foreground disabled:cursor-not-allowed',
389
+ showClearButton ? 'pr-9' : '',
390
+ ]
391
+ .filter(Boolean)
392
+ .join(' ')}
318
393
  value={input}
319
394
  placeholder={resolvedPlaceholder}
320
395
  autoFocus={autoFocus}
@@ -326,6 +401,10 @@ export function ComboboxInput({
326
401
  suppressOpenOnFocusRef.current = false
327
402
  return
328
403
  }
404
+ resetBlurCloseState()
405
+ if (loadSuggestions && availableOptions.length === 0) {
406
+ setLoading(true)
407
+ }
329
408
  setShowSuggestions(true)
330
409
  }}
331
410
  onChange={(event) => {
@@ -336,14 +415,33 @@ export function ComboboxInput({
336
415
  }}
337
416
  onKeyDown={handleKeyDown}
338
417
  onBlur={() => {
339
- // Delay to allow click on suggestions
340
- setTimeout(() => {
341
- if (disabled) return
342
- confirmSelection(input)
343
- }, 200)
418
+ // Delay closing so clicks on the popup can resolve first. If async
419
+ // suggestions are still loading, keep the dropdown open instead of
420
+ // closing before the first payload arrives.
421
+ blurClosePendingRef.current = true
422
+ clearBlurCloseTimer()
423
+ if (loadingRef.current) {
424
+ blurCloseTimerRef.current = window.setTimeout(closeAfterBlur, blurCloseMaxDelayMs)
425
+ return
426
+ }
427
+ blurCloseTimerRef.current = window.setTimeout(attemptBlurClose, blurCloseDelayMs)
344
428
  }}
345
429
  />
346
430
 
431
+ {showClearButton ? (
432
+ <IconButton
433
+ type="button"
434
+ variant="ghost"
435
+ size="xs"
436
+ aria-label={resolvedClearLabel}
437
+ className="absolute right-1 top-1/2 -translate-y-1/2"
438
+ onMouseDown={(event) => event.preventDefault()}
439
+ onClick={handleClear}
440
+ >
441
+ <X className="size-3" />
442
+ </IconButton>
443
+ ) : null}
444
+
347
445
  {showSuggestions && !disabled && (loading || filteredSuggestions.length > 0) && (
348
446
  <div className="absolute z-popover w-full mt-1 rounded-md border border-input bg-popover p-2 shadow-md max-h-48 sm:max-h-60 overflow-auto">
349
447
  {loading && touched ? (
@@ -363,7 +461,10 @@ export function ComboboxInput({
363
461
  .filter(Boolean)
364
462
  .join(' ')}
365
463
  onMouseDown={(event) => event.preventDefault()}
366
- onClick={() => selectValue(option.value)}
464
+ onClick={() => {
465
+ resetBlurCloseState()
466
+ selectValue(option.value)
467
+ }}
367
468
  onMouseEnter={() => setSelectedIndex(index)}
368
469
  >
369
470
  <span className="font-medium text-foreground">{option.label}</span>
@@ -1,17 +1,138 @@
1
+ /** @jest-environment jsdom */
2
+
1
3
  jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
2
4
  useT: () => (_key: string, fallback: string) => fallback,
3
5
  }))
4
6
 
5
7
  import * as React from 'react'
6
- import { act, render, fireEvent, waitFor } from '@testing-library/react'
8
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
7
9
  import { ComboboxInput } from '../ComboboxInput'
8
10
 
11
+ type HarnessProps = Partial<React.ComponentProps<typeof ComboboxInput>> & {
12
+ initialValue?: string
13
+ }
14
+
15
+ function Harness({ initialValue = '', onChange, ...rest }: HarnessProps) {
16
+ const [value, setValue] = React.useState(initialValue)
17
+ return (
18
+ <div>
19
+ <ComboboxInput
20
+ value={value}
21
+ onChange={(next) => {
22
+ setValue(next)
23
+ onChange?.(next)
24
+ }}
25
+ suggestions={[
26
+ { value: 'red', label: 'Red' },
27
+ { value: 'green', label: 'Green' },
28
+ ]}
29
+ {...rest}
30
+ />
31
+ <output data-testid="value">{value}</output>
32
+ </div>
33
+ )
34
+ }
35
+
9
36
  function getInput(container: HTMLElement): HTMLInputElement {
10
37
  const el = container.querySelector('input')
11
38
  if (!el) throw new Error('input not found')
12
39
  return el as HTMLInputElement
13
40
  }
14
41
 
42
+ function blurAndFlush(input: HTMLElement) {
43
+ act(() => {
44
+ fireEvent.blur(input)
45
+ })
46
+ act(() => {
47
+ jest.advanceTimersByTime(250)
48
+ })
49
+ }
50
+
51
+ describe('ComboboxInput clearable behavior', () => {
52
+ beforeEach(() => {
53
+ jest.useFakeTimers()
54
+ })
55
+
56
+ afterEach(() => {
57
+ act(() => {
58
+ jest.runOnlyPendingTimers()
59
+ })
60
+ jest.useRealTimers()
61
+ })
62
+
63
+ it('reverts to current value on blur with empty input when not clearable and custom values disallowed', () => {
64
+ const onChange = jest.fn()
65
+ render(
66
+ <Harness
67
+ initialValue="red"
68
+ clearable={false}
69
+ allowCustomValues={false}
70
+ onChange={onChange}
71
+ />
72
+ )
73
+
74
+ const input = screen.getByRole('textbox') as HTMLInputElement
75
+ expect(input.value).toBe('Red')
76
+
77
+ fireEvent.focus(input)
78
+ fireEvent.change(input, { target: { value: '' } })
79
+ expect(input.value).toBe('')
80
+
81
+ blurAndFlush(input)
82
+
83
+ expect(input.value).toBe('Red')
84
+ expect(screen.getByTestId('value')).toHaveTextContent('red')
85
+ expect(onChange).not.toHaveBeenCalled()
86
+ })
87
+
88
+ it('emits empty string on blur with empty input when clearable, regardless of allowCustomValues', () => {
89
+ const onChange = jest.fn()
90
+ render(
91
+ <Harness
92
+ initialValue="red"
93
+ clearable
94
+ allowCustomValues={false}
95
+ onChange={onChange}
96
+ />
97
+ )
98
+
99
+ const input = screen.getByRole('textbox') as HTMLInputElement
100
+ fireEvent.focus(input)
101
+ fireEvent.change(input, { target: { value: '' } })
102
+
103
+ blurAndFlush(input)
104
+
105
+ expect(onChange).toHaveBeenCalledWith('')
106
+ expect(screen.getByTestId('value')).toHaveTextContent('')
107
+ expect(input.value).toBe('')
108
+ })
109
+
110
+ it('renders a clear button when clearable and a value is set, and clears via click', () => {
111
+ const onChange = jest.fn()
112
+ render(<Harness initialValue="red" clearable onChange={onChange} />)
113
+
114
+ const input = screen.getByRole('textbox') as HTMLInputElement
115
+ expect(input.value).toBe('Red')
116
+
117
+ const clearBtn = screen.getByRole('button', { name: /clear value/i })
118
+ fireEvent.click(clearBtn)
119
+
120
+ expect(onChange).toHaveBeenCalledWith('')
121
+ expect(screen.getByTestId('value')).toHaveTextContent('')
122
+ expect(input.value).toBe('')
123
+ })
124
+
125
+ it('does not render the clear button when clearable is false', () => {
126
+ render(<Harness initialValue="red" clearable={false} />)
127
+ expect(screen.queryByRole('button', { name: /clear value/i })).toBeNull()
128
+ })
129
+
130
+ it('does not render the clear button when clearable is true but no value is set', () => {
131
+ render(<Harness initialValue="" clearable />)
132
+ expect(screen.queryByRole('button', { name: /clear value/i })).toBeNull()
133
+ })
134
+ })
135
+
15
136
  describe('ComboboxInput — eager label resolution', () => {
16
137
  it('renders the raw value when nothing can resolve it', () => {
17
138
  const { container } = render(<ComboboxInput value="uuid-123" onChange={() => {}} />)
@@ -34,7 +155,6 @@ describe('ComboboxInput — eager label resolution', () => {
34
155
  const { container } = render(
35
156
  <ComboboxInput value="uuid-123" onChange={() => {}} resolveLabel={resolveLabel} />,
36
157
  )
37
- // before the promise resolves, falls back to the raw value
38
158
  expect(getInput(container).value).toBe('uuid-123')
39
159
  await waitFor(() => expect(getInput(container).value).toBe('Resolved uuid-123'))
40
160
  expect(resolveLabel).toHaveBeenCalledWith('uuid-123')
@@ -52,6 +172,19 @@ describe('ComboboxInput — eager label resolution', () => {
52
172
  await waitFor(() => expect(getInput(container).value).toBe('Sync Label'))
53
173
  })
54
174
 
175
+ it('swallows synchronous throws from resolveLabel', async () => {
176
+ const resolveLabel = jest.fn(() => {
177
+ throw new Error('boom')
178
+ })
179
+
180
+ const { container } = render(
181
+ <ComboboxInput value="uuid-123" onChange={() => {}} resolveLabel={resolveLabel} />,
182
+ )
183
+
184
+ await waitFor(() => expect(resolveLabel).toHaveBeenCalledWith('uuid-123'))
185
+ expect(getInput(container).value).toBe('uuid-123')
186
+ })
187
+
55
188
  it('does not call resolveLabel when the value is already covered by suggestions', () => {
56
189
  const resolveLabel = jest.fn(() => 'should-not-be-used')
57
190
  render(
@@ -74,6 +207,34 @@ describe('ComboboxInput — eager label resolution', () => {
74
207
  expect(loadSuggestions).toHaveBeenCalledWith()
75
208
  })
76
209
 
210
+ it('swallows synchronous throws from loadSuggestions', async () => {
211
+ jest.useFakeTimers()
212
+ const loadSuggestions = jest.fn(() => {
213
+ throw new Error('boom')
214
+ })
215
+
216
+ try {
217
+ const { container } = render(
218
+ <ComboboxInput value="" onChange={() => {}} loadSuggestions={loadSuggestions} />,
219
+ )
220
+ const input = getInput(container)
221
+
222
+ act(() => {
223
+ fireEvent.focus(input)
224
+ fireEvent.change(input, { target: { value: 'a' } })
225
+ })
226
+
227
+ await act(async () => {
228
+ jest.advanceTimersByTime(200)
229
+ })
230
+
231
+ await waitFor(() => expect(loadSuggestions).toHaveBeenCalledWith('a'))
232
+ expect(getInput(container).value).toBe('a')
233
+ } finally {
234
+ jest.useRealTimers()
235
+ }
236
+ })
237
+
77
238
  it('does not reload suggestions after a self-labeled value is covered', async () => {
78
239
  const loadSuggestions = jest.fn(async () => [{ value: 'UTC', label: 'UTC' }])
79
240
  const { container } = render(
@@ -81,9 +242,9 @@ describe('ComboboxInput — eager label resolution', () => {
81
242
  )
82
243
 
83
244
  await waitFor(() => expect(loadSuggestions).toHaveBeenCalledTimes(1))
84
- await act(async () => {
85
- await new Promise((resolve) => setTimeout(resolve, 25))
86
- })
245
+ // flush microtask queue to let any pending effects settle
246
+ await act(async () => { await Promise.resolve() })
247
+ await act(async () => { await Promise.resolve() })
87
248
 
88
249
  expect(loadSuggestions).toHaveBeenCalledTimes(1)
89
250
  expect(getInput(container).value).toBe('UTC')
@@ -96,12 +257,85 @@ describe('ComboboxInput — eager label resolution', () => {
96
257
  )
97
258
 
98
259
  await waitFor(() => expect(loadSuggestions).toHaveBeenCalledTimes(1))
260
+ await act(async () => { await Promise.resolve() })
261
+ await act(async () => { await Promise.resolve() })
262
+
263
+ expect(loadSuggestions).toHaveBeenCalledTimes(1)
264
+ expect(getInput(container).value).toBe('UTC')
265
+ })
266
+
267
+ it('keeps the popup open when blur happens while async suggestions are still loading', async () => {
268
+ let resolveSuggestions: (items: Array<string | { value: string; label: string }>) => void = () => {}
269
+ const loadSuggestions = jest.fn(
270
+ () =>
271
+ new Promise<Array<string | { value: string; label: string }>>((resolve) => {
272
+ resolveSuggestions = resolve
273
+ }),
274
+ )
275
+
276
+ const { container, queryByText } = render(
277
+ <div>
278
+ <ComboboxInput value="" onChange={() => {}} loadSuggestions={loadSuggestions} />
279
+ </div>,
280
+ )
281
+ const input = getInput(container)
282
+
283
+ act(() => {
284
+ fireEvent.focus(input)
285
+ fireEvent.change(input, { target: { value: 'a' } })
286
+ fireEvent.blur(input)
287
+ })
288
+
289
+ await act(async () => {
290
+ await new Promise((resolve) => setTimeout(resolve, 225))
291
+ })
292
+
293
+ expect(loadSuggestions).toHaveBeenCalledWith('a')
294
+ await waitFor(() => expect(queryByText(/Loading suggestions/i)).toBeTruthy())
295
+
99
296
  await act(async () => {
297
+ resolveSuggestions([{ value: 'acme', label: 'Acme Corp' }])
298
+ await Promise.resolve()
100
299
  await new Promise((resolve) => setTimeout(resolve, 25))
101
300
  })
102
301
 
103
- expect(loadSuggestions).toHaveBeenCalledTimes(1)
104
- expect(getInput(container).value).toBe('UTC')
302
+ await waitFor(() => expect(queryByText(/Loading suggestions/i)).toBeNull())
303
+ await waitFor(() => expect(queryByText('Acme Corp')).toBeNull())
304
+ })
305
+
306
+ it('closes the popup after a bounded delay when async suggestions never finish', async () => {
307
+ jest.useFakeTimers()
308
+ const loadSuggestions = jest.fn(
309
+ () => new Promise<Array<string | { value: string; label: string }>>(() => {}),
310
+ )
311
+
312
+ try {
313
+ const { container, queryByText } = render(
314
+ <ComboboxInput value="" onChange={() => {}} loadSuggestions={loadSuggestions} />,
315
+ )
316
+ const input = getInput(container)
317
+
318
+ act(() => {
319
+ fireEvent.focus(input)
320
+ fireEvent.change(input, { target: { value: 'a' } })
321
+ fireEvent.blur(input)
322
+ })
323
+
324
+ await act(async () => {
325
+ jest.advanceTimersByTime(450)
326
+ })
327
+
328
+ expect(loadSuggestions).toHaveBeenCalledWith('a')
329
+ expect(queryByText(/Loading suggestions/i)).toBeTruthy()
330
+
331
+ await act(async () => {
332
+ jest.advanceTimersByTime(1001)
333
+ })
334
+
335
+ expect(queryByText(/Loading suggestions/i)).toBeNull()
336
+ } finally {
337
+ jest.useRealTimers()
338
+ }
105
339
  })
106
340
 
107
341
  it('keeps the resolved label after a blur revert when custom values are disallowed', async () => {
@@ -141,7 +375,6 @@ describe('ComboboxInput — eager label resolution', () => {
141
375
  />,
142
376
  )
143
377
  const input = getInput(container)
144
- // user clears the field before resolution lands, then blurs
145
378
  act(() => {
146
379
  fireEvent.change(input, { target: { value: '' } })
147
380
  fireEvent.blur(input)
@@ -149,9 +382,7 @@ describe('ComboboxInput — eager label resolution', () => {
149
382
  await act(async () => {
150
383
  await new Promise((res) => setTimeout(res, 250))
151
384
  })
152
- // the raw uuid must not have been written into the visible input
153
385
  expect(getInput(container).value).not.toBe('uuid-123')
154
- // once resolution arrives, the label shows up
155
386
  act(() => resolve('Acme Corp'))
156
387
  await waitFor(() => expect(getInput(container).value).toBe('Acme Corp'))
157
388
  })