@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.
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +52 -0
- package/COMPONENT_GUIDELINES.md +57 -1
- package/dist/index.cjs +1459 -1380
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +81 -117
- package/dist/index.d.ts +81 -117
- package/dist/index.js +1206 -1125
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__mocks__/react-native.tsx +33 -5
- package/src/__mocks__/rn-primitives/portal.tsx +71 -0
- package/src/components/atoms/Input/InputField.tsx +12 -2
- package/src/components/molecules/Drawer/Drawer.test.tsx +54 -2
- package/src/components/molecules/Drawer/DrawerBody.tsx +2 -17
- package/src/components/molecules/Drawer/DrawerContent.tsx +49 -2
- package/src/components/molecules/Drawer/DrawerOverlay.tsx +28 -16
- package/src/components/molecules/SelectField/AsyncSearch.test.tsx +373 -0
- package/src/components/molecules/SelectField/SelectDrawerField.stories.tsx +192 -0
- package/src/components/molecules/SelectField/SelectDrawerField.test.tsx +210 -0
- package/src/components/molecules/SelectField/SelectDrawerField.tsx +245 -0
- package/src/components/molecules/SelectField/SelectField.stories.tsx +221 -421
- package/src/components/molecules/SelectField/SelectField.test.tsx +214 -662
- package/src/components/molecules/SelectField/SelectField.tsx +269 -539
- package/src/components/molecules/SelectField/SelectFieldDropdown.tsx +90 -0
- package/src/components/molecules/SelectField/SelectFieldList.tsx +88 -0
- package/src/components/molecules/SelectField/SelectFieldOption.tsx +90 -0
- package/src/components/molecules/SelectField/core/index.ts +8 -0
- package/src/components/molecules/SelectField/core/listHeight.test.ts +22 -0
- package/src/components/molecules/SelectField/core/listHeight.ts +8 -0
- package/src/components/molecules/SelectField/core/options.ts +35 -0
- package/src/components/molecules/SelectField/core/useSelectOptions.ts +116 -0
- package/src/components/molecules/SelectField/index.ts +12 -9
- package/src/index.ts +0 -1
- package/src/test-utils.tsx +5 -1
- package/vitest.config.ts +7 -0
- package/src/components/molecules/SelectField/SelectFieldContent.tsx +0 -81
- package/src/components/molecules/SelectField/SelectFieldItem.tsx +0 -133
- package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +0 -188
- package/src/components/molecules/SelectField/SelectFieldValue.tsx +0 -33
- package/src/components/molecules/SelectField/hooks/index.ts +0 -2
- package/src/components/molecules/SelectField/hooks/useSelectField.ts +0 -84
- package/src/hooks/index.ts +0 -1
- package/src/hooks/useDrawerKeyboardAvoidance.ts +0 -29
|
@@ -1,57 +1,38 @@
|
|
|
1
1
|
import React from "react"
|
|
2
|
-
import {
|
|
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
|
|
5
|
-
import
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
76
|
-
Omit<ViewProps, keyof SelectFieldOwnProps
|
|
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
|
-
|
|
126
|
-
|
|
59
|
+
const StyledRoot = styled(View)(({ theme }) => ({
|
|
60
|
+
gap: parseTokenValue(theme.tokens.components.inputs.spacing.gap)
|
|
61
|
+
}))
|
|
127
62
|
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
265
|
-
const
|
|
266
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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 (!
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
194
|
+
const optionsState = useSelectOptions({
|
|
195
|
+
options,
|
|
196
|
+
query: filterQuery,
|
|
197
|
+
onSearchQuery,
|
|
198
|
+
debounceMs,
|
|
199
|
+
minQueryLength,
|
|
200
|
+
enabled: true
|
|
201
|
+
})
|
|
344
202
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 (
|
|
380
|
-
}, 50)
|
|
381
|
-
}
|
|
217
|
+
if (selectionCountRef.current !== selectionsSeen) return
|
|
382
218
|
|
|
383
|
-
|
|
384
|
-
(child) =>
|
|
385
|
-
React.isValidElement(child) && child.type === SelectFieldContent
|
|
386
|
-
)
|
|
219
|
+
setIsFocused(false)
|
|
387
220
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
onValueChange?.(valueToSet)
|
|
229
|
+
const handleChangeText = (text: string) => {
|
|
230
|
+
setInputText(text)
|
|
231
|
+
setFilterQuery(text)
|
|
400
232
|
}
|
|
401
233
|
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
336
|
+
{error && <InputError>{error}</InputError>}
|
|
337
|
+
{dropdown && <Portal name={portalName}>{dropdown}</Portal>}
|
|
338
|
+
</StyledRoot>
|
|
585
339
|
)
|
|
586
340
|
}
|
|
587
341
|
)
|
|
588
342
|
|
|
589
|
-
|
|
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"
|