@butternutbox/pawprint-native 0.21.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.
@@ -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 StyledNoResults = styled(View)(({ theme }) => {
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
- }, [isOpen, searchable])
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
- 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
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 hasNoResults =
257
- searchable && searchQuery.length > 0 && filteredItems.length === 0
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
- {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
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
- return child
355
- })}
356
- {hasNoResults && (
357
- <StyledNoResults>
358
- <Typography size="sm">{noResultsText}</Typography>
359
- </StyledNoResults>
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.height)
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"