@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.
- package/dist/backend/AppShell.js +2 -2
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +1 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +104 -26
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/upgrades/UpgradeActionBanner.js +17 -7
- package/dist/backend/upgrades/UpgradeActionBanner.js.map +2 -2
- package/dist/backend/utils/api.js +17 -14
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/theme/QueryProvider.js +5 -5
- package/dist/theme/QueryProvider.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +3 -2
- package/src/backend/CrudForm.tsx +1 -0
- package/src/backend/__tests__/AppShell.test.tsx +27 -0
- package/src/backend/__tests__/UpgradeActionBanner.test.tsx +146 -0
- package/src/backend/__tests__/nav-utils.test.ts +73 -0
- package/src/backend/inputs/ComboboxInput.tsx +136 -35
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +241 -10
- package/src/backend/upgrades/UpgradeActionBanner.tsx +21 -9
- package/src/backend/utils/__tests__/api.test.ts +27 -2
- package/src/backend/utils/api.ts +34 -19
- package/src/theme/QueryProvider.tsx +7 -6
|
@@ -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
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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(
|
|
181
|
+
const handle = window.setTimeout(() => {
|
|
157
182
|
setLoading(true)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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=
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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={() =>
|
|
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,
|
|
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
|
-
|
|
85
|
-
|
|
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(
|
|
104
|
-
expect(
|
|
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
|
})
|