@butternutbox/pawprint-native 0.22.0 → 0.23.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 +9 -9
- package/CHANGELOG.md +12 -0
- package/dist/index.cjs +93 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +93 -23
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/atoms/Button/Button.stories.tsx +22 -2
- package/src/components/atoms/Button/Button.tsx +6 -5
- package/src/components/atoms/IconButton/IconButton.tsx +1 -1
- package/src/components/atoms/Tag/Tag.stories.tsx +11 -1
- package/src/components/atoms/Tag/Tag.tsx +12 -2
- package/src/components/molecules/Checkbox/Checkbox.stories.tsx +2 -1
- package/src/components/molecules/Checkbox/Checkbox.tsx +10 -6
- package/src/components/molecules/Drawer/DrawerBody.tsx +1 -1
- package/src/components/molecules/SelectField/SelectField.stories.tsx +53 -0
- package/src/components/molecules/SelectField/SelectField.test.tsx +257 -1
- package/src/components/molecules/SelectField/SelectField.tsx +193 -46
- package/src/components/molecules/SelectField/index.ts +1 -1
- package/src/components/molecules/Slider/Slider.tsx +1 -1
|
@@ -10,6 +10,7 @@ import { InputLabel } from "../../atoms/Input/InputLabel"
|
|
|
10
10
|
import { InputDescription } from "../../atoms/Input/InputDescription"
|
|
11
11
|
import { Typography } from "../../atoms/Typography"
|
|
12
12
|
import { Icon } from "../../atoms/Icon"
|
|
13
|
+
import { Spinner } from "../../atoms/Spinner"
|
|
13
14
|
import { Search, Cancel } from "@butternutbox/pawprint-icons/core"
|
|
14
15
|
import { SelectFieldTrigger } from "./SelectFieldTrigger"
|
|
15
16
|
import { SelectFieldValue } from "./SelectFieldValue"
|
|
@@ -18,7 +19,7 @@ import { SelectFieldItem } from "./SelectFieldItem"
|
|
|
18
19
|
|
|
19
20
|
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
20
21
|
|
|
21
|
-
const
|
|
22
|
+
const StyledStatusRow = styled(View)(({ theme }) => {
|
|
22
23
|
const { dropdown } = theme.tokens.components.dropdownList
|
|
23
24
|
|
|
24
25
|
return {
|
|
@@ -38,6 +39,18 @@ const StyledRoot = styled(View)(({ theme }) => {
|
|
|
38
39
|
}
|
|
39
40
|
})
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* A single item returned by an async `onSearchQuery` resolver. Mirrors the
|
|
44
|
+
* props of `SelectField.Item` so results render identically to static items.
|
|
45
|
+
*/
|
|
46
|
+
export type SelectFieldSearchResult = {
|
|
47
|
+
value: string
|
|
48
|
+
label: string
|
|
49
|
+
leadingIcon?: React.ReactNode
|
|
50
|
+
hintText?: string
|
|
51
|
+
disabled?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
type SelectFieldOwnProps<Value = Option | string> = {
|
|
42
55
|
label?: string
|
|
43
56
|
description?: string
|
|
@@ -54,6 +67,9 @@ type SelectFieldOwnProps<Value = Option | string> = {
|
|
|
54
67
|
searchable?: boolean
|
|
55
68
|
searchPlaceholder?: string
|
|
56
69
|
noResultsText?: string
|
|
70
|
+
onSearchQuery?: (query: string) => Promise<SelectFieldSearchResult[]>
|
|
71
|
+
debounceMs?: number
|
|
72
|
+
errorText?: string
|
|
57
73
|
}
|
|
58
74
|
|
|
59
75
|
export type SelectFieldProps<Value = Option> = SelectFieldOwnProps<Value> &
|
|
@@ -98,7 +114,7 @@ export type SelectFieldProps<Value = Option> = SelectFieldOwnProps<Value> &
|
|
|
98
114
|
* </SelectField>
|
|
99
115
|
* ```
|
|
100
116
|
*
|
|
101
|
-
* **Searchable:**
|
|
117
|
+
* **Searchable (local filtering):**
|
|
102
118
|
* @example
|
|
103
119
|
* ```tsx
|
|
104
120
|
* <SelectField
|
|
@@ -113,6 +129,25 @@ export type SelectFieldProps<Value = Option> = SelectFieldOwnProps<Value> &
|
|
|
113
129
|
* </SelectField>
|
|
114
130
|
* ```
|
|
115
131
|
*
|
|
132
|
+
* **Searchable (async query):**
|
|
133
|
+
* Pass `onSearchQuery` alongside `searchable` to fetch items as the user types.
|
|
134
|
+
* The query is debounced, and the component renders a loading spinner while it
|
|
135
|
+
* runs, `errorText` if it rejects, and `noResultsText` if it resolves empty.
|
|
136
|
+
* Any `children` are shown as the default list before the user types.
|
|
137
|
+
* @example
|
|
138
|
+
* ```tsx
|
|
139
|
+
* <SelectField
|
|
140
|
+
* label="Breed"
|
|
141
|
+
* placeholder="Select a breed"
|
|
142
|
+
* searchable
|
|
143
|
+
* searchPlaceholder="Search breeds..."
|
|
144
|
+
* onSearchQuery={async (query) => {
|
|
145
|
+
* const breeds = await api.searchBreeds(query)
|
|
146
|
+
* return breeds.map((breed) => ({ value: breed.id, label: breed.name }))
|
|
147
|
+
* }}
|
|
148
|
+
* />
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
116
151
|
* **Compound Component API:**
|
|
117
152
|
* @example
|
|
118
153
|
* ```tsx
|
|
@@ -144,6 +179,9 @@ export type SelectFieldProps<Value = Option> = SelectFieldOwnProps<Value> &
|
|
|
144
179
|
* @param {boolean} [searchable=false] - Enables inline search filtering of items
|
|
145
180
|
* @param {string} [searchPlaceholder="Search..."] - Placeholder text for the search input
|
|
146
181
|
* @param {string} [noResultsText="No results found"] - Text shown when search returns no matches
|
|
182
|
+
* @param {function} [onSearchQuery] - Async resolver run as the user types; its returned items replace the list. Enables query mode (local filtering is skipped)
|
|
183
|
+
* @param {number} [debounceMs=300] - Delay before onSearchQuery fires after the last keystroke
|
|
184
|
+
* @param {string} [errorText="Something went wrong"] - Text shown when onSearchQuery rejects
|
|
147
185
|
*/
|
|
148
186
|
const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
|
|
149
187
|
(
|
|
@@ -163,6 +201,9 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
|
|
|
163
201
|
searchable = false,
|
|
164
202
|
searchPlaceholder,
|
|
165
203
|
noResultsText = "No results found",
|
|
204
|
+
onSearchQuery,
|
|
205
|
+
debounceMs = 300,
|
|
206
|
+
errorText = "Something went wrong",
|
|
166
207
|
...rest
|
|
167
208
|
},
|
|
168
209
|
ref
|
|
@@ -176,6 +217,62 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
|
|
|
176
217
|
const [clearKey, setClearKey] = React.useState(0)
|
|
177
218
|
const searchInputRef = React.useRef<TextInput>(null)
|
|
178
219
|
|
|
220
|
+
const isQueryMode = searchable && typeof onSearchQuery === "function"
|
|
221
|
+
|
|
222
|
+
const [searchResults, setSearchResults] = React.useState<
|
|
223
|
+
SelectFieldSearchResult[]
|
|
224
|
+
>([])
|
|
225
|
+
const [isSearching, setIsSearching] = React.useState(false)
|
|
226
|
+
const [searchFailed, setSearchFailed] = React.useState(false)
|
|
227
|
+
const searchRequestIdRef = React.useRef(0)
|
|
228
|
+
|
|
229
|
+
// Keep the latest resolver in a ref so an inline `onSearchQuery` (new
|
|
230
|
+
// identity every render) does not restart the debounce on unrelated
|
|
231
|
+
// re-renders. The effect below keys on the query text, not the function.
|
|
232
|
+
const onSearchQueryRef = React.useRef(onSearchQuery)
|
|
233
|
+
React.useEffect(() => {
|
|
234
|
+
onSearchQueryRef.current = onSearchQuery
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
React.useEffect(() => {
|
|
238
|
+
if (!isQueryMode) return
|
|
239
|
+
|
|
240
|
+
// Empty query: drop any in-flight result and fall back to `children`.
|
|
241
|
+
if (searchQuery.length === 0) {
|
|
242
|
+
searchRequestIdRef.current += 1
|
|
243
|
+
setSearchResults([])
|
|
244
|
+
setIsSearching(false)
|
|
245
|
+
setSearchFailed(false)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const requestId = searchRequestIdRef.current + 1
|
|
250
|
+
searchRequestIdRef.current = requestId
|
|
251
|
+
setIsSearching(true)
|
|
252
|
+
setSearchFailed(false)
|
|
253
|
+
|
|
254
|
+
const debounceTimer = setTimeout(() => {
|
|
255
|
+
const runQuery = onSearchQueryRef.current
|
|
256
|
+
if (!runQuery) return
|
|
257
|
+
|
|
258
|
+
Promise.resolve(runQuery(searchQuery))
|
|
259
|
+
.then((items) => {
|
|
260
|
+
// Ignore a response that a newer keystroke has superseded.
|
|
261
|
+
if (searchRequestIdRef.current !== requestId) return
|
|
262
|
+
setSearchResults(items)
|
|
263
|
+
setIsSearching(false)
|
|
264
|
+
})
|
|
265
|
+
.catch(() => {
|
|
266
|
+
if (searchRequestIdRef.current !== requestId) return
|
|
267
|
+
setSearchResults([])
|
|
268
|
+
setSearchFailed(true)
|
|
269
|
+
setIsSearching(false)
|
|
270
|
+
})
|
|
271
|
+
}, debounceMs)
|
|
272
|
+
|
|
273
|
+
return () => clearTimeout(debounceTimer)
|
|
274
|
+
}, [searchQuery, isQueryMode, debounceMs])
|
|
275
|
+
|
|
179
276
|
React.useEffect(() => {
|
|
180
277
|
if (!isOpen) {
|
|
181
278
|
setShowContent(false)
|
|
@@ -210,7 +307,13 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
|
|
|
210
307
|
clearTimeout(hardwareKeyboardTimer)
|
|
211
308
|
keyboardSub.remove()
|
|
212
309
|
}
|
|
213
|
-
|
|
310
|
+
// `searchQuery` and `isSearching` are dependencies so this focus recovery
|
|
311
|
+
// re-runs while the user types, not just on open. Each keystroke changes
|
|
312
|
+
// the rendered list (local filtering re-filters it; a query mounts it on
|
|
313
|
+
// the first keystroke and later swaps in results), and every such change
|
|
314
|
+
// re-lays-out the dropdown, which triggers the same Portal-cascade blur.
|
|
315
|
+
// Re-running here re-focuses the input so typing is not interrupted.
|
|
316
|
+
}, [isOpen, searchable, isSearching, searchQuery])
|
|
214
317
|
|
|
215
318
|
const isCompound = React.Children.toArray(children).some(
|
|
216
319
|
(child) =>
|
|
@@ -240,21 +343,53 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
|
|
|
240
343
|
|
|
241
344
|
const allItems = React.Children.toArray(children)
|
|
242
345
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
346
|
+
// Local filter mode: filter the passed children by their label text.
|
|
347
|
+
// Skipped in query mode, where the resolver returns an already-filtered set.
|
|
348
|
+
const localFilteredItems =
|
|
349
|
+
searchable && !isQueryMode
|
|
350
|
+
? allItems.filter((child) => {
|
|
351
|
+
if (!searchQuery) return true
|
|
352
|
+
if (React.isValidElement(child) && child.type === SelectFieldItem) {
|
|
353
|
+
const label = String(
|
|
354
|
+
(child.props as { children?: unknown }).children ?? ""
|
|
355
|
+
)
|
|
356
|
+
return label.toLowerCase().includes(searchQuery.toLowerCase())
|
|
357
|
+
}
|
|
358
|
+
return true
|
|
359
|
+
})
|
|
360
|
+
: allItems
|
|
361
|
+
|
|
362
|
+
// Query mode, once the user has typed: render the resolver's results.
|
|
363
|
+
// Before that, `children` act as the default list (via localFilteredItems).
|
|
364
|
+
const isQueryActive = isQueryMode && searchQuery.length > 0
|
|
365
|
+
|
|
366
|
+
const queryResultItems = searchResults.map((result) => (
|
|
367
|
+
<SelectFieldItem
|
|
368
|
+
key={String(result.value)}
|
|
369
|
+
value={result.value}
|
|
370
|
+
leadingIcon={result.leadingIcon}
|
|
371
|
+
hintText={result.hintText}
|
|
372
|
+
disabled={result.disabled}
|
|
373
|
+
>
|
|
374
|
+
{result.label}
|
|
375
|
+
</SelectFieldItem>
|
|
376
|
+
))
|
|
255
377
|
|
|
256
|
-
const
|
|
257
|
-
|
|
378
|
+
const itemsToRender = isQueryActive ? queryResultItems : localFilteredItems
|
|
379
|
+
|
|
380
|
+
const hasNoResults = isQueryMode
|
|
381
|
+
? isQueryActive &&
|
|
382
|
+
!isSearching &&
|
|
383
|
+
!searchFailed &&
|
|
384
|
+
searchResults.length === 0
|
|
385
|
+
: searchable && searchQuery.length > 0 && localFilteredItems.length === 0
|
|
386
|
+
|
|
387
|
+
// Only render the dropdown when it has something to show. An empty content
|
|
388
|
+
// subtree (query mode opened before the user types, with no default
|
|
389
|
+
// children) makes a Slot inside the underlying Select.Content resolve to
|
|
390
|
+
// nothing, which throws "React.Children.only expected a single child".
|
|
391
|
+
const hasContentToShow =
|
|
392
|
+
isSearching || searchFailed || hasNoResults || itemsToRender.length > 0
|
|
258
393
|
|
|
259
394
|
const searchActionIcon = (() => {
|
|
260
395
|
if (!searchable) return undefined
|
|
@@ -326,37 +461,49 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
|
|
|
326
461
|
)}
|
|
327
462
|
{error && state === "error" && <InputError>{error}</InputError>}
|
|
328
463
|
</StyledRoot>
|
|
329
|
-
{showContent && (
|
|
464
|
+
{showContent && hasContentToShow && (
|
|
330
465
|
<SelectFieldContent>
|
|
331
|
-
{
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
466
|
+
{isSearching ? (
|
|
467
|
+
<StyledStatusRow>
|
|
468
|
+
<Spinner size="sm" />
|
|
469
|
+
</StyledStatusRow>
|
|
470
|
+
) : searchFailed ? (
|
|
471
|
+
<StyledStatusRow>
|
|
472
|
+
<Typography size="sm">{errorText}</Typography>
|
|
473
|
+
</StyledStatusRow>
|
|
474
|
+
) : (
|
|
475
|
+
<>
|
|
476
|
+
{itemsToRender.map((child) => {
|
|
477
|
+
if (
|
|
478
|
+
React.isValidElement(child) &&
|
|
479
|
+
child.type === SelectFieldItem
|
|
480
|
+
) {
|
|
481
|
+
const selectedValue =
|
|
482
|
+
currentValue &&
|
|
483
|
+
typeof currentValue === "object" &&
|
|
484
|
+
"value" in currentValue
|
|
485
|
+
? (currentValue as { value: string }).value
|
|
486
|
+
: currentValue
|
|
487
|
+
|
|
488
|
+
const childProps = child.props as { value: string }
|
|
489
|
+
return React.cloneElement(
|
|
490
|
+
child as React.ReactElement<{
|
|
491
|
+
value: string
|
|
492
|
+
isSelected?: boolean
|
|
493
|
+
}>,
|
|
494
|
+
{
|
|
495
|
+
isSelected: childProps.value === selectedValue
|
|
496
|
+
}
|
|
497
|
+
)
|
|
351
498
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
499
|
+
return child
|
|
500
|
+
})}
|
|
501
|
+
{hasNoResults && (
|
|
502
|
+
<StyledStatusRow>
|
|
503
|
+
<Typography size="sm">{noResultsText}</Typography>
|
|
504
|
+
</StyledStatusRow>
|
|
505
|
+
)}
|
|
506
|
+
</>
|
|
360
507
|
)}
|
|
361
508
|
</SelectFieldContent>
|
|
362
509
|
)}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { SelectField } from "./SelectField"
|
|
2
|
-
export type { SelectFieldProps } from "./SelectField"
|
|
2
|
+
export type { SelectFieldProps, SelectFieldSearchResult } from "./SelectField"
|
|
3
3
|
export { useSelectField } from "./hooks"
|
|
4
4
|
export type { SelectValidationRule } from "./hooks"
|
|
5
5
|
export { SelectFieldTrigger } from "./SelectFieldTrigger"
|
|
@@ -101,7 +101,7 @@ export const Slider = React.forwardRef<View, SliderProps>(
|
|
|
101
101
|
const [internalValue, setInternalValue] = React.useState(defaultValue)
|
|
102
102
|
const currentValue = isControlled ? controlledValue : internalValue
|
|
103
103
|
|
|
104
|
-
const thumbSize = parseTokenValue(buttons.size.md.
|
|
104
|
+
const thumbSize = parseTokenValue(buttons.size.md.minHeight)
|
|
105
105
|
const trackHeight = parseTokenValue(slider.sizing.track.height)
|
|
106
106
|
|
|
107
107
|
const isWeb = Platform.OS === "web"
|