@butternutbox/pawprint-native 1.6.3 → 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 (44) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/CHANGELOG.md +52 -0
  3. package/COMPONENT_GUIDELINES.md +57 -1
  4. package/dist/index.cjs +1459 -1380
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +81 -117
  7. package/dist/index.d.ts +81 -117
  8. package/dist/index.js +1206 -1125
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/__mocks__/react-native.tsx +33 -5
  12. package/src/__mocks__/rn-primitives/portal.tsx +71 -0
  13. package/src/components/atoms/Input/InputField.tsx +12 -2
  14. package/src/components/molecules/Drawer/Drawer.test.tsx +54 -2
  15. package/src/components/molecules/Drawer/DrawerBody.tsx +2 -17
  16. package/src/components/molecules/Drawer/DrawerContent.tsx +49 -2
  17. package/src/components/molecules/Drawer/DrawerOverlay.tsx +28 -16
  18. package/src/components/molecules/SelectField/AsyncSearch.test.tsx +373 -0
  19. package/src/components/molecules/SelectField/SelectDrawerField.stories.tsx +192 -0
  20. package/src/components/molecules/SelectField/SelectDrawerField.test.tsx +210 -0
  21. package/src/components/molecules/SelectField/SelectDrawerField.tsx +245 -0
  22. package/src/components/molecules/SelectField/SelectField.stories.tsx +221 -421
  23. package/src/components/molecules/SelectField/SelectField.test.tsx +214 -662
  24. package/src/components/molecules/SelectField/SelectField.tsx +269 -539
  25. package/src/components/molecules/SelectField/SelectFieldDropdown.tsx +90 -0
  26. package/src/components/molecules/SelectField/SelectFieldList.tsx +88 -0
  27. package/src/components/molecules/SelectField/SelectFieldOption.tsx +90 -0
  28. package/src/components/molecules/SelectField/core/index.ts +8 -0
  29. package/src/components/molecules/SelectField/core/listHeight.test.ts +22 -0
  30. package/src/components/molecules/SelectField/core/listHeight.ts +8 -0
  31. package/src/components/molecules/SelectField/core/options.ts +35 -0
  32. package/src/components/molecules/SelectField/core/useSelectOptions.ts +116 -0
  33. package/src/components/molecules/SelectField/index.ts +12 -9
  34. package/src/index.ts +0 -1
  35. package/src/test-utils.tsx +5 -1
  36. package/vitest.config.ts +7 -0
  37. package/src/components/molecules/SelectField/SelectFieldContent.tsx +0 -81
  38. package/src/components/molecules/SelectField/SelectFieldItem.tsx +0 -133
  39. package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +0 -188
  40. package/src/components/molecules/SelectField/SelectFieldValue.tsx +0 -33
  41. package/src/components/molecules/SelectField/hooks/index.ts +0 -2
  42. package/src/components/molecules/SelectField/hooks/useSelectField.ts +0 -84
  43. package/src/hooks/index.ts +0 -1
  44. package/src/hooks/useDrawerKeyboardAvoidance.ts +0 -29
@@ -1,777 +1,329 @@
1
1
  import React from "react"
2
- import { View } from "react-native"
3
- import { screen, fireEvent, act, waitFor } from "@testing-library/react"
4
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
5
- import { MEASURED_TRIGGER } from "../../../__mocks__/react-native"
2
+ import { screen, fireEvent, waitFor } from "@testing-library/react"
3
+ import { describe, it, expect, vi } from "vitest"
6
4
  import { renderWithTheme } from "../../../test-utils"
7
5
  import { SelectField } from "./SelectField"
6
+ import type { SelectFieldOptionData } from "./SelectField"
7
+
8
+ const COUNTRIES: SelectFieldOptionData[] = [
9
+ { value: "uk", label: "United Kingdom" },
10
+ { value: "us", label: "United States" },
11
+ { value: "ca", label: "Canada" },
12
+ { value: "au", label: "Australia" }
13
+ ]
14
+
15
+ const renderField = (
16
+ props: Partial<React.ComponentProps<typeof SelectField>> = {}
17
+ ) => {
18
+ renderWithTheme(
19
+ <SelectField
20
+ label="Country"
21
+ placeholder="Select a country"
22
+ options={COUNTRIES}
23
+ {...props}
24
+ />
25
+ )
26
+
27
+ return screen.getByRole("combobox") as HTMLInputElement
28
+ }
8
29
 
