@butternutbox/pawprint-native 0.8.0 → 0.10.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.
- package/.turbo/turbo-build.log +7 -7
- package/CHANGELOG.md +32 -0
- package/dist/index.cjs +1194 -1061
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -13
- package/dist/index.d.ts +18 -13
- package/dist/index.js +693 -561
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__mocks__/react-native.tsx +7 -0
- package/src/__mocks__/rn-primitives/select.tsx +58 -21
- package/src/components/atoms/Input/InputField.tsx +37 -6
- package/src/components/atoms/Input/index.ts +1 -1
- package/src/components/molecules/ProductDisplayCard/ProductDisplayCard.stories.tsx +31 -75
- package/src/components/molecules/ProductDisplayCard/ProductDisplayCard.test.tsx +1 -7
- package/src/components/molecules/ProductDisplayCard/ProductDisplayCard.tsx +93 -121
- package/src/components/molecules/ProductListingCard/Badge.tsx +2 -2
- package/src/components/molecules/ProductListingCard/ProductListingCard.stories.tsx +65 -1
- package/src/components/molecules/ProductListingCard/ProductListingCard.tsx +38 -52
- package/src/components/molecules/SelectField/SelectField.stories.tsx +110 -5
- package/src/components/molecules/SelectField/SelectField.test.tsx +174 -2
- package/src/components/molecules/SelectField/SelectField.tsx +186 -30
- package/src/components/molecules/SelectField/SelectFieldContent.tsx +8 -12
- package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +43 -25
- package/src/components/molecules/SelectField/SelectFieldValue.tsx +17 -18
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react"
|
|
2
2
|
import { View } from "react-native"
|
|
3
|
-
import { screen } from "@testing-library/react"
|
|
3
|
+
import { screen, fireEvent, act } from "@testing-library/react"
|
|
4
4
|
import { describe, it, expect, vi } from "vitest"
|
|
5
5
|
import { renderWithTheme } from "../../../test-utils"
|
|
6
6
|
import { SelectField } from "./SelectField"
|
|
@@ -194,7 +194,7 @@ describe("SelectField", () => {
|
|
|
194
194
|
})
|
|
195
195
|
|
|
196
196
|
describe("uncontrolled mode with defaultValue", () => {
|
|
197
|
-
it("
|
|
197
|
+
it("renders with defaultValue without crashing and shows items when opened", () => {
|
|
198
198
|
renderWithTheme(
|
|
199
199
|
<SelectField
|
|
200
200
|
label="Country"
|
|
@@ -206,11 +206,180 @@ describe("SelectField", () => {
|
|
|
206
206
|
</SelectField>
|
|
207
207
|
)
|
|
208
208
|
|
|
209
|
+
fireEvent.click(screen.getAllByRole("button")[0])
|
|
209
210
|
expect(screen.getByText("United Kingdom")).toBeInTheDocument()
|
|
210
211
|
})
|
|
211
212
|
})
|
|
212
213
|
|
|
214
|
+
describe("when searchable", () => {
|
|
215
|
+
const openDropdown = () => {
|
|
216
|
+
fireEvent.click(screen.getAllByRole("button")[0])
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const openDropdownWithContent = () => {
|
|
220
|
+
vi.useFakeTimers()
|
|
221
|
+
fireEvent.click(screen.getAllByRole("button")[0])
|
|
222
|
+
act(() => vi.advanceTimersByTime(500))
|
|
223
|
+
vi.useRealTimers()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
it("shows search input in the trigger when dropdown is opened", () => {
|
|
227
|
+
renderWithTheme(
|
|
228
|
+
<SelectField label="Country" placeholder="Select a country" searchable>
|
|
229
|
+
<SelectField.Item value="uk">United Kingdom</SelectField.Item>
|
|
230
|
+
<SelectField.Item value="us">United States</SelectField.Item>
|
|
231
|
+
</SelectField>
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument()
|
|
235
|
+
openDropdown()
|
|
236
|
+
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument()
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("does not show search input when not searchable", () => {
|
|
240
|
+
renderWithTheme(
|
|
241
|
+
<SelectField label="Country" placeholder="Select a country">
|
|
242
|
+
<SelectField.Item value="uk">United Kingdom</SelectField.Item>
|
|
243
|
+
<SelectField.Item value="us">United States</SelectField.Item>
|
|
244
|
+
</SelectField>
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
openDropdown()
|
|
248
|
+
expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("renders with a custom searchPlaceholder", () => {
|
|
252
|
+
renderWithTheme(
|
|
253
|
+
<SelectField
|
|
254
|
+
label="Country"
|
|
255
|
+
placeholder="Select a country"
|
|
256
|
+
searchable
|
|
257
|
+
searchPlaceholder="Find a country..."
|
|
258
|
+
>
|
|
259
|
+
<SelectField.Item value="uk">United Kingdom</SelectField.Item>
|
|
260
|
+
</SelectField>
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
openDropdown()
|
|
264
|
+
expect(
|
|
265
|
+
screen.getByPlaceholderText("Find a country...")
|
|
266
|
+
).toBeInTheDocument()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("filters items based on the search query", () => {
|
|
270
|
+
renderWithTheme(
|
|
271
|
+
<SelectField label="Country" placeholder="Select a country" searchable>
|
|
272
|
+
<SelectField.Item value="uk">United Kingdom</SelectField.Item>
|
|
273
|
+
<SelectField.Item value="us">United States</SelectField.Item>
|
|
274
|
+
<SelectField.Item value="ca">Canada</SelectField.Item>
|
|
275
|
+
</SelectField>
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
openDropdownWithContent()
|
|
279
|
+
fireEvent.change(screen.getByPlaceholderText("Search..."), {
|
|
280
|
+
target: { value: "united" }
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
expect(screen.getByText("United Kingdom")).toBeInTheDocument()
|
|
284
|
+
expect(screen.getByText("United States")).toBeInTheDocument()
|
|
285
|
+
expect(screen.queryByText("Canada")).not.toBeInTheDocument()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("is case-insensitive when filtering", () => {
|
|
289
|
+
renderWithTheme(
|
|
290
|
+
<SelectField label="Country" placeholder="Select a country" searchable>
|
|
291
|
+
<SelectField.Item value="uk">United Kingdom</SelectField.Item>
|
|
292
|
+
<SelectField.Item value="ca">Canada</SelectField.Item>
|
|
293
|
+
</SelectField>
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
openDropdownWithContent()
|
|
297
|
+
fireEvent.change(screen.getByPlaceholderText("Search..."), {
|
|
298
|
+
target: { value: "CANADA" }
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
expect(screen.getByText("Canada")).toBeInTheDocument()
|
|
302
|
+
expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("shows default 'No results found' when search matches no items", () => {
|
|
306
|
+
renderWithTheme(
|
|
307
|
+
<SelectField label="Country" placeholder="Select a country" searchable>
|
|
308
|
+
<SelectField.Item value="uk">United Kingdom</SelectField.Item>
|
|
309
|
+
<SelectField.Item value="us">United States</SelectField.Item>
|
|
310
|
+
</SelectField>
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
openDropdownWithContent()
|
|
314
|
+
fireEvent.change(screen.getByPlaceholderText("Search..."), {
|
|
315
|
+
target: { value: "xyz" }
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
expect(screen.getByText("No results found")).toBeInTheDocument()
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it("shows custom noResultsText when provided", () => {
|
|
322
|
+
renderWithTheme(
|
|
323
|
+
<SelectField
|
|
324
|
+
label="Country"
|
|
325
|
+
placeholder="Select a country"
|
|
326
|
+
searchable
|
|
327
|
+
noResultsText="No countries match your search"
|
|
328
|
+
>
|
|
329
|
+
<SelectField.Item value="uk">United Kingdom</SelectField.Item>
|
|
330
|
+
</SelectField>
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
openDropdownWithContent()
|
|
334
|
+
fireEvent.change(screen.getByPlaceholderText("Search..."), {
|
|
335
|
+
target: { value: "xyz" }
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
expect(
|
|
339
|
+
screen.getByText("No countries match your search")
|
|
340
|
+
).toBeInTheDocument()
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it("shows all items when search query is cleared", () => {
|
|
344
|
+
renderWithTheme(
|
|
345
|
+
<SelectField label="Country" placeholder="Select a country" searchable>
|
|
346
|
+
<SelectField.Item value="uk">United Kingdom</SelectField.Item>
|
|
347
|
+
<SelectField.Item value="ca">Canada</SelectField.Item>
|
|
348
|
+
</SelectField>
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
openDropdownWithContent()
|
|
352
|
+
const input = screen.getByPlaceholderText("Search...")
|
|
353
|
+
fireEvent.change(input, { target: { value: "canada" } })
|
|
354
|
+
expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
|
|
355
|
+
|
|
356
|
+
fireEvent.change(input, { target: { value: "" } })
|
|
357
|
+
expect(screen.getByText("United Kingdom")).toBeInTheDocument()
|
|
358
|
+
expect(screen.getByText("Canada")).toBeInTheDocument()
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it("closes the dropdown and clears the search when closed", () => {
|
|
362
|
+
renderWithTheme(
|
|
363
|
+
<SelectField label="Country" placeholder="Select a country" searchable>
|
|
364
|
+
<SelectField.Item value="uk">United Kingdom</SelectField.Item>
|
|
365
|
+
<SelectField.Item value="ca">Canada</SelectField.Item>
|
|
366
|
+
</SelectField>
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
openDropdownWithContent()
|
|
370
|
+
fireEvent.change(screen.getByPlaceholderText("Search..."), {
|
|
371
|
+
target: { value: "canada" }
|
|
372
|
+
})
|
|
373
|
+
expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
|
|
374
|
+
|
|
375
|
+
fireEvent.click(screen.getByTestId("select-overlay"))
|
|
376
|
+
expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument()
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
213
380
|
describe("with items", () => {
|
|
381
|
+
const openDropdown = () => fireEvent.click(screen.getAllByRole("button")[0])
|
|
382
|
+
|
|
214
383
|
it("renders items with leadingIcon", () => {
|
|
215
384
|
renderWithTheme(
|
|
216
385
|
<SelectField label="Country" placeholder="Select a country">
|
|
@@ -223,6 +392,7 @@ describe("SelectField", () => {
|
|
|
223
392
|
</SelectField>
|
|
224
393
|
)
|
|
225
394
|
|
|
395
|
+
openDropdown()
|
|
226
396
|
expect(screen.getByTestId("icon")).toBeInTheDocument()
|
|
227
397
|
})
|
|
228
398
|
|
|
@@ -235,6 +405,7 @@ describe("SelectField", () => {
|
|
|
235
405
|
</SelectField>
|
|
236
406
|
)
|
|
237
407
|
|
|
408
|
+
openDropdown()
|
|
238
409
|
expect(screen.getByText("Europe")).toBeInTheDocument()
|
|
239
410
|
})
|
|
240
411
|
|
|
@@ -247,6 +418,7 @@ describe("SelectField", () => {
|
|
|
247
418
|
</SelectField>
|
|
248
419
|
)
|
|
249
420
|
|
|
421
|
+
openDropdown()
|
|
250
422
|
const item = screen.getByText("United Kingdom")
|
|
251
423
|
expect(item).toBeInTheDocument()
|
|
252
424
|
})
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import React from "react"
|
|
2
|
-
import { View, ViewProps } from "react-native"
|
|
2
|
+
import { View, ViewProps, TextInput, Keyboard, Pressable } from "react-native"
|
|
3
3
|
import styled from "@emotion/native"
|
|
4
4
|
import * as SelectPrimitive from "@rn-primitives/select"
|
|
5
5
|
import type { Option } from "@rn-primitives/select"
|
|
6
6
|
import type { InputState } from "../../atoms/Input/InputField"
|
|
7
|
+
import { InputText } from "../../atoms/Input/InputField"
|
|
7
8
|
import { InputError } from "../../atoms/Input/InputError"
|
|
8
9
|
import { InputLabel } from "../../atoms/Input/InputLabel"
|
|
9
10
|
import { InputDescription } from "../../atoms/Input/InputDescription"
|
|
11
|
+
import { Typography } from "../../atoms/Typography"
|
|
12
|
+
import { Icon } from "../../atoms/Icon"
|
|
13
|
+
import { Search, Cancel } from "@butternutbox/pawprint-icons/core"
|
|
10
14
|
import { SelectFieldTrigger } from "./SelectFieldTrigger"
|
|
11
15
|
import { SelectFieldValue } from "./SelectFieldValue"
|
|
12
16
|
import { SelectFieldContent } from "./SelectFieldContent"
|
|
@@ -14,6 +18,18 @@ import { SelectFieldItem } from "./SelectFieldItem"
|
|
|
14
18
|
|
|
15
19
|
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
16
20
|
|
|
21
|
+
const StyledNoResults = styled(View)(({ theme }) => {
|
|
22
|
+
const { dropdown } = theme.tokens.components.dropdownList
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
paddingVertical: parseTokenValue(dropdown.listItem.spacing.verticalPadding),
|
|
26
|
+
paddingHorizontal: parseTokenValue(
|
|
27
|
+
dropdown.listItem.spacing.horiztonalPadding
|
|
28
|
+
),
|
|
29
|
+
alignItems: "center"
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
17
33
|
const StyledRoot = styled(View)(({ theme }) => {
|
|
18
34
|
const { spacing } = theme.tokens.components.inputs
|
|
19
35
|
|
|
@@ -35,6 +51,9 @@ type SelectFieldOwnProps<Value = Option | string> = {
|
|
|
35
51
|
onValueChange?: (value: Value | null) => void
|
|
36
52
|
children?: React.ReactNode
|
|
37
53
|
disabled?: boolean
|
|
54
|
+
searchable?: boolean
|
|
55
|
+
searchPlaceholder?: string
|
|
56
|
+
noResultsText?: string
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
export type SelectFieldProps<Value = Option> = SelectFieldOwnProps<Value> &
|
|
@@ -79,6 +98,21 @@ export type SelectFieldProps<Value = Option> = SelectFieldOwnProps<Value> &
|
|
|
79
98
|
* </SelectField>
|
|
80
99
|
* ```
|
|
81
100
|
*
|
|
101
|
+
* **Searchable:**
|
|
102
|
+
* @example
|
|
103
|
+
* ```tsx
|
|
104
|
+
* <SelectField
|
|
105
|
+
* label="Country"
|
|
106
|
+
* placeholder="Select a country"
|
|
107
|
+
* searchable
|
|
108
|
+
* searchPlaceholder="Search countries..."
|
|
109
|
+
* noResultsText="No countries found"
|
|
110
|
+
* >
|
|
111
|
+
* <SelectField.Item value="uk">United Kingdom</SelectField.Item>
|
|
112
|
+
* <SelectField.Item value="us">United States</SelectField.Item>
|
|
113
|
+
* </SelectField>
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
82
116
|
* **Compound Component API:**
|
|
83
117
|
* @example
|
|
84
118
|
* ```tsx
|
|
@@ -107,6 +141,9 @@ export type SelectFieldProps<Value = Option> = SelectFieldOwnProps<Value> &
|
|
|
107
141
|
* @param {Value | null} [defaultValue] - Default value for uncontrolled mode
|
|
108
142
|
* @param {function} [onValueChange] - Value change handler
|
|
109
143
|
* @param {boolean} [disabled] - Disables the select
|
|
144
|
+
* @param {boolean} [searchable=false] - Enables inline search filtering of items
|
|
145
|
+
* @param {string} [searchPlaceholder="Search..."] - Placeholder text for the search input
|
|
146
|
+
* @param {string} [noResultsText="No results found"] - Text shown when search returns no matches
|
|
110
147
|
*/
|
|
111
148
|
const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
|
|
112
149
|
(
|
|
@@ -123,14 +160,57 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
|
|
|
123
160
|
defaultValue,
|
|
124
161
|
onValueChange,
|
|
125
162
|
disabled,
|
|
163
|
+
searchable = false,
|
|
164
|
+
searchPlaceholder,
|
|
165
|
+
noResultsText = "No results found",
|
|
126
166
|
...rest
|
|
127
167
|
},
|
|
128
168
|
ref
|
|
129
169
|
) => {
|
|
130
170
|
const [isOpen, setIsOpen] = React.useState(false)
|
|
171
|
+
const [showContent, setShowContent] = React.useState(false)
|
|
131
172
|
const [internalValue, setInternalValue] = React.useState(
|
|
132
173
|
defaultValue ?? null
|
|
133
174
|
)
|
|
175
|
+
const [searchQuery, setSearchQuery] = React.useState("")
|
|
176
|
+
const [clearKey, setClearKey] = React.useState(0)
|
|
177
|
+
const searchInputRef = React.useRef<TextInput>(null)
|
|
178
|
+
|
|
179
|
+
React.useEffect(() => {
|
|
180
|
+
if (!isOpen) {
|
|
181
|
+
setShowContent(false)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if (!searchable) {
|
|
185
|
+
setShowContent(true)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const openTimer = setTimeout(() => {
|
|
190
|
+
setShowContent(true)
|
|
191
|
+
searchInputRef.current?.focus()
|
|
192
|
+
}, 0)
|
|
193
|
+
|
|
194
|
+
// The Portal cascade (SelectPrimitive.Content onLayout → setContentLayout
|
|
195
|
+
// → Root context re-render via Zustand) blurs the input after content
|
|
196
|
+
// mounts. Re-focus once the keyboard is fully shown
|
|
197
|
+
const keyboardSub = Keyboard.addListener("keyboardDidShow", () => {
|
|
198
|
+
setTimeout(() => searchInputRef.current?.focus(), 0)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// Hardware keyboard: keyboardDidShow never fires, so re-focus after a
|
|
202
|
+
// short delay to recover from the Portal cascade blur.
|
|
203
|
+
const hardwareKeyboardTimer = setTimeout(
|
|
204
|
+
() => searchInputRef.current?.focus(),
|
|
205
|
+
100
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return () => {
|
|
209
|
+
clearTimeout(openTimer)
|
|
210
|
+
clearTimeout(hardwareKeyboardTimer)
|
|
211
|
+
keyboardSub.remove()
|
|
212
|
+
}
|
|
213
|
+
}, [isOpen, searchable])
|
|
134
214
|
|
|
135
215
|
const isCompound = React.Children.toArray(children).some(
|
|
136
216
|
(child) =>
|
|
@@ -151,15 +231,68 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
|
|
|
151
231
|
onValueChange?.(valueToSet)
|
|
152
232
|
}
|
|
153
233
|
|
|
234
|
+
const handleOpenChange = (open: boolean) => {
|
|
235
|
+
setIsOpen(open)
|
|
236
|
+
if (!open) setSearchQuery("")
|
|
237
|
+
}
|
|
238
|
+
|
|
154
239
|
const currentValue = value !== undefined ? value : internalValue
|
|
155
240
|
|
|
241
|
+
const allItems = React.Children.toArray(children)
|
|
242
|
+
|
|
243
|
+
const filteredItems = searchable
|
|
244
|
+
? allItems.filter((child) => {
|
|
245
|
+
if (!searchQuery) return true
|
|
246
|
+
if (React.isValidElement(child) && child.type === SelectFieldItem) {
|
|
247
|
+
const label = String(
|
|
248
|
+
(child.props as { children?: unknown }).children ?? ""
|
|
249
|
+
)
|
|
250
|
+
return label.toLowerCase().includes(searchQuery.toLowerCase())
|
|
251
|
+
}
|
|
252
|
+
return true
|
|
253
|
+
})
|
|
254
|
+
: allItems
|
|
255
|
+
|
|
256
|
+
const hasNoResults =
|
|
257
|
+
searchable && searchQuery.length > 0 && filteredItems.length === 0
|
|
258
|
+
|
|
259
|
+
const searchActionIcon = (() => {
|
|
260
|
+
if (!searchable) return undefined
|
|
261
|
+
|
|
262
|
+
const showClear =
|
|
263
|
+
(isOpen && searchQuery.length > 0) || (!isOpen && Boolean(currentValue))
|
|
264
|
+
if (!showClear) return <Icon icon={Search} size="md" />
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<Pressable
|
|
268
|
+
onPress={
|
|
269
|
+
isOpen
|
|
270
|
+
? () => {
|
|
271
|
+
setSearchQuery("")
|
|
272
|
+
searchInputRef.current?.focus()
|
|
273
|
+
}
|
|
274
|
+
: () => {
|
|
275
|
+
setInternalValue(null)
|
|
276
|
+
onValueChange?.(null)
|
|
277
|
+
setClearKey((k) => k + 1)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
accessibilityLabel={isOpen ? "Clear search" : "Clear selection"}
|
|
281
|
+
accessibilityRole="button"
|
|
282
|
+
hitSlop={8}
|
|
283
|
+
>
|
|
284
|
+
<Icon icon={Cancel} size="md" />
|
|
285
|
+
</Pressable>
|
|
286
|
+
)
|
|
287
|
+
})()
|
|
288
|
+
|
|
156
289
|
return (
|
|
157
290
|
<SelectPrimitive.Root
|
|
158
|
-
|
|
159
|
-
|
|
291
|
+
key={clearKey}
|
|
292
|
+
value={currentValue ?? undefined}
|
|
160
293
|
onValueChange={handleValueChange}
|
|
161
294
|
disabled={disabled}
|
|
162
|
-
onOpenChange={
|
|
295
|
+
onOpenChange={handleOpenChange}
|
|
163
296
|
>
|
|
164
297
|
<StyledRoot ref={ref} {...rest}>
|
|
165
298
|
{label && (
|
|
@@ -171,39 +304,62 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
|
|
|
171
304
|
state={state}
|
|
172
305
|
leadingIcon={leadingIcon}
|
|
173
306
|
isOpen={isOpen}
|
|
307
|
+
isSearching={searchable && isOpen}
|
|
308
|
+
actionIcon={searchActionIcon}
|
|
174
309
|
>
|
|
175
|
-
|
|
310
|
+
{searchable && isOpen ? (
|
|
311
|
+
<InputText
|
|
312
|
+
ref={searchInputRef}
|
|
313
|
+
value={searchQuery}
|
|
314
|
+
onChangeText={setSearchQuery}
|
|
315
|
+
placeholder={searchPlaceholder ?? "Search..."}
|
|
316
|
+
returnKeyType="search"
|
|
317
|
+
clearButtonMode="never"
|
|
318
|
+
accessibilityLabel="Search options"
|
|
319
|
+
/>
|
|
320
|
+
) : (
|
|
321
|
+
<SelectFieldValue placeholder={placeholder} />
|
|
322
|
+
)}
|
|
176
323
|
</SelectFieldTrigger>
|
|
177
324
|
{description && (
|
|
178
325
|
<InputDescription state={state}>{description}</InputDescription>
|
|
179
326
|
)}
|
|
180
327
|
{error && state === "error" && <InputError>{error}</InputError>}
|
|
181
328
|
</StyledRoot>
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
329
|
+
{showContent && (
|
|
330
|
+
<SelectFieldContent>
|
|
331
|
+
{filteredItems.map((child) => {
|
|
332
|
+
if (
|
|
333
|
+
React.isValidElement(child) &&
|
|
334
|
+
child.type === SelectFieldItem
|
|
335
|
+
) {
|
|
336
|
+
const selectedValue =
|
|
337
|
+
currentValue &&
|
|
338
|
+
typeof currentValue === "object" &&
|
|
339
|
+
"value" in currentValue
|
|
340
|
+
? (currentValue as { value: string }).value
|
|
341
|
+
: currentValue
|
|
342
|
+
|
|
343
|
+
const childProps = child.props as { value: string }
|
|
344
|
+
return React.cloneElement(
|
|
345
|
+
child as React.ReactElement<{
|
|
346
|
+
value: string
|
|
347
|
+
isSelected?: boolean
|
|
348
|
+
}>,
|
|
349
|
+
{
|
|
350
|
+
isSelected: childProps.value === selectedValue
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
return child
|
|
355
|
+
})}
|
|
356
|
+
{hasNoResults && (
|
|
357
|
+
<StyledNoResults>
|
|
358
|
+
<Typography size="sm">{noResultsText}</Typography>
|
|
359
|
+
</StyledNoResults>
|
|
360
|
+
)}
|
|
361
|
+
</SelectFieldContent>
|
|
362
|
+
)}
|
|
207
363
|
</SelectPrimitive.Root>
|
|
208
364
|
)
|
|
209
365
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react"
|
|
2
|
-
import { View, ViewProps, ScrollView } from "react-native"
|
|
2
|
+
import { View, ViewProps, ScrollView, StyleSheet } from "react-native"
|
|
3
3
|
import styled from "@emotion/native"
|
|
4
4
|
import { useTheme } from "@emotion/react"
|
|
5
5
|
import * as SelectPrimitive from "@rn-primitives/select"
|
|
@@ -11,11 +11,6 @@ type SelectFieldContentProps = {
|
|
|
11
11
|
|
|
12
12
|
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
13
13
|
|
|
14
|
-
// TODO: Replace with dynamic positioning when @rn-primitives/select supports it on React Native
|
|
15
|
-
// These values position the dropdown below and aligned with the trigger
|
|
16
|
-
const DROPDOWN_OFFSET_X = -32 // Align with trigger left edge
|
|
17
|
-
const DROPDOWN_OFFSET_Y = -94 // Position below trigger with gap
|
|
18
|
-
|
|
19
14
|
const StyledContentShadow = styled(View)(({ theme }) => {
|
|
20
15
|
const { dropdown } = theme.tokens.components.dropdownList
|
|
21
16
|
const shadow = dropdown.list.dropshadow.default
|
|
@@ -29,11 +24,7 @@ const StyledContentShadow = styled(View)(({ theme }) => {
|
|
|
29
24
|
},
|
|
30
25
|
shadowOpacity: 1,
|
|
31
26
|
shadowRadius: parseTokenValue(shadow.blur),
|
|
32
|
-
elevation: 8
|
|
33
|
-
transform: [
|
|
34
|
-
{ translateX: DROPDOWN_OFFSET_X },
|
|
35
|
-
{ translateY: DROPDOWN_OFFSET_Y }
|
|
36
|
-
]
|
|
27
|
+
elevation: 8
|
|
37
28
|
}
|
|
38
29
|
})
|
|
39
30
|
|
|
@@ -59,6 +50,7 @@ export const SelectFieldContent = React.forwardRef<
|
|
|
59
50
|
|
|
60
51
|
return (
|
|
61
52
|
<SelectPrimitive.Portal>
|
|
53
|
+
<SelectPrimitive.Overlay style={StyleSheet.absoluteFill} />
|
|
62
54
|
<SelectPrimitive.Content
|
|
63
55
|
side="bottom"
|
|
64
56
|
align="start"
|
|
@@ -70,7 +62,11 @@ export const SelectFieldContent = React.forwardRef<
|
|
|
70
62
|
<StyledContentShadow {...rest}>
|
|
71
63
|
<StyledContentInner>
|
|
72
64
|
<SelectPrimitive.Viewport>
|
|
73
|
-
<ScrollView
|
|
65
|
+
<ScrollView
|
|
66
|
+
showsVerticalScrollIndicator
|
|
67
|
+
nestedScrollEnabled
|
|
68
|
+
keyboardShouldPersistTaps="handled"
|
|
69
|
+
>
|
|
74
70
|
{children}
|
|
75
71
|
</ScrollView>
|
|
76
72
|
</SelectPrimitive.Viewport>
|
|
@@ -17,6 +17,8 @@ type SelectFieldTriggerOwnProps = {
|
|
|
17
17
|
leadingIcon?: React.ReactNode
|
|
18
18
|
children?: React.ReactNode
|
|
19
19
|
isOpen?: boolean
|
|
20
|
+
isSearching?: boolean
|
|
21
|
+
actionIcon?: React.ReactNode
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export type SelectFieldTriggerProps = SelectFieldTriggerOwnProps &
|
|
@@ -59,7 +61,15 @@ export const SelectFieldTrigger = React.forwardRef<
|
|
|
59
61
|
SelectFieldTriggerProps
|
|
60
62
|
>(
|
|
61
63
|
(
|
|
62
|
-
{
|
|
64
|
+
{
|
|
65
|
+
state = "default",
|
|
66
|
+
leadingIcon,
|
|
67
|
+
children,
|
|
68
|
+
isOpen = false,
|
|
69
|
+
isSearching = false,
|
|
70
|
+
actionIcon,
|
|
71
|
+
...rest
|
|
72
|
+
},
|
|
63
73
|
ref
|
|
64
74
|
) => {
|
|
65
75
|
const theme = useTheme()
|
|
@@ -134,33 +144,41 @@ export const SelectFieldTrigger = React.forwardRef<
|
|
|
134
144
|
<Icon icon={SuccessIcon} size="md" colour="success" />
|
|
135
145
|
) : null
|
|
136
146
|
|
|
147
|
+
const triggerContent = (
|
|
148
|
+
<StyledTriggerWrapper
|
|
149
|
+
state={state}
|
|
150
|
+
style={[
|
|
151
|
+
{
|
|
152
|
+
borderColor,
|
|
153
|
+
borderWidth: animatedBorderWidth,
|
|
154
|
+
paddingVertical: animatedPaddingVertical,
|
|
155
|
+
paddingHorizontal: animatedPaddingHorizontal
|
|
156
|
+
}
|
|
157
|
+
]}
|
|
158
|
+
>
|
|
159
|
+
{leadingIcon && <StyledIconWrapper>{leadingIcon}</StyledIconWrapper>}
|
|
160
|
+
{children}
|
|
161
|
+
{stateIcon && <StyledIconWrapper>{stateIcon}</StyledIconWrapper>}
|
|
162
|
+
{actionIcon ? (
|
|
163
|
+
<StyledIconWrapper>{actionIcon}</StyledIconWrapper>
|
|
164
|
+
) : (
|
|
165
|
+
<StyledArrowIcon
|
|
166
|
+
style={{ transform: [{ rotate: animatedRotation }] }}
|
|
167
|
+
>
|
|
168
|
+
<Icon icon={KeyboardArrowDown} size="md" colour="primary" />
|
|
169
|
+
</StyledArrowIcon>
|
|
170
|
+
)}
|
|
171
|
+
</StyledTriggerWrapper>
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if (isSearching) {
|
|
175
|
+
return <View ref={ref}>{triggerContent}</View>
|
|
176
|
+
}
|
|
177
|
+
|
|
137
178
|
return (
|
|
138
179
|
<SelectPrimitive.Trigger asChild>
|
|
139
180
|
<Slot ref={ref}>
|
|
140
|
-
<Pressable {...rest}>
|
|
141
|
-
<StyledTriggerWrapper
|
|
142
|
-
state={state}
|
|
143
|
-
style={[
|
|
144
|
-
{
|
|
145
|
-
borderColor,
|
|
146
|
-
borderWidth: animatedBorderWidth,
|
|
147
|
-
paddingVertical: animatedPaddingVertical,
|
|
148
|
-
paddingHorizontal: animatedPaddingHorizontal
|
|
149
|
-
}
|
|
150
|
-
]}
|
|
151
|
-
>
|
|
152
|
-
{leadingIcon && (
|
|
153
|
-
<StyledIconWrapper>{leadingIcon}</StyledIconWrapper>
|
|
154
|
-
)}
|
|
155
|
-
{children}
|
|
156
|
-
{stateIcon && <StyledIconWrapper>{stateIcon}</StyledIconWrapper>}
|
|
157
|
-
<StyledArrowIcon
|
|
158
|
-
style={{ transform: [{ rotate: animatedRotation }] }}
|
|
159
|
-
>
|
|
160
|
-
<Icon icon={KeyboardArrowDown} size="md" colour="primary" />
|
|
161
|
-
</StyledArrowIcon>
|
|
162
|
-
</StyledTriggerWrapper>
|
|
163
|
-
</Pressable>
|
|
181
|
+
<Pressable {...rest}>{triggerContent}</Pressable>
|
|
164
182
|
</Slot>
|
|
165
183
|
</SelectPrimitive.Trigger>
|
|
166
184
|
)
|