@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,57 +1,38 @@
1
1
  import React from "react"
2
- import { View, ViewProps, TextInput, Keyboard, Pressable } from "react-native"
2
+ import {
3
+ Keyboard,
4
+ Pressable,
5
+ TextInput,
6
+ useWindowDimensions,
7
+ View,
8
+ ViewProps
9
+ } from "react-native"
3
10
  import styled from "@emotion/native"
4
- import * as SelectPrimitive from "@rn-primitives/select"
5
- import type { Option } from "@rn-primitives/select"
11
+ import { Portal } from "@rn-primitives/portal"
12
+ import { useTheme } from "@emotion/react"
13
+ import { Cancel } from "@butternutbox/pawprint-icons/core"
14
+ import { Icon } from "../../atoms/Icon"
15
+ import { InputField } from "../../atoms/Input/InputField"
6
16
  import type { InputState } from "../../atoms/Input/InputField"
7
- import { InputText } from "../../atoms/Input/InputField"
8
- import { InputError } from "../../atoms/Input/InputError"
9
17
  import { InputLabel } from "../../atoms/Input/InputLabel"
10
18
  import { InputDescription } from "../../atoms/Input/InputDescription"
11
- import { Typography } from "../../atoms/Typography"
12
- import { Icon } from "../../atoms/Icon"
13
- import { Spinner } from "../../atoms/Spinner"
14
- import { Search, Cancel } from "@butternutbox/pawprint-icons/core"
15
- import { SelectFieldTrigger } from "./SelectFieldTrigger"
16
- import { SelectFieldValue } from "./SelectFieldValue"
17
- import { SelectFieldContent } from "./SelectFieldContent"
18
- import { SelectFieldItem } from "./SelectFieldItem"
19
-
20
- const parseTokenValue = (value: string): number => parseFloat(value)
21
-
22
- const StyledStatusRow = styled(View)(({ theme }) => {
23
- const { dropdown } = theme.tokens.components.dropdownList
24
-
25
- return {
26
- paddingVertical: parseTokenValue(dropdown.listItem.spacing.verticalPadding),
27
- paddingHorizontal: parseTokenValue(
28
- dropdown.listItem.spacing.horiztonalPadding
29
- ),
30
- alignItems: "center"
31
- }
32
- })
33
-
34
- const StyledRoot = styled(View)(({ theme }) => {
35
- const { spacing } = theme.tokens.components.inputs
36
-
37
- return {
38
- gap: parseTokenValue(spacing.gap)
39
- }
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
-
54
- type SelectFieldOwnProps<Value = Option | string> = {
19
+ import { InputError } from "../../atoms/Input/InputError"
20
+ import { parseTokenValue } from "../../../utils"
21
+ import { SelectFieldDropdown } from "./SelectFieldDropdown"
22
+ import type {
23
+ SelectFieldAnchorRect,
24
+ SelectFieldPlacement
25
+ } from "./SelectFieldDropdown"
26
+ import { SelectFieldList } from "./SelectFieldList"
27
+ import { useSelectOptions } from "./core"
28
+ import type { SelectFieldOptionData, SelectOptionsResolver } from "./core"
29
+
30
+ const MIN_DROPDOWN_HEIGHT = 80
31
+
32
+ export type { SelectFieldOptionData }
33
+
34
+ type SelectFieldOwnProps = {
35
+ options: SelectFieldOptionData[]
55
36
  label?: string
56
37
  description?: string
57
38
  error?: string
@@ -59,555 +40,304 @@ type SelectFieldOwnProps<Value = Option | string> = {
59
40
  optionalText?: string
60
41
  placeholder?: string
61
42
  leadingIcon?: React.ReactNode
62
- value?: Value | null
63
- defaultValue?: Value | null
64
- onValueChange?: (value: Value | null) => void
65
- children?: React.ReactNode
66
- disabled?: boolean
67
- searchable?: boolean
68
- searchPlaceholder?: string
69
43
  noResultsText?: string
70
- onSearchQuery?: (query: string) => Promise<SelectFieldSearchResult[]>
71
- debounceMs?: number
72
44
  errorText?: string
45
+ onSearchQuery?: SelectOptionsResolver
46
+ debounceMs?: number
47
+ minQueryLength?: number
48
+ searchHintText?: string
49
+ clearAccessibilityLabel?: string
50
+ disabled?: boolean
51
+ value?: string | null
52
+ defaultValue?: string | null
53
+ onValueChange?: (value: string | null) => void
73
54
  }
74
55
 
75
- export type SelectFieldProps<Value = Option> = SelectFieldOwnProps<Value> &
76
- Omit<ViewProps, keyof SelectFieldOwnProps<Value>>
77
-
78
- type SelectFieldAnchorProps = {
79
- triggerRef: React.RefObject<View | null>
80
- enabled: boolean
81
- }
82
-
83
- type TriggerPosition = NonNullable<
84
- ReturnType<typeof SelectPrimitive.useRootContext>["triggerPosition"]
85
- >
86
-
87
- const isSamePosition = (a: TriggerPosition | null, b: TriggerPosition | null) =>
88
- a !== null &&
89
- b !== null &&
90
- a.width === b.width &&
91
- a.height === b.height &&
92
- a.pageX === b.pageX &&
93
- a.pageY === b.pageY
94
-
95
- function SelectFieldAnchor({ triggerRef, enabled }: SelectFieldAnchorProps) {
96
- const { triggerPosition, setTriggerPosition } =
97
- SelectPrimitive.useRootContext()
98
- const anchoredRef = React.useRef<TriggerPosition | null>(null)
99
-
100
- React.useEffect(() => {
101
- anchoredRef.current = triggerPosition ?? null
102
- }, [triggerPosition])
103
-
104
- React.useEffect(() => {
105
- if (!enabled) return
106
-
107
- let frame: number
108
- let previous: TriggerPosition | null = null
109
-
110
- const track = () => {
111
- triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
112
- const measured = { width, height, pageX, pageY }
113
- const settled = isSamePosition(measured, previous)
114
- if (settled && !isSamePosition(measured, anchoredRef.current)) {
115
- anchoredRef.current = measured
116
- setTriggerPosition(measured)
117
- }
118
- previous = measured
119
- })
120
- frame = requestAnimationFrame(track)
121
- }
122
-
123
- frame = requestAnimationFrame(track)
56
+ export type SelectFieldProps = SelectFieldOwnProps &
57
+ Omit<ViewProps, keyof SelectFieldOwnProps>
124
58
 
125
- return () => cancelAnimationFrame(frame)
126
- }, [enabled, setTriggerPosition, triggerRef])
59
+ const StyledRoot = styled(View)(({ theme }) => ({
60
+ gap: parseTokenValue(theme.tokens.components.inputs.spacing.gap)
61
+ }))
127
62
 
128
- return null
129
- }
63
+ const StyledAnchor = styled(View)({})
130
64
 
131
- /**
132
- * Select field component for choosing a value from a dropdown list.
133
- * Built on @rn-primitives/select with design system styling.
134
- * Supports a simple props API, compound component API, and useSelectField hook for state management.
135
- *
136
- * **Simple Props API:**
137
- * @example
138
- * ```tsx
139
- * <SelectField
140
- * label="Country"
141
- * placeholder="Select a country"
142
- * description="Choose your country"
143
- * >
144
- * <SelectField.Item value="uk">United Kingdom</SelectField.Item>
145
- * <SelectField.Item value="us">United States</SelectField.Item>
146
- * <SelectField.Item value="ca">Canada</SelectField.Item>
147
- * </SelectField>
148
- * ```
149
- *
150
- * **With useSelectField Hook (Recommended):**
151
- * @example
152
- * ```tsx
153
- * const selectProps = useSelectField({
154
- * validationRule: {
155
- * test: (value) => value !== null,
156
- * message: "Please select an option"
157
- * }
158
- * })
159
- *
160
- * <SelectField
161
- * {...selectProps}
162
- * label="Category"
163
- * placeholder="Select a category"
164
- * >
165
- * <SelectField.Item value="food">Food</SelectField.Item>
166
- * <SelectField.Item value="toys">Toys</SelectField.Item>
167
- * </SelectField>
168
- * ```
169
- *
170
- * **Searchable (local filtering):**
171
- * @example
172
- * ```tsx
173
- * <SelectField
174
- * label="Country"
175
- * placeholder="Select a country"
176
- * searchable
177
- * searchPlaceholder="Search countries..."
178
- * noResultsText="No countries found"
179
- * >
180
- * <SelectField.Item value="uk">United Kingdom</SelectField.Item>
181
- * <SelectField.Item value="us">United States</SelectField.Item>
182
- * </SelectField>
183
- * ```
184
- *
185
- * **Searchable (async query):**
186
- * Pass `onSearchQuery` alongside `searchable` to fetch items as the user types.
187
- * The query is debounced, and the component renders a loading spinner while it
188
- * runs, `errorText` if it rejects, and `noResultsText` if it resolves empty.
189
- * Any `children` are shown as the default list before the user types.
190
- * @example
191
- * ```tsx
192
- * <SelectField
193
- * label="Breed"
194
- * placeholder="Select a breed"
195
- * searchable
196
- * searchPlaceholder="Search breeds..."
197
- * onSearchQuery={async (query) => {
198
- * const breeds = await api.searchBreeds(query)
199
- * return breeds.map((breed) => ({ value: breed.id, label: breed.name }))
200
- * }}
201
- * />
202
- * ```
203
- *
204
- * **Compound Component API:**
205
- * @example
206
- * ```tsx
207
- * <SelectField.Root>
208
- * <SelectField.Label optionalText="(optional)">Country</SelectField.Label>
209
- * <SelectField.Trigger>
210
- * <SelectField.Value placeholder="Select a country" />
211
- * </SelectField.Trigger>
212
- * <SelectField.Content>
213
- * <SelectField.Item value="uk">United Kingdom</SelectField.Item>
214
- * <SelectField.Item value="us">United States</SelectField.Item>
215
- * </SelectField.Content>
216
- * <SelectField.Description>Choose your country</SelectField.Description>
217
- * <SelectField.Error>Country is required</SelectField.Error>
218
- * </SelectField.Root>
219
- * ```
220
- *
221
- * @param {string} [label] - Label text (props API)
222
- * @param {string} [description] - Help text below select (props API)
223
- * @param {string} [error] - Error message (props API)
224
- * @param {InputState} [state] - Visual state (default, error, success)
225
- * @param {string} [optionalText] - Optional text to display next to label (props API)
226
- * @param {string} [placeholder] - Placeholder text when no value is selected
227
- * @param {React.ReactNode} [leadingIcon] - Icon to display before the trigger text
228
- * @param {Value | null} [value] - Controlled value
229
- * @param {Value | null} [defaultValue] - Default value for uncontrolled mode
230
- * @param {function} [onValueChange] - Value change handler
231
- * @param {boolean} [disabled] - Disables the select
232
- * @param {boolean} [searchable=false] - Enables inline search filtering of items
233
- * @param {string} [searchPlaceholder="Search..."] - Placeholder text for the search input
234
- * @param {string} [noResultsText="No results found"] - Text shown when search returns no matches
235
- * @param {function} [onSearchQuery] - Async resolver run as the user types; its returned items replace the list. Enables query mode (local filtering is skipped)
236
- * @param {number} [debounceMs=300] - Delay before onSearchQuery fires after the last keystroke
237
- * @param {string} [errorText="Something went wrong"] - Text shown when onSearchQuery rejects
238
- */
239
- const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
65
+ export const SelectField = React.forwardRef<View, SelectFieldProps>(
240
66
  (
241
67
  {
68
+ options,
242
69
  label,
243
- placeholder,
244
70
  description,
245
71
  error,
246
72
  state = "default",
247
73
  optionalText,
74
+ placeholder,
248
75
  leadingIcon,
249
- children,
250
- value,
251
- defaultValue,
252
- onValueChange,
253
- disabled,
254
- searchable = false,
255
- searchPlaceholder,
256
76
  noResultsText = "No results found",
77
+ errorText = "Something went wrong",
257
78
  onSearchQuery,
258
79
  debounceMs = 300,
259
- errorText = "Something went wrong",
80
+ minQueryLength = 1,
81
+ searchHintText = "Type to search",
82
+ clearAccessibilityLabel = "Clear selection",
83
+ disabled = false,
84
+ value,
85
+ defaultValue,
86
+ onValueChange,
260
87
  ...rest
261
88
  },
262
89
  ref
263
90
  ) => {
264
- const [isOpen, setIsOpen] = React.useState(false)
265
- const [showContent, setShowContent] = React.useState(false)
266
- const [internalValue, setInternalValue] = React.useState(
91
+ const theme = useTheme()
92
+ const { height: windowHeight } = useWindowDimensions()
93
+
94
+ const maxDropdownHeight = parseTokenValue(
95
+ theme.tokens.components.dropdownList.dropdown.list.size.maxHeight
96
+ )
97
+
98
+ const isControlled = value !== undefined
99
+ const [internalValue, setInternalValue] = React.useState<string | null>(
267
100
  defaultValue ?? null
268
101
  )
269
- const [searchQuery, setSearchQuery] = React.useState("")
270
- const [clearKey, setClearKey] = React.useState(0)
271
- const searchInputRef = React.useRef<TextInput>(null)
272
- const triggerViewRef = React.useRef<View>(null)
273
-
274
- const isQueryMode = searchable && typeof onSearchQuery === "function"
275
-
276
- const [searchResults, setSearchResults] = React.useState<
277
- SelectFieldSearchResult[]
278
- >([])
279
- const [isSearching, setIsSearching] = React.useState(false)
280
- const [searchFailed, setSearchFailed] = React.useState(false)
281
- const searchRequestIdRef = React.useRef(0)
282
-
283
- // Keep the latest resolver in a ref so an inline `onSearchQuery` (new
284
- // identity every render) does not restart the debounce on unrelated
285
- // re-renders. The effect below keys on the query text, not the function.
286
- const onSearchQueryRef = React.useRef(onSearchQuery)
287
- React.useEffect(() => {
288
- onSearchQueryRef.current = onSearchQuery
102
+ const selectedValue = isControlled ? value : internalValue
103
+
104
+ const [inputText, setInputText] = React.useState(() => {
105
+ const initialValue = isControlled ? value : (defaultValue ?? null)
106
+ return (
107
+ options.find((option) => option.value === initialValue)?.label ?? ""
108
+ )
289
109
  })
110
+ const [filterQuery, setFilterQuery] = React.useState("")
111
+ const [isFocused, setIsFocused] = React.useState(false)
112
+ const [keyboardHeight, setKeyboardHeight] = React.useState(0)
113
+ const [placement, setPlacement] = React.useState<{
114
+ direction: SelectFieldPlacement
115
+ maxHeight: number
116
+ }>({ direction: "below", maxHeight: maxDropdownHeight })
117
+ const [anchorRect, setAnchorRect] =
118
+ React.useState<SelectFieldAnchorRect | null>(null)
119
+ const portalName = React.useId()
120
+
121
+ const inputRef = React.useRef<TextInput>(null)
122
+ const anchorRef = React.useRef<View>(null)
123
+ const selectionCountRef = React.useRef(0)
290
124
 
291
125
  React.useEffect(() => {
292
- if (!isQueryMode) return
293
-
294
- // Empty query: drop any in-flight result and fall back to `children`.
295
- if (searchQuery.length === 0) {
296
- searchRequestIdRef.current += 1
297
- setSearchResults([])
298
- setIsSearching(false)
299
- setSearchFailed(false)
300
- return
126
+ const showSubscription = Keyboard.addListener(
127
+ "keyboardDidShow",
128
+ (event) => setKeyboardHeight(event.endCoordinates.height)
129
+ )
130
+ const hideSubscription = Keyboard.addListener("keyboardDidHide", () =>
131
+ setKeyboardHeight(0)
132
+ )
133
+
134
+ return () => {
135
+ showSubscription.remove()
136
+ hideSubscription.remove()
301
137
  }
138
+ }, [])
139
+
140
+ const measurePlacement = React.useCallback(() => {
141
+ anchorRef.current?.measureInWindow(
142
+ (anchorX, anchorY, anchorWidth, anchorHeight) => {
143
+ setAnchorRect((current) =>
144
+ current &&
145
+ current.x === anchorX &&
146
+ current.y === anchorY &&
147
+ current.width === anchorWidth &&
148
+ current.height === anchorHeight
149
+ ? current
150
+ : {
151
+ x: anchorX,
152
+ y: anchorY,
153
+ width: anchorWidth,
154
+ height: anchorHeight
155
+ }
156
+ )
157
+
158
+ const spaceBelow =
159
+ windowHeight - keyboardHeight - (anchorY + anchorHeight)
160
+ const spaceAbove = anchorY
161
+ const preferAbove =
162
+ spaceBelow < maxDropdownHeight && spaceAbove > spaceBelow
163
+ const availableSpace = preferAbove ? spaceAbove : spaceBelow
164
+
165
+ const direction = preferAbove ? "above" : "below"
166
+ const maxHeight = Math.max(
167
+ MIN_DROPDOWN_HEIGHT,
168
+ Math.min(maxDropdownHeight, availableSpace)
169
+ )
170
+
171
+ setPlacement((current) =>
172
+ current.direction === direction && current.maxHeight === maxHeight
173
+ ? current
174
+ : { direction, maxHeight }
175
+ )
176
+ }
177
+ )
178
+ }, [windowHeight, keyboardHeight, maxDropdownHeight])
302
179
 
303
- const requestId = searchRequestIdRef.current + 1
304
- searchRequestIdRef.current = requestId
305
- setIsSearching(true)
306
- setSearchFailed(false)
307
-
308
- const debounceTimer = setTimeout(() => {
309
- const runQuery = onSearchQueryRef.current
310
- if (!runQuery) return
311
-
312
- Promise.resolve(runQuery(searchQuery))
313
- .then((items) => {
314
- // Ignore a response that a newer keystroke has superseded.
315
- if (searchRequestIdRef.current !== requestId) return
316
- setSearchResults(items)
317
- setIsSearching(false)
318
- })
319
- .catch(() => {
320
- if (searchRequestIdRef.current !== requestId) return
321
- setSearchResults([])
322
- setSearchFailed(true)
323
- setIsSearching(false)
324
- })
325
- }, debounceMs)
326
-
327
- return () => clearTimeout(debounceTimer)
328
- }, [searchQuery, isQueryMode, debounceMs])
180
+ React.useEffect(() => {
181
+ if (isFocused) measurePlacement()
182
+ }, [isFocused, measurePlacement])
329
183
 
330
184
  React.useEffect(() => {
331
- if (!isOpen) {
332
- setShowContent(false)
333
- return
334
- }
335
- if (!searchable) {
336
- setShowContent(true)
185
+ if (!isControlled || isFocused) return
186
+ if (value === null || value === undefined) {
187
+ setInputText("")
337
188
  return
338
189
  }
190
+ const selectedOption = options.find((option) => option.value === value)
191
+ if (selectedOption) setInputText(selectedOption.label)
192
+ }, [isControlled, isFocused, value, options])
339
193
 
340
- const openTimer = setTimeout(() => {
341
- setShowContent(true)
342
- searchInputRef.current?.focus()
343
- }, 0)
194
+ const optionsState = useSelectOptions({
195
+ options,
196
+ query: filterQuery,
197
+ onSearchQuery,
198
+ debounceMs,
199
+ minQueryLength,
200
+ enabled: true
201
+ })
344
202
 
345
- // The Portal cascade (SelectPrimitive.Content onLayout → setContentLayout
346
- // → Root context re-render via Zustand) blurs the input after content
347
- // mounts. Re-focus once the keyboard is fully shown
348
- const keyboardSub = Keyboard.addListener("keyboardDidShow", () => {
349
- setTimeout(() => searchInputRef.current?.focus(), 0)
350
- })
351
-
352
- // Hardware keyboard: keyboardDidShow never fires, so re-focus after a
353
- // short delay to recover from the Portal cascade blur.
354
- const hardwareKeyboardTimer = setTimeout(
355
- () => searchInputRef.current?.focus(),
356
- 100
357
- )
203
+ const commitValue = (nextValue: string | null) => {
204
+ if (!isControlled) setInternalValue(nextValue)
205
+ if (nextValue !== selectedValue) onValueChange?.(nextValue)
206
+ }
358
207
 
359
- return () => {
360
- clearTimeout(openTimer)
361
- clearTimeout(hardwareKeyboardTimer)
362
- keyboardSub.remove()
363
- }
364
- // `searchQuery` and `isSearching` are dependencies so this focus recovery
365
- // re-runs while the user types, not just on open. Each keystroke changes
366
- // the rendered list (local filtering re-filters it; a query mounts it on
367
- // the first keystroke and later swaps in results), and every such change
368
- // re-lays-out the dropdown, which triggers the same Portal-cascade blur.
369
- // Re-running here re-focuses the input so typing is not interrupted.
370
- }, [isOpen, searchable, isSearching, searchQuery])
371
-
372
- const isOpenRef = React.useRef(isOpen)
373
- React.useEffect(() => {
374
- isOpenRef.current = isOpen
375
- })
208
+ const handleFocus = () => {
209
+ setIsFocused(true)
210
+ setFilterQuery("")
211
+ }
212
+
213
+ const handleBlur = () => {
214
+ const selectionsSeen = selectionCountRef.current
376
215
 
377
- const handleSearchBlur = () => {
378
216
  setTimeout(() => {
379
- if (isOpenRef.current) searchInputRef.current?.focus()
380
- }, 50)
381
- }
217
+ if (selectionCountRef.current !== selectionsSeen) return
382
218
 
383
- const isCompound = React.Children.toArray(children).some(
384
- (child) =>
385
- React.isValidElement(child) && child.type === SelectFieldContent
386
- )
219
+ setIsFocused(false)
387
220
 
388
- if (isCompound) {
389
- return (
390
- <StyledRoot ref={ref} {...rest}>
391
- {children}
392
- </StyledRoot>
393
- )
221
+ const typedLabel = inputText.trim().toLowerCase()
222
+ const exactMatch = options.find(
223
+ (option) => option.label.toLowerCase() === typedLabel
224
+ )
225
+ commitValue(exactMatch?.value ?? null)
226
+ }, 0)
394
227
  }
395
228
 
396
- const handleValueChange = (newValue: Option) => {
397
- const valueToSet = newValue ?? null
398
- setInternalValue(valueToSet)
399
- onValueChange?.(valueToSet)
229
+ const handleChangeText = (text: string) => {
230
+ setInputText(text)
231
+ setFilterQuery(text)
400
232
  }
401
233
 
402
- const handleOpenChange = (open: boolean) => {
403
- setIsOpen(open)
404
- if (!open) setSearchQuery("")
234
+ const handleSelect = (option: SelectFieldOptionData) => {
235
+ selectionCountRef.current += 1
236
+ setInputText(option.label)
237
+ setFilterQuery("")
238
+ setIsFocused(false)
239
+ commitValue(option.value)
240
+ Keyboard.dismiss()
405
241
  }
406
242
 
407
- const currentValue = value !== undefined ? value : internalValue
408
-
409
- const allItems = React.Children.toArray(children)
410
-
411
- // Local filter mode: filter the passed children by their label text.
412
- // Skipped in query mode, where the resolver returns an already-filtered set.
413
- const localFilteredItems =
414
- searchable && !isQueryMode
415
- ? allItems.filter((child) => {
416
- if (!searchQuery) return true
417
- if (React.isValidElement(child) && child.type === SelectFieldItem) {
418
- const label = String(
419
- (child.props as { children?: unknown }).children ?? ""
420
- )
421
- return label.toLowerCase().includes(searchQuery.toLowerCase())
422
- }
423
- return true
424
- })
425
- : allItems
426
-
427
- // Query mode, once the user has typed: render the resolver's results.
428
- // Before that, `children` act as the default list (via localFilteredItems).
429
- const isQueryActive = isQueryMode && searchQuery.length > 0
430
-
431
- const queryResultItems = searchResults.map((result) => (
432
- <SelectFieldItem
433
- key={String(result.value)}
434
- value={result.value}
435
- leadingIcon={result.leadingIcon}
436
- hintText={result.hintText}
437
- disabled={result.disabled}
438
- >
439
- {result.label}
440
- </SelectFieldItem>
441
- ))
442
-
443
- const itemsToRender = isQueryActive ? queryResultItems : localFilteredItems
444
-
445
- const hasNoResults = isQueryMode
446
- ? isQueryActive &&
447
- !isSearching &&
448
- !searchFailed &&
449
- searchResults.length === 0
450
- : searchable && searchQuery.length > 0 && localFilteredItems.length === 0
451
-
452
- // Only render the dropdown when it has something to show. An empty content
453
- // subtree (query mode opened before the user types, with no default
454
- // children) makes a Slot inside the underlying Select.Content resolve to
455
- // nothing, which throws "React.Children.only expected a single child".
456
- const hasContentToShow =
457
- isSearching || searchFailed || hasNoResults || itemsToRender.length > 0
458
-
459
- const searchActionIcon = (() => {
460
- if (!searchable) return undefined
461
-
462
- const showClear =
463
- (isOpen && searchQuery.length > 0) || (!isOpen && Boolean(currentValue))
464
- if (!showClear) return <Icon icon={Search} size="md" />
243
+ const handleClear = () => {
244
+ setInputText("")
245
+ setFilterQuery("")
246
+ commitValue(null)
247
+ inputRef.current?.focus()
248
+ }
465
249
 
466
- return (
250
+ const fieldState: InputState = error ? "error" : state
251
+ const isOpen = isFocused && !disabled
252
+ const clearAction =
253
+ !disabled && inputText.length > 0 ? (
467
254
  <Pressable
468
- onPress={
469
- isOpen
470
- ? () => {
471
- setSearchQuery("")
472
- searchInputRef.current?.focus()
473
- }
474
- : () => {
475
- setInternalValue(null)
476
- onValueChange?.(null)
477
- setClearKey((k) => k + 1)
478
- }
479
- }
480
- accessibilityLabel={isOpen ? "Clear search" : "Clear selection"}
255
+ onPress={handleClear}
481
256
  accessibilityRole="button"
257
+ accessibilityLabel={clearAccessibilityLabel}
482
258
  hitSlop={8}
483
259
  >
484
260
  <Icon icon={Cancel} size="md" />
485
261
  </Pressable>
262
+ ) : undefined
263
+
264
+ const handleSelectRef = React.useRef(handleSelect)
265
+ handleSelectRef.current = handleSelect
266
+ const onOptionPress = React.useCallback(
267
+ (option: SelectFieldOptionData) => handleSelectRef.current(option),
268
+ []
269
+ )
270
+
271
+ const dropdown = React.useMemo(() => {
272
+ if (!isOpen || !anchorRect) return null
273
+
274
+ return (
275
+ <SelectFieldDropdown
276
+ anchor={anchorRect}
277
+ placement={placement.direction}
278
+ maxHeight={placement.maxHeight}
279
+ screenHeight={windowHeight}
280
+ accessibilityLiveRegion="polite"
281
+ >
282
+ <SelectFieldList
283
+ state={optionsState}
284
+ selectedValue={selectedValue}
285
+ onSelect={onOptionPress}
286
+ noResultsText={noResultsText}
287
+ errorText={errorText}
288
+ searchHintText={searchHintText}
289
+ />
290
+ </SelectFieldDropdown>
486
291
  )
487
- })()
292
+ }, [
293
+ isOpen,
294
+ anchorRect,
295
+ placement,
296
+ windowHeight,
297
+ optionsState,
298
+ noResultsText,
299
+ errorText,
300
+ searchHintText,
301
+ selectedValue,
302
+ onOptionPress
303
+ ])
488
304
 
489
305
  return (
490
- <SelectPrimitive.Root
491
- key={clearKey}
492
- value={currentValue ?? undefined}
493
- onValueChange={handleValueChange}
494
- disabled={disabled}
495
- onOpenChange={handleOpenChange}
496
- >
497
- <StyledRoot ref={ref} {...rest}>
498
- {label && (
499
- <InputLabel optionalText={optionalText} state={state}>
500
- {label}
501
- </InputLabel>
502
- )}
503
- <SelectFieldTrigger
504
- ref={triggerViewRef}
505
- state={state}
306
+ <StyledRoot ref={ref} {...rest}>
307
+ {label && (
308
+ <InputLabel optionalText={optionalText} state={fieldState}>
309
+ {label}
310
+ </InputLabel>
311
+ )}
312
+ <StyledAnchor ref={anchorRef}>
313
+ <InputField
314
+ ref={inputRef}
315
+ value={inputText}
316
+ onChangeText={handleChangeText}
317
+ onFocus={handleFocus}
318
+ onBlur={handleBlur}
319
+ placeholder={placeholder}
320
+ editable={!disabled}
321
+ state={fieldState}
322
+ hideStateIcons={Boolean(clearAction)}
506
323
  leadingIcon={leadingIcon}
507
- isOpen={isOpen}
508
- isSearching={searchable && isOpen}
509
- actionIcon={searchActionIcon}
510
- >
511
- {searchable && isOpen ? (
512
- <InputText
513
- ref={searchInputRef}
514
- value={searchQuery}
515
- onChangeText={setSearchQuery}
516
- onBlur={handleSearchBlur}
517
- placeholder={searchPlaceholder ?? "Search..."}
518
- returnKeyType="search"
519
- clearButtonMode="never"
520
- accessibilityLabel="Search options"
521
- />
522
- ) : (
523
- <SelectFieldValue
524
- placeholder={placeholder}
525
- hasValue={Boolean(currentValue)}
526
- />
527
- )}
528
- </SelectFieldTrigger>
529
- {description && (
530
- <InputDescription state={state}>{description}</InputDescription>
531
- )}
532
- {error && state === "error" && <InputError>{error}</InputError>}
533
- </StyledRoot>
534
- <SelectFieldAnchor
535
- triggerRef={triggerViewRef}
536
- enabled={searchable && isOpen}
537
- />
538
- {showContent && hasContentToShow && (
539
- <SelectFieldContent>
540
- {isSearching ? (
541
- <StyledStatusRow>
542
- <Spinner size="sm" />
543
- </StyledStatusRow>
544
- ) : searchFailed ? (
545
- <StyledStatusRow>
546
- <Typography size="sm">{errorText}</Typography>
547
- </StyledStatusRow>
548
- ) : (
549
- <>
550
- {itemsToRender.map((child) => {
551
- if (
552
- React.isValidElement(child) &&
553
- child.type === SelectFieldItem
554
- ) {
555
- const selectedValue =
556
- currentValue &&
557
- typeof currentValue === "object" &&
558
- "value" in currentValue
559
- ? (currentValue as { value: string }).value
560
- : currentValue
561
-
562
- const childProps = child.props as { value: string }
563
- return React.cloneElement(
564
- child as React.ReactElement<{
565
- value: string
566
- isSelected?: boolean
567
- }>,
568
- {
569
- isSelected: childProps.value === selectedValue
570
- }
571
- )
572
- }
573
- return child
574
- })}
575
- {hasNoResults && (
576
- <StyledStatusRow>
577
- <Typography size="sm">{noResultsText}</Typography>
578
- </StyledStatusRow>
579
- )}
580
- </>
581
- )}
582
- </SelectFieldContent>
324
+ actionIcon={clearAction}
325
+ selectTextOnFocus
326
+ autoCorrect={false}
327
+ autoCapitalize="none"
328
+ returnKeyType="done"
329
+ accessibilityRole="combobox"
330
+ accessibilityState={{ disabled, expanded: isOpen }}
331
+ />
332
+ </StyledAnchor>
333
+ {description && (
334
+ <InputDescription state={fieldState}>{description}</InputDescription>
583
335
  )}
584
- </SelectPrimitive.Root>
336
+ {error && <InputError>{error}</InputError>}
337
+ {dropdown && <Portal name={portalName}>{dropdown}</Portal>}
338
+ </StyledRoot>
585
339
  )
586
340
  }
587
341
  )
588
342
 
589
- SelectFieldRoot.displayName = "SelectField"
590
-
591
- type SelectFieldComponent = React.ForwardRefExoticComponent<
592
- SelectFieldProps<Option | string> & React.RefAttributes<View>
593
- > & {
594
- Root: typeof StyledRoot
595
- Label: typeof InputLabel
596
- Trigger: typeof SelectFieldTrigger
597
- Value: typeof SelectFieldValue
598
- Content: typeof SelectFieldContent
599
- Item: typeof SelectFieldItem
600
- Description: typeof InputDescription
601
- Error: typeof InputError
602
- }
603
-
604
- export const SelectField = Object.assign(SelectFieldRoot, {
605
- Root: StyledRoot,
606
- Label: InputLabel,
607
- Trigger: SelectFieldTrigger,
608
- Value: SelectFieldValue,
609
- Content: SelectFieldContent,
610
- Item: SelectFieldItem,
611
- Description: InputDescription,
612
- Error: InputError
613
- }) as SelectFieldComponent
343
+ SelectField.displayName = "SelectField"