9
30
  describe("SelectField", () => {
10
- describe("when using simple props API", () => {
11
- it("renders label when provided", () => {
12
- renderWithTheme(
13
- <SelectField label="Country" placeholder="Select a country">
14
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
15
- </SelectField>
16
- )
31
+ describe("field chrome", () => {
32
+ it("renders the label, description and optional text", () => {
33
+ renderField({ description: "Pick one", optionalText: "(optional)" })
17
34
 
18
35
  expect(screen.getByText("Country")).toBeInTheDocument()
19
- })
20
-
21
- it("renders description when provided", () => {
22
- renderWithTheme(
23
- <SelectField
24
- label="Country"
25
- placeholder="Select a country"
26
- description="Choose your country"
27
- >
28
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
29
- </SelectField>
30
- )
31
-
32
- expect(screen.getByText("Choose your country")).toBeInTheDocument()
33
- })
34
-
35
- it("renders error message when error prop and state='error' are provided", () => {
36
- renderWithTheme(
37
- <SelectField
38
- label="Country"
39
- placeholder="Select a country"
40
- state="error"
41
- error="Country is required"
42
- >
43
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
44
- </SelectField>
45
- )
46
-
47
- expect(screen.getByText("Country is required")).toBeInTheDocument()
48
- })
49
-
50
- it("does not render error message when error prop is provided without state='error'", () => {
51
- renderWithTheme(
52
- <SelectField
53
- label="Country"
54
- placeholder="Select a country"
55
- error="Country is required"
56
- >
57
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
58
- </SelectField>
59
- )
60
-
61
- expect(screen.queryByText("Country is required")).not.toBeInTheDocument()
62
- })
63
-
64
- it("shows (optional) when optionalText is provided", () => {
65
- renderWithTheme(
66
- <SelectField
67
- label="Country"
68
- placeholder="Select a country"
69
- optionalText="(optional)"
70
- >
71
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
72
- </SelectField>
73
- )
74
-
36
+ expect(screen.getByText("Pick one")).toBeInTheDocument()
75
37
  expect(screen.getByText("(optional)")).toBeInTheDocument()
76
38
  })
77
39
 
78
- it("renders the component structure", () => {
79
- renderWithTheme(
80
- <SelectField label="Country" placeholder="Select a country">
81
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
82
- </SelectField>
83
- )
40
+ it("renders the error message whenever error is set", () => {
41
+ renderField({ error: "Please choose a country" })
84
42
 
85
- expect(screen.getByText("Country")).toBeInTheDocument()
43
+ expect(screen.getByText("Please choose a country")).toBeInTheDocument()
86
44
  })
87
- })
88
-
89
- describe("when using controlled mode", () => {
90
- it("renders with controlled value", () => {
91
- const onValueChange = vi.fn()
92
45
 
93
- renderWithTheme(
94
- <SelectField
95
- label="Country"
96
- value="uk"
97
- onValueChange={onValueChange}
98
- placeholder="Select a country"
99
- >
100
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
101
- <SelectField.Item value="us">United States</SelectField.Item>
102
- </SelectField>
103
- )
46
+ it("is not editable when disabled", () => {
47
+ const input = renderField({ disabled: true })
104
48
 
105
- expect(screen.getByText("Country")).toBeInTheDocument()
49
+ expect(input).toBeDisabled()
106
50
  })
107
51
  })
108
52
 
109
- describe("when disabled", () => {
110
- it("renders disabled state", () => {
111
- renderWithTheme(
112
- <SelectField label="Country" placeholder="Select a country" disabled>
113
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
114
- </SelectField>
115
- )
53
+ describe("opening and closing", () => {
54
+ it("shows every option on focus", () => {
55
+ const input = renderField()
56
+ fireEvent.focus(input)
116
57
 
117
- expect(screen.getByText("Country")).toBeInTheDocument()
58
+ COUNTRIES.forEach((option) => {
59
+ expect(screen.getByText(option.label)).toBeInTheDocument()
60
+ })
118
61
  })
119
- })
120
62
 
121
- describe("validation states", () => {
122
- it("shows error state with error message", () => {
123
- renderWithTheme(
124
- <SelectField
125
- label="Country"
126
- placeholder="Select a country"
127
- state="error"
128
- error="Country is required"
129
- >
130
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
131
- </SelectField>
132
- )
63
+ it("stays closed until the field is focused", () => {
64
+ renderField()
133
65
 
134
- expect(screen.getByText("Country is required")).toBeInTheDocument()
66
+ expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
135
67
  })
136
68
 
137
- it("renders success state", () => {
138
- renderWithTheme(
139
- <SelectField
140
- label="Country"
141
- placeholder="Select a country"
142
- state="success"
143
- >
144
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
145
- </SelectField>
146
- )
147
-
148
- expect(screen.getByText("Country")).toBeInTheDocument()
149
- })
150
- })
69
+ it("closes on blur", async () => {
70
+ const input = renderField()
71
+ fireEvent.focus(input)
72
+ fireEvent.blur(input)
151
73
 
152
- describe("when using compound component API", () => {
153
- it("renders compound components", () => {
154
- renderWithTheme(
155
- <SelectField.Root>
156
- <SelectField.Label>Country</SelectField.Label>
157
- <SelectField.Trigger>
158
- <SelectField.Value placeholder="Select a country" />
159
- </SelectField.Trigger>
160
- <SelectField.Content>
161
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
162
- </SelectField.Content>
163
- <SelectField.Description>Choose your country</SelectField.Description>
164
- </SelectField.Root>
74
+ await waitFor(() =>
75
+ expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
165
76
  )
166
-
167
- expect(screen.getByText("Country")).toBeInTheDocument()
168
- expect(screen.getByText("Choose your country")).toBeInTheDocument()
169
77
  })
170
78
 
171
- it("renders error in compound mode", () => {
172
- renderWithTheme(
173
- <SelectField.Root>
174
- <SelectField.Label state="error">Country</SelectField.Label>
175
- <SelectField.Trigger state="error">
176
- <SelectField.Value placeholder="Select a country" />
177
- </SelectField.Trigger>
178
- <SelectField.Content>
179
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
180
- </SelectField.Content>
181
- <SelectField.Error>Country is required</SelectField.Error>
182
- </SelectField.Root>
183
- )
184
-
185
- expect(screen.getByText("Country is required")).toBeInTheDocument()
186
- })
187
- })
79
+ it("does not open when disabled", () => {
80
+ const input = renderField({ disabled: true })
81
+ fireEvent.focus(input)
188
82
 
189
- describe("when using with ref", () => {
190
- it("forwards ref to root element", () => {
191
- const ref = React.createRef<View>()
192
- renderWithTheme(<SelectField ref={ref} label="Test" />)
193
- expect(ref.current).toBeTruthy()
83
+ expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
194
84
  })
195
85
  })
196
86
 
197
- describe("uncontrolled mode with defaultValue", () => {
198
- it("renders with defaultValue without crashing and shows items when opened", () => {
87
+ describe("portal", () => {
88
+ it("renders the dropdown outside the field's own subtree", () => {
199
89
  renderWithTheme(
200
90
  <SelectField
201
91
  label="Country"
202
92
  placeholder="Select a country"
203
- defaultValue="uk"
204
- >
205
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
206
- <SelectField.Item value="us">United States</SelectField.Item>
207
- </SelectField>
93
+ options={COUNTRIES}
94
+ />
208
95
  )
209
96
 
210
- fireEvent.click(screen.getAllByRole("button")[0])
211
- expect(screen.getByText("United Kingdom")).toBeInTheDocument()
212
- })
213
- })
214
-
215
- describe("when searchable", () => {
216
- const openDropdown = () => {
217
- fireEvent.click(screen.getAllByRole("button")[0])
218
- }
219
-
220
- const openDropdownWithContent = () => {
221
- vi.useFakeTimers()
222
- fireEvent.click(screen.getAllByRole("button")[0])
223
- act(() => vi.advanceTimersByTime(500))
224
- vi.useRealTimers()
225
- }
97
+ const input = screen.getByRole("combobox") as HTMLInputElement
98
+ fireEvent.focus(input)
226
99
 
227
- it("shows search input in the trigger when dropdown is opened", () => {
228
- renderWithTheme(
229
- <SelectField label="Country" placeholder="Select a country" searchable>
230
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
231
- <SelectField.Item value="us">United States</SelectField.Item>
232
- </SelectField>
233
- )
100
+ const row = screen.getByText("Canada")
101
+ const fieldRoot = input.closest("div")
234
102
 
235
- expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument()
236
- openDropdown()
237
- expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument()
103
+ expect(row).toBeInTheDocument()
104
+ expect(fieldRoot?.contains(row)).toBe(false)
238
105
  })
239
106
 
240
- it("does not show search input when not searchable", () => {
241
- renderWithTheme(
242
- <SelectField label="Country" placeholder="Select a country">
243
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
244
- <SelectField.Item value="us">United States</SelectField.Item>
245
- </SelectField>
246
- )
247
-
248
- openDropdown()
249
- expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument()
250
- })
107
+ it("removes the dropdown from the host when it closes", async () => {
108
+ const input = renderField()
109
+ fireEvent.focus(input)
110
+ expect(screen.getByText("Canada")).toBeInTheDocument()
251
111
 
252
- it("renders with a custom searchPlaceholder", () => {
253
- renderWithTheme(
254
- <SelectField
255
- label="Country"
256
- placeholder="Select a country"
257
- searchable
258
- searchPlaceholder="Find a country..."
259
- >
260
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
261
- </SelectField>
112
+ fireEvent.blur(input)
113
+ await waitFor(() =>
114
+ expect(screen.queryByText("Canada")).not.toBeInTheDocument()
262
115
  )
263
-
264
- openDropdown()
265
- expect(
266
- screen.getByPlaceholderText("Find a country...")
267
- ).toBeInTheDocument()
268
116
  })
117
+ })
269
118
 
270
- it("filters items based on the search query", () => {
271
- renderWithTheme(
272
- <SelectField label="Country" placeholder="Select a country" searchable>
273
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
274
- <SelectField.Item value="us">United States</SelectField.Item>
275
- <SelectField.Item value="ca">Canada</SelectField.Item>
276
- </SelectField>
277
- )
278
-
279
- openDropdownWithContent()
280
- fireEvent.change(screen.getByPlaceholderText("Search..."), {
281
- target: { value: "united" }
282
- })
119
+ describe("filtering", () => {
120
+ it("matches on a substring, case-insensitively", () => {
121
+ const input = renderField()
122
+ fireEvent.focus(input)
123
+ fireEvent.change(input, { target: { value: " united" } })
283
124
 
284
125
  expect(screen.getByText("United Kingdom")).toBeInTheDocument()
285
126
  expect(screen.getByText("United States")).toBeInTheDocument()
286
127
  expect(screen.queryByText("Canada")).not.toBeInTheDocument()
287
128
  })
288
129
 
289
- it("is case-insensitive when filtering", () => {
290
- renderWithTheme(
291
- <SelectField label="Country" placeholder="Select a country" searchable>
292
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
293
- <SelectField.Item value="ca">Canada</SelectField.Item>
294
- </SelectField>
295
- )
296
-
297
- openDropdownWithContent()
298
- fireEvent.change(screen.getByPlaceholderText("Search..."), {
299
- target: { value: "CANADA" }
130
+ it("orders starts-with matches ahead of contains matches", () => {
131
+ const input = renderField({
132
+ options: [
133
+ { value: "scan", label: "Canine scan" },
134
+ { value: "ca", label: "Canada" },
135
+ { value: "af", label: "Africa" }
136
+ ]
300
137
  })
138
+ fireEvent.focus(input)
139
+ fireEvent.change(input, { target: { value: "ca" } })
301
140
 
302
- expect(screen.getByText("Canada")).toBeInTheDocument()
303
- expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
304
- })
141
+ const rendered = screen
142
+ .getAllByRole("button")
143
+ .map((row) => row.textContent)
144
+ .filter((text) => text && text.length > 0)
305
145
 
306
- it("shows default 'No results found' when search matches no items", () => {
307
- renderWithTheme(
308
- <SelectField label="Country" placeholder="Select a country" searchable>
309
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
310
- <SelectField.Item value="us">United States</SelectField.Item>
311
- </SelectField>
146
+ expect(rendered.indexOf("Canine scan")).toBeLessThan(
147
+ rendered.indexOf("Africa")
312
148
  )
313
-
314
- openDropdownWithContent()
315
- fireEvent.change(screen.getByPlaceholderText("Search..."), {
316
- target: { value: "xyz" }
317
- })
318
-
319
- expect(screen.getByText("No results found")).toBeInTheDocument()
320
- })
321
-
322
- it("shows custom noResultsText when provided", () => {
323
- renderWithTheme(
324
- <SelectField
325
- label="Country"
326
- placeholder="Select a country"
327
- searchable
328
- noResultsText="No countries match your search"
329
- >
330
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
331
- </SelectField>
149
+ expect(rendered.indexOf("Canada")).toBeLessThan(
150
+ rendered.indexOf("Africa")
332
151
  )
333
-
334
- openDropdownWithContent()
335
- fireEvent.change(screen.getByPlaceholderText("Search..."), {
336
- target: { value: "xyz" }
337
- })
338
-
339
- expect(
340
- screen.getByText("No countries match your search")
341
- ).toBeInTheDocument()
342
152
  })
343
153
 
344
- it("shows all items when search query is cleared", () => {
345
- renderWithTheme(
346
- <SelectField label="Country" placeholder="Select a country" searchable>
347
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
348
- <SelectField.Item value="ca">Canada</SelectField.Item>
349
- </SelectField>
350
- )
351
-
352
- openDropdownWithContent()
353
- const input = screen.getByPlaceholderText("Search...")
354
- fireEvent.change(input, { target: { value: "canada" } })
355
- expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
154
+ it("shows the no-results row when nothing matches", () => {
155
+ const input = renderField({ noResultsText: "No countries found" })
156
+ fireEvent.focus(input)
157
+ fireEvent.change(input, { target: { value: "zzzz" } })
356
158
 
357
- fireEvent.change(input, { target: { value: "" } })
358
- expect(screen.getByText("United Kingdom")).toBeInTheDocument()
359
- expect(screen.getByText("Canada")).toBeInTheDocument()
360
- })
361
-
362
- it("closes the dropdown and clears the search when closed", () => {
363
- renderWithTheme(
364
- <SelectField label="Country" placeholder="Select a country" searchable>
365
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
366
- <SelectField.Item value="ca">Canada</SelectField.Item>
367
- </SelectField>
368
- )
369
-
370
- openDropdownWithContent()
371
- fireEvent.change(screen.getByPlaceholderText("Search..."), {
372
- target: { value: "canada" }
373
- })
374
- expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
375
-
376
- fireEvent.click(screen.getByTestId("select-overlay"))
377
- expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument()
159
+ expect(screen.getByText("No countries found")).toBeInTheDocument()
160
+ expect(screen.queryByText("Canada")).not.toBeInTheDocument()
378
161
  })
379
162
 
380
- it("re-focuses the search input when it blurs while the dropdown is open", () => {
381
- renderWithTheme(
382
- <SelectField label="Country" placeholder="Select a country" searchable>
383
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
384
- </SelectField>
385
- )
163
+ it("resets the filter on refocus so the full list shows again", () => {
164
+ const input = renderField()
165
+ fireEvent.focus(input)
166
+ fireEvent.change(input, { target: { value: "united" } })
167
+ fireEvent.blur(input)
168
+ fireEvent.focus(input)
386
169
 
387
- openDropdownWithContent()
388
- const input = screen.getByPlaceholderText("Search...")
389
- vi.useFakeTimers()
390
- input.blur()
391
- expect(input).not.toHaveFocus()
392
-
393
- act(() => vi.advanceTimersByTime(50))
394
- vi.useRealTimers()
395
- expect(input).toHaveFocus()
170
+ expect(screen.getByText("Canada")).toBeInTheDocument()
396
171
  })
397
172
  })
398
173
 
399
- describe("when searchable with an async query", () => {
400
- type Result = { value: string; label: string }
401
-
402
- const openAndType = (query: string) => {
403
- fireEvent.click(screen.getAllByRole("button")[0])
404
- const input = screen.getByPlaceholderText("Search...")
405
- fireEvent.change(input, { target: { value: query } })
406
- }
407
-
408
- it("shows children as the default list before the user types", async () => {
409
- const onSearchQuery = vi.fn(async (): Promise<Result[]> => [])
410
-
411
- renderWithTheme(
412
- <SelectField
413
- label="Breed"
414
- placeholder="Select a breed"
415
- searchable
416
- debounceMs={0}
417
- onSearchQuery={onSearchQuery}
418
- >
419
- <SelectField.Item value="popular">Most popular</SelectField.Item>
420
- </SelectField>
421
- )
422
-
423
- fireEvent.click(screen.getAllByRole("button")[0])
424
-
425
- expect(await screen.findByText("Most popular")).toBeInTheDocument()
426
- expect(onSearchQuery).not.toHaveBeenCalled()
427
- })
428
-
429
- it("does not open an empty dropdown in query mode before the user types", () => {
430
- const onSearchQuery = vi.fn(async (): Promise<Result[]> => [])
431
-
432
- renderWithTheme(
433
- <SelectField
434
- label="Breed"
435
- placeholder="Select a breed"
436
- searchable
437
- debounceMs={0}
438
- onSearchQuery={onSearchQuery}
439
- />
440
- )
441
-
442
- vi.useFakeTimers()
443
- fireEvent.click(screen.getAllByRole("button")[0])
444
- act(() => vi.advanceTimersByTime(500))
445
- vi.useRealTimers()
446
-
447
- // Trigger is open (search input shown) but no content container is
448
- // rendered: an empty dropdown crashes the real Select.Content on web.
449
- expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument()
450
- expect(screen.queryByTestId("select-overlay")).not.toBeInTheDocument()
451
- expect(onSearchQuery).not.toHaveBeenCalled()
452
- })
453
-
454
- it("calls onSearchQuery with the typed query and renders the results", async () => {
455
- const onSearchQuery = vi.fn(
456
- async (): Promise<Result[]> => [
457
- { value: "labrador", label: "Labrador Retriever" }
458
- ]
459
- )
460
-
461
- renderWithTheme(
462
- <SelectField
463
- label="Breed"
464
- placeholder="Select a breed"
465
- searchable
466
- debounceMs={0}
467
- onSearchQuery={onSearchQuery}
468
- />
469
- )
470
-
471
- openAndType("lab")
174
+ describe("selecting", () => {
175
+ it("sets the input text to the option label and emits its value", () => {
176
+ const onValueChange = vi.fn()
177
+ const input = renderField({ onValueChange })
178
+ fireEvent.focus(input)
179
+ fireEvent.click(screen.getByText("Canada"))
472
180
 
473
- expect(await screen.findByText("Labrador Retriever")).toBeInTheDocument()
474
- expect(onSearchQuery).toHaveBeenCalledWith("lab")
181
+ expect(onValueChange).toHaveBeenCalledWith("ca")
182
+ expect(input).toHaveValue("Canada")
475
183
  })
476
184
 
477
- it("shows a loading spinner while the query is in flight", async () => {
478
- let resolveQuery: (results: Result[]) => void = () => {}
479
- const onSearchQuery = vi.fn(
480
- () =>
481
- new Promise<Result[]>((resolve) => {
482
- resolveQuery = resolve
483
- })
484
- )
485
-
486
- renderWithTheme(
487
- <SelectField
488
- label="Breed"
489
- placeholder="Select a breed"
490
- searchable
491
- debounceMs={0}
492
- onSearchQuery={onSearchQuery}
493
- />
494
- )
495
-
496
- openAndType("lab")
497
-
498
- expect(await screen.findByRole("progressbar")).toBeInTheDocument()
185
+ it("closes the dropdown after selecting", () => {
186
+ const input = renderField()
187
+ fireEvent.focus(input)
188
+ fireEvent.click(screen.getByText("Canada"))
189
+ fireEvent.blur(input)
499
190
 
500
- await waitFor(() => expect(onSearchQuery).toHaveBeenCalled())
501
- act(() => resolveQuery([{ value: "labrador", label: "Labrador" }]))
502
-
503
- expect(await screen.findByText("Labrador")).toBeInTheDocument()
504
- expect(screen.queryByRole("progressbar")).not.toBeInTheDocument()
191
+ expect(screen.queryByText("Australia")).not.toBeInTheDocument()
505
192
  })
506
193
 
507
- it("shows errorText when the query rejects", async () => {
508
- const onSearchQuery = vi.fn(async (): Promise<Result[]> => {
509
- throw new Error("boom")
194
+ it("does not emit for a disabled option", () => {
195
+ const onValueChange = vi.fn()
196
+ const input = renderField({
197
+ onValueChange,
198
+ options: [{ value: "uk", label: "United Kingdom", disabled: true }]
510
199
  })
200
+ fireEvent.focus(input)
201
+ fireEvent.click(screen.getByText("United Kingdom"))
511
202
 
512
- renderWithTheme(
513
- <SelectField
514
- label="Breed"
515
- placeholder="Select a breed"
516
- searchable
517
- debounceMs={0}
518
- errorText="Couldn't load breeds"
519
- onSearchQuery={onSearchQuery}
520
- />
521
- )
522
-
523
- openAndType("lab")
524
-
525
- expect(
526
- await screen.findByText("Couldn't load breeds")
527
- ).toBeInTheDocument()
203
+ expect(onValueChange).not.toHaveBeenCalled()
528
204
  })
529
205
 
530
- it("shows noResultsText when the query resolves empty", async () => {
531
- const onSearchQuery = vi.fn(async (): Promise<Result[]> => [])
206
+ it("marks the committed option as selected", () => {
207
+ const input = renderField({ defaultValue: "ca" })
208
+ fireEvent.focus(input)
532
209
 
533
- renderWithTheme(
534
- <SelectField
535
- label="Breed"
536
- placeholder="Select a breed"
537
- searchable
538
- debounceMs={0}
539
- noResultsText="No breeds found"
540
- onSearchQuery={onSearchQuery}
541
- />
542
- )
210
+ const selected = screen
211
+ .getAllByRole("button")
212
+ .find((row) => row.textContent === "Canada")
543
213
 
544
- openAndType("xyz")
545
-
546
- expect(await screen.findByText("No breeds found")).toBeInTheDocument()
214
+ expect(selected).toHaveAttribute("aria-selected", "true")
547
215
  })
216
+ })
548
217
 
549
- it("renders results instead of locally filtering children in query mode", async () => {
550
- const onSearchQuery = vi.fn(
551
- async (): Promise<Result[]> => [
552
- { value: "labrador", label: "Labrador Retriever" }
553
- ]
554
- )
555
-
556
- renderWithTheme(
557
- <SelectField
558
- label="Breed"
559
- placeholder="Select a breed"
560
- searchable
561
- debounceMs={0}
562
- onSearchQuery={onSearchQuery}
563
- >
564
- <SelectField.Item value="uk">United Kingdom</SelectField.Item>
565
- </SelectField>
566
- )
567
-
568
- openAndType("lab")
569
-
570
- expect(await screen.findByText("Labrador Retriever")).toBeInTheDocument()
571
- expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
572
- })
573
-
574
- it("debounces the query, firing once after the last keystroke", () => {
575
- const onSearchQuery = vi.fn(
576
- async (): Promise<Result[]> => [
577
- { value: "labrador", label: "Labrador Retriever" }
578
- ]
579
- )
580
-
581
- renderWithTheme(
582
- <SelectField
583
- label="Breed"
584
- placeholder="Select a breed"
585
- searchable
586
- debounceMs={300}
587
- onSearchQuery={onSearchQuery}
588
- />
589
- )
590
-
591
- vi.useFakeTimers()
592
- fireEvent.click(screen.getAllByRole("button")[0])
593
- const input = screen.getByPlaceholderText("Search...")
594
-
595
- fireEvent.change(input, { target: { value: "l" } })
596
- act(() => vi.advanceTimersByTime(200))
597
- fireEvent.change(input, { target: { value: "la" } })
598
- act(() => vi.advanceTimersByTime(200))
599
- fireEvent.change(input, { target: { value: "lab" } })
600
-
601
- // Each keystroke restarted the timer, so nothing has fired yet even
602
- // though 400ms of typing has elapsed.
603
- expect(onSearchQuery).not.toHaveBeenCalled()
218
+ describe("selection vs blur", () => {
219
+ it("selects even when the row press also blurs the field", async () => {
220
+ const onValueChange = vi.fn()
221
+ const input = renderField({ onValueChange })
222
+ fireEvent.focus(input)
604
223
 
605
- act(() => vi.advanceTimersByTime(300))
606
- vi.useRealTimers()
224
+ const row = screen.getByText("Canada")
225
+ fireEvent.blur(input)
226
+ fireEvent.click(row)
607
227
 
608
- expect(onSearchQuery).toHaveBeenCalledTimes(1)
609
- expect(onSearchQuery).toHaveBeenCalledWith("lab")
228
+ await waitFor(() => expect(input).toHaveValue("Canada"))
229
+ expect(onValueChange).toHaveBeenLastCalledWith("ca")
610
230
  })
611
231
 
612
- it("ignores a stale response superseded by a later keystroke", async () => {
613
- const resolvers: Array<(results: Result[]) => void> = []
614
- const onSearchQuery = vi.fn(
615
- () =>
616
- new Promise<Result[]>((resolve) => {
617
- resolvers.push(resolve)
618
- })
619
- )
620
-
621
- renderWithTheme(
622
- <SelectField
623
- label="Breed"
624
- placeholder="Select a breed"
625
- searchable
626
- debounceMs={0}
627
- onSearchQuery={onSearchQuery}
628
- />
629
- )
630
-
631
- fireEvent.click(screen.getAllByRole("button")[0])
632
- const input = screen.getByPlaceholderText("Search...")
633
-
634
- fireEvent.change(input, { target: { value: "lab" } })
635
- await waitFor(() => expect(resolvers).toHaveLength(1))
636
-
637
- fireEvent.change(input, { target: { value: "labr" } })
638
- await waitFor(() => expect(resolvers).toHaveLength(2))
639
-
640
- // The newer query lands first, then the superseded one resolves late.
641
- await act(async () => {
642
- resolvers[1]([{ value: "labrador", label: "Labrador Retriever" }])
643
- })
644
- expect(screen.getByText("Labrador Retriever")).toBeInTheDocument()
232
+ it("does not overwrite the selection with a blur commit", async () => {
233
+ const onValueChange = vi.fn()
234
+ const input = renderField({ onValueChange })
235
+ fireEvent.focus(input)
236
+ fireEvent.change(input, { target: { value: "can" } })
645
237
 
646
- await act(async () => {
647
- resolvers[0]([{ value: "stale", label: "Stale Result" }])
648
- })
238
+ const row = screen.getByText("Canada")
239
+ fireEvent.blur(input)
240
+ fireEvent.click(row)
649
241
 
650
- expect(screen.queryByText("Stale Result")).not.toBeInTheDocument()
651
- expect(screen.getByText("Labrador Retriever")).toBeInTheDocument()
242
+ await waitFor(() => expect(input).toHaveValue("Canada"))
243
+ expect(onValueChange).not.toHaveBeenCalledWith(null)
652
244
  })
653
245
  })
654
246
 
655
- describe("with items", () => {
656
- const openDropdown = () => fireEvent.click(screen.getAllByRole("button")[0])
657
-
658
- it("renders items with leadingIcon", () => {
659
- renderWithTheme(
660
- <SelectField label="Country" placeholder="Select a country">
661
- <SelectField.Item
662
- value="uk"
663
- leadingIcon={<div data-testid="icon">🇬🇧</div>}
664
- >
665
- United Kingdom
666
- </SelectField.Item>
667
- </SelectField>
668
- )
669
-
670
- openDropdown()
671
- expect(screen.getByTestId("icon")).toBeInTheDocument()
672
- })
673
-
674
- it("renders items with hintText", () => {
675
- renderWithTheme(
676
- <SelectField label="Country" placeholder="Select a country">
677
- <SelectField.Item value="uk" hintText="Europe">
678
- United Kingdom
679
- </SelectField.Item>
680
- </SelectField>
681
- )
247
+ describe("free text", () => {
248
+ it("keeps the typed text and emits null when nothing matches", async () => {
249
+ const onValueChange = vi.fn()
250
+ const input = renderField({ onValueChange, defaultValue: "ca" })
251
+ fireEvent.focus(input)
252
+ fireEvent.change(input, { target: { value: "xyz" } })
253
+ fireEvent.blur(input)
682
254
 
683
- openDropdown()
684
- expect(screen.getByText("Europe")).toBeInTheDocument()
255
+ expect(input).toHaveValue("xyz")
256
+ await waitFor(() => expect(onValueChange).toHaveBeenLastCalledWith(null))
685
257
  })
686
258
 
687
- it("renders disabled items", () => {
688
- renderWithTheme(
689
- <SelectField label="Country" placeholder="Select a country">
690
- <SelectField.Item value="uk" disabled>
691
- United Kingdom
692
- </SelectField.Item>
693
- </SelectField>
694
- )
259
+ it("commits an exactly typed label without selecting from the list", async () => {
260
+ const onValueChange = vi.fn()
261
+ const input = renderField({ onValueChange })
262
+ fireEvent.focus(input)
263
+ fireEvent.change(input, { target: { value: "canada" } })
264
+ fireEvent.blur(input)
695
265
 
696
- openDropdown()
697
- const item = screen.getByText("United Kingdom")
698
- expect(item).toBeInTheDocument()
266
+ await waitFor(() => expect(onValueChange).toHaveBeenLastCalledWith("ca"))
699
267
  })
700
268
  })
701
- describe("when the field moves while the dropdown is open", () => {
702
- const restingPageY = MEASURED_TRIGGER.pageY
703
269
 
704
- beforeEach(() => {
705
- vi.useFakeTimers()
706
- })
270
+ describe("clearing", () => {
271
+ it("is hidden while the field is empty", () => {
272
+ renderField()
707
273
 
708
- afterEach(() => {
709
- MEASURED_TRIGGER.pageY = restingPageY
710
- vi.useRealTimers()
274
+ expect(screen.queryByLabelText("Clear selection")).not.toBeInTheDocument()
711
275
  })
712
276
 
713
- const renderSearchable = () =>
714
- renderWithTheme(
715
- <SelectField label="Breed" placeholder="Select a breed" searchable>
716
- <SelectField.Item value="lab">Labrador</SelectField.Item>
717
- <SelectField.Item value="pug">Pug</SelectField.Item>
718
- </SelectField>
719
- )
720
-
721
- const triggerPositionOf = (container: HTMLElement) =>
722
- container
723
- .querySelector("[data-trigger-position]")
724
- ?.getAttribute("data-trigger-position")
725
-
726
- const advanceFrames = (count: number) =>
727
- act(() => vi.advanceTimersByTime(count * 16))
728
-
729
- it("anchors the dropdown to the field once it has opened", () => {
730
- const { container } = renderSearchable()
277
+ it("appears once there is text and empties the field", () => {
278
+ const onValueChange = vi.fn()
279
+ const input = renderField({ onValueChange, defaultValue: "ca" })
731
280
 
732
- fireEvent.click(screen.getAllByRole("button")[0])
733
- advanceFrames(3)
281
+ fireEvent.click(screen.getByLabelText("Clear selection"))
734
282
 
735
- expect(triggerPositionOf(container)).toBe(
736
- JSON.stringify(MEASURED_TRIGGER)
737
- )
283
+ expect(input).toHaveValue("")
284
+ expect(onValueChange).toHaveBeenCalledWith(null)
738
285
  })
739
286
 
740
- it("follows the field to where it comes to rest", () => {
741
- const { container } = renderSearchable()
287
+ it("shows the full list again after clearing", () => {
288
+ const input = renderField({ defaultValue: "ca" })
289
+ fireEvent.focus(input)
290
+ fireEvent.click(screen.getByLabelText("Clear selection"))
742
291
 
743
- fireEvent.click(screen.getAllByRole("button")[0])
744
- advanceFrames(3)
745
- MEASURED_TRIGGER.pageY = 380
746
- advanceFrames(3)
747
-
748
- expect(triggerPositionOf(container)).toBe(
749
- JSON.stringify({ ...MEASURED_TRIGGER, pageY: 380 })
750
- )
292
+ expect(screen.getByText("Australia")).toBeInTheDocument()
751
293
  })
294
+ })
752
295
 
753
- it("holds the dropdown in place while the field is still moving", () => {
754
- const { container } = renderSearchable()
296
+ describe("controlled mode", () => {
297
+ it("shows the label for the controlled value", () => {
298
+ const input = renderField({ value: "au" })
755
299
 
756
- fireEvent.click(screen.getAllByRole("button")[0])
757
- advanceFrames(3)
300
+ expect(input).toHaveValue("Australia")
301
+ })
758
302
 
759
- for (const pageY of [650, 600, 550]) {
760
- MEASURED_TRIGGER.pageY = pageY
761
- advanceFrames(1)
762
- }
303
+ it("does not change its own value when controlled", () => {
304
+ const input = renderField({ value: "au", onValueChange: vi.fn() })
305
+ fireEvent.focus(input)
306
+ fireEvent.click(screen.getByText("Canada"))
307
+ fireEvent.blur(input)
763
308
 
764
- expect(triggerPositionOf(container)).toBe(
765
- JSON.stringify({ ...MEASURED_TRIGGER, pageY: restingPageY })
766
- )
309
+ expect(input).toHaveValue("Australia")
767
310
  })
311
+ })
312
+
313
+ describe("regression", () => {
314
+ it("keeps focus on the input while the filtered list changes", () => {
315
+ const input = renderField()
316
+ fireEvent.focus(input)
317
+ input.focus()
768
318
 
769
- it("does not track the field while the dropdown is closed", () => {
770
- const { container } = renderSearchable()
319
+ fireEvent.change(input, { target: { value: "u" } })
320
+ expect(document.activeElement).toBe(input)
771
321
 
772
- advanceFrames(3)
322
+ fireEvent.change(input, { target: { value: "un" } })
323
+ expect(document.activeElement).toBe(input)
773
324
 
774
- expect(triggerPositionOf(container)).toBeUndefined()
325
+ fireEvent.change(input, { target: { value: "zzz" } })
326
+ expect(document.activeElement).toBe(input)
775
327
  })
776
328
  })
777
329
  })