@butternutbox/pawprint-native 1.6.3 → 2.0.1

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 +61 -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 +3 -3
  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
@@ -0,0 +1,373 @@
1
+ import React from "react"
2
+ import { screen, fireEvent, act, waitFor } from "@testing-library/react"
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
4
+ import { renderWithTheme } from "../../../test-utils"
5
+ import { SelectField } from "./SelectField"
6
+ import type { SelectFieldOptionData } from "./core"
7
+
8
+ const DEFAULTS: SelectFieldOptionData[] = [
9
+ { value: "uk", label: "United Kingdom" },
10
+ { value: "ie", label: "Ireland" }
11
+ ]
12
+
13
+ const RESULTS: SelectFieldOptionData[] = [
14
+ { value: "lab", label: "Labrador Retriever" },
15
+ { value: "labradoodle", label: "Labradoodle" }
16
+ ]
17
+
18
+ const renderAsyncField = (
19
+ onSearchQuery: (query: string) => Promise<SelectFieldOptionData[]>,
20
+ extra: Partial<React.ComponentProps<typeof SelectField>> = {}
21
+ ) => {
22
+ renderWithTheme(
23
+ <SelectField
24
+ label="Breed"
25
+ placeholder="Select a breed"
26
+ options={DEFAULTS}
27
+ onSearchQuery={onSearchQuery}
28
+ {...extra}
29
+ />
30
+ )
31
+
32
+ const input = screen.getByRole("combobox") as HTMLInputElement
33
+ fireEvent.focus(input)
34
+ return input
35
+ }
36
+
37
+ const type = (input: HTMLInputElement, value: string) =>
38
+ fireEvent.change(input, { target: { value } })
39
+
40
+ describe("SelectField async search", () => {
41
+ beforeEach(() => {
42
+ vi.useFakeTimers({ shouldAdvanceTime: true })
43
+ })
44
+
45
+ afterEach(() => {
46
+ vi.useRealTimers()
47
+ })
48
+
49
+ it("shows the static options before anything is typed", () => {
50
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
51
+ renderAsyncField(onSearchQuery)
52
+
53
+ expect(screen.getByText("United Kingdom")).toBeInTheDocument()
54
+ expect(onSearchQuery).not.toHaveBeenCalled()
55
+ })
56
+
57
+ it("debounces, firing once after the last keystroke", () => {
58
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
59
+ const input = renderAsyncField(onSearchQuery)
60
+
61
+ type(input, "l")
62
+ type(input, "la")
63
+ type(input, "lab")
64
+
65
+ expect(onSearchQuery).not.toHaveBeenCalled()
66
+
67
+ act(() => {
68
+ vi.advanceTimersByTime(300)
69
+ })
70
+
71
+ expect(onSearchQuery).toHaveBeenCalledTimes(1)
72
+ expect(onSearchQuery).toHaveBeenCalledWith("lab")
73
+ })
74
+
75
+ it("honours a custom debounceMs", () => {
76
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
77
+ const input = renderAsyncField(onSearchQuery, { debounceMs: 50 })
78
+
79
+ type(input, "lab")
80
+ act(() => {
81
+ vi.advanceTimersByTime(50)
82
+ })
83
+
84
+ expect(onSearchQuery).toHaveBeenCalledTimes(1)
85
+ })
86
+
87
+ it("renders the resolved results in place of the static options", async () => {
88
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
89
+ const input = renderAsyncField(onSearchQuery)
90
+
91
+ type(input, "lab")
92
+ act(() => {
93
+ vi.advanceTimersByTime(300)
94
+ })
95
+
96
+ await waitFor(() =>
97
+ expect(screen.getByText("Labrador Retriever")).toBeInTheDocument()
98
+ )
99
+ expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
100
+ })
101
+
102
+ it("shows a loading state while the query is in flight", () => {
103
+ const onSearchQuery = vi.fn(
104
+ () => new Promise<SelectFieldOptionData[]>(() => {})
105
+ )
106
+ const input = renderAsyncField(onSearchQuery)
107
+
108
+ type(input, "lab")
109
+
110
+ expect(screen.getByLabelText("Loading results")).toBeInTheDocument()
111
+ })
112
+
113
+ it("shows errorText when the query rejects", async () => {
114
+ const onSearchQuery = vi.fn().mockRejectedValue(new Error("boom"))
115
+ const input = renderAsyncField(onSearchQuery, {
116
+ errorText: "Could not load breeds"
117
+ })
118
+
119
+ type(input, "lab")
120
+ act(() => {
121
+ vi.advanceTimersByTime(300)
122
+ })
123
+
124
+ await waitFor(() =>
125
+ expect(screen.getByText("Could not load breeds")).toBeInTheDocument()
126
+ )
127
+ })
128
+
129
+ it("shows noResultsText when the query resolves empty", async () => {
130
+ const onSearchQuery = vi.fn().mockResolvedValue([])
131
+ const input = renderAsyncField(onSearchQuery, {
132
+ noResultsText: "No breeds found"
133
+ })
134
+
135
+ type(input, "zzz")
136
+ act(() => {
137
+ vi.advanceTimersByTime(300)
138
+ })
139
+
140
+ await waitFor(() =>
141
+ expect(screen.getByText("No breeds found")).toBeInTheDocument()
142
+ )
143
+ })
144
+
145
+ describe("selecting an async result", () => {
146
+ it("shows the chosen label even though it is not in options", async () => {
147
+ const onValueChange = vi.fn()
148
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
149
+ const input = renderAsyncField(onSearchQuery, { onValueChange })
150
+
151
+ type(input, "lab")
152
+ act(() => {
153
+ vi.advanceTimersByTime(300)
154
+ })
155
+ await waitFor(() =>
156
+ expect(screen.getByText("Labradoodle")).toBeInTheDocument()
157
+ )
158
+
159
+ fireEvent.click(screen.getByText("Labradoodle"))
160
+
161
+ expect(onValueChange).toHaveBeenCalledWith("labradoodle")
162
+ expect(input).toHaveValue("Labradoodle")
163
+ })
164
+
165
+ it("keeps the label after the dropdown closes", async () => {
166
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
167
+ const input = renderAsyncField(onSearchQuery)
168
+
169
+ type(input, "lab")
170
+ act(() => {
171
+ vi.advanceTimersByTime(300)
172
+ })
173
+ await waitFor(() =>
174
+ expect(screen.getByText("Labradoodle")).toBeInTheDocument()
175
+ )
176
+
177
+ fireEvent.click(screen.getByText("Labradoodle"))
178
+ fireEvent.blur(input)
179
+
180
+ expect(input).toHaveValue("Labradoodle")
181
+ })
182
+ })
183
+
184
+ describe("reopening with a value already set", () => {
185
+ it("shows the search hint rather than no results, and fires no request", async () => {
186
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
187
+ const input = renderAsyncField(onSearchQuery, {
188
+ options: [],
189
+ searchHintText: "Type to search"
190
+ })
191
+
192
+ type(input, "lab")
193
+ act(() => {
194
+ vi.advanceTimersByTime(300)
195
+ })
196
+ await waitFor(() =>
197
+ expect(screen.getByText("Labradoodle")).toBeInTheDocument()
198
+ )
199
+ fireEvent.click(screen.getByText("Labradoodle"))
200
+ onSearchQuery.mockClear()
201
+
202
+ fireEvent.focus(input)
203
+ act(() => {
204
+ vi.advanceTimersByTime(300)
205
+ })
206
+
207
+ expect(onSearchQuery).not.toHaveBeenCalled()
208
+ expect(screen.getByText("Type to search")).toBeInTheDocument()
209
+ expect(screen.queryByText("No results found")).not.toBeInTheDocument()
210
+ })
211
+
212
+ it("opens the search box empty", async () => {
213
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
214
+ const input = renderAsyncField(onSearchQuery, { options: [] })
215
+
216
+ type(input, "lab")
217
+ act(() => {
218
+ vi.advanceTimersByTime(300)
219
+ })
220
+ await waitFor(() =>
221
+ expect(screen.getByText("Labradoodle")).toBeInTheDocument()
222
+ )
223
+ fireEvent.click(screen.getByText("Labradoodle"))
224
+
225
+ expect(input).toHaveValue("Labradoodle")
226
+ })
227
+
228
+ it("still shows a default list when one is supplied", () => {
229
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
230
+ renderAsyncField(onSearchQuery)
231
+
232
+ expect(screen.getByText("United Kingdom")).toBeInTheDocument()
233
+ expect(screen.queryByText("Type to search")).not.toBeInTheDocument()
234
+ })
235
+ })
236
+
237
+ describe("minQueryLength", () => {
238
+ it("searches on the first character by default", () => {
239
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
240
+ const input = renderAsyncField(onSearchQuery)
241
+
242
+ type(input, "l")
243
+ act(() => {
244
+ vi.advanceTimersByTime(300)
245
+ })
246
+
247
+ expect(onSearchQuery).toHaveBeenCalledWith("l")
248
+ })
249
+
250
+ it("does not search below the minimum", () => {
251
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
252
+ const input = renderAsyncField(onSearchQuery, { minQueryLength: 3 })
253
+
254
+ type(input, "la")
255
+ act(() => {
256
+ vi.advanceTimersByTime(300)
257
+ })
258
+
259
+ expect(onSearchQuery).not.toHaveBeenCalled()
260
+ })
261
+
262
+ it("searches once the minimum is reached", () => {
263
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
264
+ const input = renderAsyncField(onSearchQuery, { minQueryLength: 3 })
265
+
266
+ type(input, "lab")
267
+ act(() => {
268
+ vi.advanceTimersByTime(300)
269
+ })
270
+
271
+ expect(onSearchQuery).toHaveBeenCalledWith("lab")
272
+ })
273
+
274
+ it("shows the keep-typing hint below the minimum", () => {
275
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
276
+ const input = renderAsyncField(onSearchQuery, {
277
+ minQueryLength: 3,
278
+ searchHintText: "Type 3 or more characters"
279
+ })
280
+
281
+ type(input, "la")
282
+
283
+ expect(screen.getByText("Type 3 or more characters")).toBeInTheDocument()
284
+ expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
285
+ })
286
+
287
+ it("counts the trimmed query, not the raw one", () => {
288
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
289
+ const input = renderAsyncField(onSearchQuery, { minQueryLength: 3 })
290
+
291
+ type(input, " l ")
292
+ act(() => {
293
+ vi.advanceTimersByTime(300)
294
+ })
295
+
296
+ expect(onSearchQuery).not.toHaveBeenCalled()
297
+ })
298
+
299
+ it("still shows the static options when the query is empty", () => {
300
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
301
+ renderAsyncField(onSearchQuery, { minQueryLength: 3 })
302
+
303
+ expect(screen.getByText("United Kingdom")).toBeInTheDocument()
304
+ })
305
+ })
306
+
307
+ it("ignores a stale response superseded by a later keystroke", async () => {
308
+ const onSearchQuery = vi.fn((query: string) =>
309
+ query === "slow"
310
+ ? new Promise<SelectFieldOptionData[]>((resolve) =>
311
+ setTimeout(
312
+ () => resolve([{ value: "slow", label: "Slow result" }]),
313
+ 500
314
+ )
315
+ )
316
+ : Promise.resolve([{ value: "fast", label: "Fast result" }])
317
+ )
318
+
319
+ const input = renderAsyncField(onSearchQuery)
320
+
321
+ type(input, "slow")
322
+ act(() => {
323
+ vi.advanceTimersByTime(300)
324
+ })
325
+
326
+ type(input, "fast")
327
+ act(() => {
328
+ vi.advanceTimersByTime(300)
329
+ })
330
+
331
+ await waitFor(() =>
332
+ expect(screen.getByText("Fast result")).toBeInTheDocument()
333
+ )
334
+
335
+ act(() => {
336
+ vi.advanceTimersByTime(1000)
337
+ })
338
+
339
+ expect(screen.queryByText("Slow result")).not.toBeInTheDocument()
340
+ expect(screen.getByText("Fast result")).toBeInTheDocument()
341
+ })
342
+
343
+ it("returns to the static options when the query is cleared", async () => {
344
+ const onSearchQuery = vi.fn().mockResolvedValue(RESULTS)
345
+ const input = renderAsyncField(onSearchQuery)
346
+
347
+ type(input, "lab")
348
+ act(() => {
349
+ vi.advanceTimersByTime(300)
350
+ })
351
+ await waitFor(() =>
352
+ expect(screen.getByText("Labrador Retriever")).toBeInTheDocument()
353
+ )
354
+
355
+ type(input, "")
356
+
357
+ await waitFor(() =>
358
+ expect(screen.getByText("United Kingdom")).toBeInTheDocument()
359
+ )
360
+ })
361
+
362
+ it("does not filter the static options locally in query mode", () => {
363
+ const onSearchQuery = vi.fn(
364
+ () => new Promise<SelectFieldOptionData[]>(() => {})
365
+ )
366
+ const input = renderAsyncField(onSearchQuery)
367
+
368
+ type(input, "united")
369
+
370
+ expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
371
+ expect(screen.getByLabelText("Loading results")).toBeInTheDocument()
372
+ })
373
+ })
@@ -0,0 +1,192 @@
1
+ import React, { useState } from "react"
2
+ import { ScrollView, StyleSheet, View } from "react-native"
3
+ import { Search } from "@butternutbox/pawprint-icons/core"
4
+ import { SelectDrawerField } from "./SelectDrawerField"
5
+ import type { SelectFieldOptionData } from "./core"
6
+ import { Icon } from "../../atoms/Icon"
7
+ import { Typography } from "../../atoms/Typography"
8
+
9
+ export default {
10
+ title: "Molecules/SelectDrawerField",
11
+ component: SelectDrawerField
12
+ }
13
+
14
+ const COUNTRIES: SelectFieldOptionData[] = [
15
+ { value: "uk", label: "United Kingdom" },
16
+ { value: "ie", label: "Ireland" },
17
+ { value: "nl", label: "Netherlands" },
18
+ { value: "de", label: "Germany" },
19
+ { value: "fr", label: "France" }
20
+ ]
21
+
22
+ const BREEDS: SelectFieldOptionData[] = [
23
+ "Affenpinscher",
24
+ "Afghan Hound",
25
+ "Airedale Terrier",
26
+ "Akita",
27
+ "Border Collie",
28
+ "Border Terrier",
29
+ "Boston Terrier",
30
+ "Cavalier King Charles Spaniel",
31
+ "Cocker Spaniel",
32
+ "Dachshund",
33
+ "Flat-Coated Retriever",
34
+ "French Bulldog",
35
+ "German Shepherd",
36
+ "Golden Retriever",
37
+ "Jack Russell Terrier",
38
+ "Labrador Retriever",
39
+ "Springer Spaniel",
40
+ "Staffordshire Bull Terrier",
41
+ "West Highland Terrier",
42
+ "Yorkshire Terrier"
43
+ ].map((label) => ({ value: label.toLowerCase().replace(/\s+/g, "-"), label }))
44
+
45
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
46
+ <ScrollView contentContainerStyle={styles.wrapper}>{children}</ScrollView>
47
+ )
48
+
49
+ export const Default = () => (
50
+ <Wrapper>
51
+ <SelectDrawerField
52
+ label="Breed"
53
+ placeholder="Select a breed"
54
+ description="Opens a drawer with search"
55
+ drawerTitle="Select a breed"
56
+ searchPlaceholder="Search breeds"
57
+ noResultsText="No breeds found"
58
+ options={BREEDS}
59
+ />
60
+ </Wrapper>
61
+ )
62
+
63
+ export const WithoutSearch = () => (
64
+ <Wrapper>
65
+ <SelectDrawerField
66
+ label="Country"
67
+ placeholder="Select a country"
68
+ description="Short list, no search input"
69
+ searchable={false}
70
+ options={COUNTRIES}
71
+ />
72
+ </Wrapper>
73
+ )
74
+
75
+ export const WithOptionIcons = () => (
76
+ <Wrapper>
77
+ <SelectDrawerField
78
+ label="Search type"
79
+ placeholder="Select a type"
80
+ options={COUNTRIES.map((option) => ({
81
+ ...option,
82
+ leadingIcon: <Icon icon={Search} size="md" />
83
+ }))}
84
+ />
85
+ </Wrapper>
86
+ )
87
+
88
+ export const States = () => (
89
+ <Wrapper>
90
+ <SelectDrawerField
91
+ label="Error"
92
+ placeholder="Select a country"
93
+ error="Please choose a country"
94
+ options={COUNTRIES}
95
+ />
96
+ <SelectDrawerField
97
+ label="Error with a value"
98
+ defaultValue="uk"
99
+ error="This country is not served yet"
100
+ options={COUNTRIES}
101
+ />
102
+ <SelectDrawerField
103
+ label="Success"
104
+ state="success"
105
+ defaultValue="uk"
106
+ description="Looks good"
107
+ options={COUNTRIES}
108
+ />
109
+ <SelectDrawerField
110
+ label="Disabled"
111
+ placeholder="Cannot open"
112
+ disabled
113
+ options={COUNTRIES}
114
+ />
115
+ </Wrapper>
116
+ )
117
+
118
+ export const Controlled = () => {
119
+ const [country, setCountry] = useState<string | null>("ie")
120
+
121
+ return (
122
+ <Wrapper>
123
+ <SelectDrawerField
124
+ label="Country"
125
+ placeholder="Select a country"
126
+ value={country}
127
+ onValueChange={setCountry}
128
+ options={COUNTRIES}
129
+ />
130
+ <View>
131
+ <Typography size="sm" color="tertiary">
132
+ {`value: ${country ?? "null"}`}
133
+ </Typography>
134
+ </View>
135
+ </Wrapper>
136
+ )
137
+ }
138
+
139
+ const ALL_BREEDS: SelectFieldOptionData[] = [
140
+ "Border Collie",
141
+ "Boston Terrier",
142
+ "Cocker Spaniel",
143
+ "Flat-Coated Retriever",
144
+ "French Bulldog",
145
+ "German Shepherd",
146
+ "Golden Retriever",
147
+ "Jack Russell Terrier",
148
+ "Labradoodle",
149
+ "Labrador Retriever",
150
+ "Springer Spaniel",
151
+ "Yorkshire Terrier"
152
+ ].map((label) => ({ value: label.toLowerCase().replace(/\s+/g, "-"), label }))
153
+
154
+ const searchBreeds = (query: string) =>
155
+ new Promise<SelectFieldOptionData[]>((resolve, reject) => {
156
+ setTimeout(() => {
157
+ if (query.toLowerCase().includes("fail")) {
158
+ reject(new Error("Simulated request failure"))
159
+ return
160
+ }
161
+ resolve(
162
+ ALL_BREEDS.filter((breed) =>
163
+ breed.label.toLowerCase().includes(query.toLowerCase())
164
+ )
165
+ )
166
+ }, 600)
167
+ })
168
+
169
+ export const AsyncSearch = () => (
170
+ <Wrapper>
171
+ <SelectDrawerField
172
+ label="Breed"
173
+ placeholder="Select a breed"
174
+ description="Type to search — 'fail' triggers the error state"
175
+ noResultsText="No breeds found"
176
+ errorText="Couldn't load breeds"
177
+ onSearchQuery={searchBreeds}
178
+ options={[]}
179
+ drawerTitle="Select a breed"
180
+ searchPlaceholder="Search breeds"
181
+ />
182
+ </Wrapper>
183
+ )
184
+
185
+ const styles = StyleSheet.create({
186
+ wrapper: {
187
+ flexDirection: "column",
188
+ gap: 24,
189
+ padding: 16,
190
+ width: 360
191
+ }
192
+ })