@utilitywarehouse/hearth-react-native 0.27.2 → 0.28.0-testid-fix-1

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 (155) hide show
  1. package/.turbo/turbo-build.log +5 -4
  2. package/.turbo/turbo-lint.log +70 -69
  3. package/CHANGELOG.md +149 -0
  4. package/build/components/Button/ButtonRoot.js +8 -0
  5. package/build/components/Combobox/Combobox.context.d.ts +13 -0
  6. package/build/components/Combobox/Combobox.context.js +9 -0
  7. package/build/components/Combobox/Combobox.d.ts +6 -0
  8. package/build/components/Combobox/Combobox.js +246 -0
  9. package/build/components/Combobox/Combobox.props.d.ts +180 -0
  10. package/build/components/Combobox/Combobox.props.js +1 -0
  11. package/build/components/Combobox/ComboboxOption.d.ts +6 -0
  12. package/build/components/Combobox/ComboboxOption.js +56 -0
  13. package/build/components/Combobox/index.d.ts +4 -0
  14. package/build/components/Combobox/index.js +3 -0
  15. package/build/components/DatePicker/TimePicker.d.ts +3 -0
  16. package/build/components/DatePicker/TimePicker.js +84 -0
  17. package/build/components/DatePicker/time-picker/animated-math.d.ts +4 -0
  18. package/build/components/DatePicker/time-picker/animated-math.js +19 -0
  19. package/build/components/DatePicker/time-picker/period-native.d.ts +6 -0
  20. package/build/components/DatePicker/time-picker/period-native.js +17 -0
  21. package/build/components/DatePicker/time-picker/period-picker.d.ts +6 -0
  22. package/build/components/DatePicker/time-picker/period-picker.js +10 -0
  23. package/build/components/DatePicker/time-picker/period-web.d.ts +6 -0
  24. package/build/components/DatePicker/time-picker/period-web.js +21 -0
  25. package/build/components/DatePicker/time-picker/wheel-native.d.ts +8 -0
  26. package/build/components/DatePicker/time-picker/wheel-native.js +19 -0
  27. package/build/components/DatePicker/time-picker/wheel-picker/index.d.ts +2 -0
  28. package/build/components/DatePicker/time-picker/wheel-picker/index.js +2 -0
  29. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker-item.d.ts +16 -0
  30. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker-item.js +97 -0
  31. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.d.ts +21 -0
  32. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.js +88 -0
  33. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.style.d.ts +23 -0
  34. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.style.js +21 -0
  35. package/build/components/DatePicker/time-picker/wheel-web.d.ts +8 -0
  36. package/build/components/DatePicker/time-picker/wheel-web.js +146 -0
  37. package/build/components/DatePicker/time-picker/wheel.d.ts +8 -0
  38. package/build/components/DatePicker/time-picker/wheel.js +10 -0
  39. package/build/components/List/List.js +2 -2
  40. package/build/components/Modal/Modal.js +31 -42
  41. package/build/components/Modal/Modal.web.js +3 -3
  42. package/build/components/Pagination/Pagination.d.ts +6 -0
  43. package/build/components/Pagination/Pagination.js +125 -0
  44. package/build/components/Pagination/Pagination.props.d.ts +26 -0
  45. package/build/components/Pagination/Pagination.props.js +1 -0
  46. package/build/components/Pagination/Pagination.utils.d.ts +2 -0
  47. package/build/components/Pagination/Pagination.utils.js +20 -0
  48. package/build/components/Pagination/Pagination.utils.test.d.ts +1 -0
  49. package/build/components/Pagination/Pagination.utils.test.js +16 -0
  50. package/build/components/Pagination/index.d.ts +2 -0
  51. package/build/components/Pagination/index.js +1 -0
  52. package/build/components/SafeAreaView/SafeAreaView.d.ts +5 -0
  53. package/build/components/SafeAreaView/SafeAreaView.js +117 -0
  54. package/build/components/SafeAreaView/SafeAreaView.props.d.ts +17 -0
  55. package/build/components/SafeAreaView/SafeAreaView.props.js +1 -0
  56. package/build/components/SafeAreaView/index.d.ts +2 -0
  57. package/build/components/SafeAreaView/index.js +1 -0
  58. package/build/components/Select/Select.d.ts +1 -1
  59. package/build/components/Select/Select.js +6 -5
  60. package/build/components/Select/Select.props.d.ts +4 -0
  61. package/build/components/Select/SelectOption.d.ts +1 -1
  62. package/build/components/Select/SelectOption.js +2 -2
  63. package/build/components/Table/Table.context.d.ts +12 -0
  64. package/build/components/Table/Table.context.js +9 -0
  65. package/build/components/Table/Table.d.ts +6 -0
  66. package/build/components/Table/Table.js +71 -0
  67. package/build/components/Table/Table.props.d.ts +56 -0
  68. package/build/components/Table/Table.props.js +1 -0
  69. package/build/components/Table/Table.utils.d.ts +5 -0
  70. package/build/components/Table/Table.utils.js +48 -0
  71. package/build/components/Table/Table.utils.test.d.ts +1 -0
  72. package/build/components/Table/Table.utils.test.js +71 -0
  73. package/build/components/Table/TableBody.d.ts +6 -0
  74. package/build/components/Table/TableBody.js +16 -0
  75. package/build/components/Table/TableCell.d.ts +10 -0
  76. package/build/components/Table/TableCell.js +44 -0
  77. package/build/components/Table/TableHeader.d.ts +6 -0
  78. package/build/components/Table/TableHeader.js +24 -0
  79. package/build/components/Table/TableHeaderCell.d.ts +10 -0
  80. package/build/components/Table/TableHeaderCell.js +97 -0
  81. package/build/components/Table/TablePagination.d.ts +6 -0
  82. package/build/components/Table/TablePagination.js +7 -0
  83. package/build/components/Table/TableRow.d.ts +8 -0
  84. package/build/components/Table/TableRow.js +25 -0
  85. package/build/components/Table/index.d.ts +8 -0
  86. package/build/components/Table/index.js +7 -0
  87. package/build/components/Timeline/Timeline.d.ts +6 -0
  88. package/build/components/Timeline/Timeline.js +34 -0
  89. package/build/components/Timeline/Timeline.props.d.ts +47 -0
  90. package/build/components/Timeline/Timeline.props.js +1 -0
  91. package/build/components/Timeline/TimelineItem.d.ts +6 -0
  92. package/build/components/Timeline/TimelineItem.js +235 -0
  93. package/build/components/Timeline/index.d.ts +3 -0
  94. package/build/components/Timeline/index.js +2 -0
  95. package/build/components/VerificationInput/VerificationInput.js +3 -3
  96. package/build/components/index.d.ts +5 -0
  97. package/build/components/index.js +5 -0
  98. package/build/tokens/components/dark/timeline.d.ts +2 -2
  99. package/build/tokens/components/dark/timeline.js +2 -2
  100. package/docs/components/AllComponents.web.tsx +106 -23
  101. package/docs/llm-docs/unistyles-llms-full.txt +1132 -534
  102. package/docs/llm-docs/unistyles-llms-small.txt +37 -37
  103. package/package.json +4 -4
  104. package/src/components/Button/Button.stories.tsx +43 -7
  105. package/src/components/Button/ButtonRoot.tsx +8 -0
  106. package/src/components/Combobox/Combobox.context.ts +26 -0
  107. package/src/components/Combobox/Combobox.docs.mdx +277 -0
  108. package/src/components/Combobox/Combobox.figma.tsx +60 -0
  109. package/src/components/Combobox/Combobox.props.ts +187 -0
  110. package/src/components/Combobox/Combobox.stories.tsx +233 -0
  111. package/src/components/Combobox/Combobox.tsx +446 -0
  112. package/src/components/Combobox/ComboboxOption.tsx +100 -0
  113. package/src/components/Combobox/index.ts +9 -0
  114. package/src/components/List/List.tsx +5 -4
  115. package/src/components/Modal/Modal.tsx +67 -74
  116. package/src/components/Modal/Modal.web.tsx +3 -3
  117. package/src/components/Pagination/Pagination.docs.mdx +99 -0
  118. package/src/components/Pagination/Pagination.figma.tsx +20 -0
  119. package/src/components/Pagination/Pagination.props.ts +28 -0
  120. package/src/components/Pagination/Pagination.stories.tsx +88 -0
  121. package/src/components/Pagination/Pagination.tsx +248 -0
  122. package/src/components/Pagination/Pagination.utils.test.ts +20 -0
  123. package/src/components/Pagination/Pagination.utils.ts +37 -0
  124. package/src/components/Pagination/index.ts +2 -0
  125. package/src/components/SafeAreaView/SafeAreaView.props.ts +20 -0
  126. package/src/components/SafeAreaView/SafeAreaView.tsx +173 -0
  127. package/src/components/SafeAreaView/index.ts +2 -0
  128. package/src/components/Select/Select.props.ts +4 -0
  129. package/src/components/Select/Select.tsx +35 -28
  130. package/src/components/Select/SelectOption.tsx +2 -0
  131. package/src/components/Table/Table.context.tsx +23 -0
  132. package/src/components/Table/Table.docs.mdx +239 -0
  133. package/src/components/Table/Table.figma.tsx +65 -0
  134. package/src/components/Table/Table.props.ts +65 -0
  135. package/src/components/Table/Table.stories.tsx +399 -0
  136. package/src/components/Table/Table.tsx +127 -0
  137. package/src/components/Table/Table.utils.test.ts +82 -0
  138. package/src/components/Table/Table.utils.ts +72 -0
  139. package/src/components/Table/TableBody.tsx +25 -0
  140. package/src/components/Table/TableCell.tsx +67 -0
  141. package/src/components/Table/TableHeader.tsx +41 -0
  142. package/src/components/Table/TableHeaderCell.tsx +136 -0
  143. package/src/components/Table/TablePagination.tsx +10 -0
  144. package/src/components/Table/TableRow.tsx +42 -0
  145. package/src/components/Table/index.ts +16 -0
  146. package/src/components/Timeline/Timeline.docs.mdx +177 -0
  147. package/src/components/Timeline/Timeline.figma.tsx +89 -0
  148. package/src/components/Timeline/Timeline.props.ts +51 -0
  149. package/src/components/Timeline/Timeline.stories.tsx +102 -0
  150. package/src/components/Timeline/Timeline.tsx +48 -0
  151. package/src/components/Timeline/TimelineItem.tsx +293 -0
  152. package/src/components/Timeline/index.ts +9 -0
  153. package/src/components/VerificationInput/VerificationInput.tsx +3 -0
  154. package/src/components/index.ts +5 -0
  155. package/src/tokens/components/dark/timeline.ts +2 -2
@@ -0,0 +1,233 @@
1
+ import type { Meta } from '@storybook/react-vite';
2
+ import { useEffect, useState } from 'react';
3
+ import { BottomSheetFlatList } from '../BottomSheet';
4
+ import { Box } from '../Box';
5
+ import { ComboboxOptionItemProps } from './Combobox.props';
6
+ import Combobox from './Combobox';
7
+ import ComboboxOption from './ComboboxOption';
8
+
9
+ const countries = [
10
+ { label: 'United Kingdom', value: 'uk', keywords: ['britain', 'england'] },
11
+ { label: 'United States', value: 'us', keywords: ['america', 'usa'] },
12
+ { label: 'Canada', value: 'ca' },
13
+ { label: 'Australia', value: 'au' },
14
+ { label: 'France', value: 'fr' },
15
+ { label: 'Germany', value: 'de' },
16
+ { label: 'Japan', value: 'jp' },
17
+ { label: 'Brazil', value: 'br' },
18
+ { label: 'India', value: 'in' },
19
+ { label: 'South Africa', value: 'za' },
20
+ ] satisfies ComboboxOptionItemProps[];
21
+
22
+ const products = [
23
+ 'Broadband',
24
+ 'Energy',
25
+ 'Mobile',
26
+ 'Insurance',
27
+ 'Cashback Card',
28
+ 'Home Phone',
29
+ 'TV',
30
+ 'Boiler Cover',
31
+ 'Life Insurance',
32
+ 'Smart Home',
33
+ ].map((label, index) => ({
34
+ label,
35
+ value: `product-${index + 1}`,
36
+ })) satisfies ComboboxOptionItemProps[];
37
+
38
+ const cities = Array.from({ length: 150 }, (_, index) => ({
39
+ label: `City ${String(index + 1).padStart(3, '0')}`,
40
+ value: `city-${index + 1}`,
41
+ }));
42
+
43
+ const meta = {
44
+ title: 'Stories / Combobox',
45
+ component: Combobox,
46
+ parameters: {
47
+ status: {
48
+ type: 'stable',
49
+ },
50
+ },
51
+ argTypes: {
52
+ label: {
53
+ control: 'text',
54
+ description: 'Label for the combobox',
55
+ defaultValue: 'Combobox',
56
+ },
57
+ placeholder: {
58
+ control: 'text',
59
+ description: 'Trigger placeholder text',
60
+ defaultValue: 'Search for a country',
61
+ },
62
+ searchPlaceholder: {
63
+ control: 'text',
64
+ description: 'Bottom sheet input placeholder text',
65
+ defaultValue: 'Search for a country',
66
+ },
67
+ disabled: {
68
+ control: 'boolean',
69
+ description: 'Whether the combobox is disabled',
70
+ defaultValue: false,
71
+ },
72
+ readonly: {
73
+ control: 'boolean',
74
+ description: 'Whether the combobox is readonly',
75
+ defaultValue: false,
76
+ },
77
+ loading: {
78
+ control: 'boolean',
79
+ description: 'Whether to show a loading spinner',
80
+ defaultValue: false,
81
+ },
82
+ validationStatus: {
83
+ control: 'select',
84
+ options: ['initial', 'valid', 'invalid'],
85
+ description: 'Validation status',
86
+ defaultValue: 'initial',
87
+ },
88
+ noOptionsFoundText: {
89
+ control: 'text',
90
+ description: 'Empty state text',
91
+ defaultValue: 'No options found',
92
+ },
93
+ },
94
+ args: {
95
+ label: 'Combobox',
96
+ helperText: 'Helper text',
97
+ placeholder: 'Search for a country',
98
+ searchPlaceholder: 'Search for a country',
99
+ disabled: false,
100
+ readonly: false,
101
+ loading: false,
102
+ validationStatus: 'initial',
103
+ noOptionsFoundText: 'No options found',
104
+ options: countries,
105
+ },
106
+ } satisfies Meta<typeof Combobox>;
107
+
108
+ export default meta;
109
+
110
+ export const Playground = ({ ...args }) => {
111
+ const [value, setValue] = useState<string | null>('uk');
112
+
113
+ return <Combobox value={value} onValueChange={setValue} {...args} />;
114
+ };
115
+
116
+ export const StaticSearchableList = () => {
117
+ const [value, setValue] = useState<string | null>(null);
118
+
119
+ return (
120
+ <Combobox
121
+ label="Country"
122
+ helperText="Search a fixed list of countries"
123
+ placeholder="Search for a country"
124
+ searchPlaceholder="Search for a country"
125
+ menuHeading="Choose a country"
126
+ options={countries}
127
+ value={value}
128
+ onValueChange={setValue}
129
+ />
130
+ );
131
+ };
132
+
133
+ export const DynamicItems = () => {
134
+ const [value, setValue] = useState<string | null>(null);
135
+ const [inputValue, setInputValue] = useState('');
136
+ const [loading, setLoading] = useState(false);
137
+ const [options, setOptions] = useState<ComboboxOptionItemProps[]>(products);
138
+
139
+ useEffect(() => {
140
+ setLoading(true);
141
+
142
+ const timeout = setTimeout(() => {
143
+ const normalizedQuery = inputValue.trim().toLowerCase();
144
+ const nextOptions = products.filter(option => {
145
+ if (!normalizedQuery) {
146
+ return true;
147
+ }
148
+
149
+ return option.label.toLowerCase().includes(normalizedQuery);
150
+ });
151
+
152
+ setOptions(nextOptions);
153
+ setLoading(false);
154
+ }, 350);
155
+
156
+ return () => clearTimeout(timeout);
157
+ }, [inputValue]);
158
+
159
+ return (
160
+ <Combobox
161
+ label="Products"
162
+ helperText="Search results are updated asynchronously"
163
+ menuHeading="Search products"
164
+ placeholder="Search products"
165
+ searchPlaceholder="Search products"
166
+ inputValue={inputValue}
167
+ onInputValueChange={setInputValue}
168
+ loading={loading}
169
+ options={options}
170
+ value={value}
171
+ onValueChange={setValue}
172
+ getValueLabel={selectedValue =>
173
+ products.find(option => option.value === selectedValue)?.label ?? ''
174
+ }
175
+ noOptionsFoundText="No products match your search"
176
+ />
177
+ );
178
+ };
179
+
180
+ export const CustomFlatList = () => {
181
+ const [value, setValue] = useState<string | null>(null);
182
+
183
+ return (
184
+ <Combobox
185
+ label="Cities"
186
+ helperText="Custom bottom sheet content using BottomSheetFlatList"
187
+ placeholder="Search a city"
188
+ searchPlaceholder="Search a city"
189
+ menuHeading="Popular cities"
190
+ value={value}
191
+ onValueChange={setValue}
192
+ getValueLabel={selectedValue =>
193
+ cities.find(option => option.value === selectedValue)?.label ?? ''
194
+ }
195
+ >
196
+ {({ search }) => {
197
+ const normalizedQuery = search.trim().toLowerCase();
198
+ const filteredCities = normalizedQuery
199
+ ? cities.filter(option => option.label.toLowerCase().includes(normalizedQuery))
200
+ : cities;
201
+
202
+ return (
203
+ <BottomSheetFlatList
204
+ data={filteredCities}
205
+ keyExtractor={item => item.value}
206
+ renderItem={({ item }) => <ComboboxOption label={item.label} value={item.value} />}
207
+ />
208
+ );
209
+ }}
210
+ </Combobox>
211
+ );
212
+ };
213
+
214
+ export const InlineOptions = () => {
215
+ const [value, setValue] = useState<string | null>(null);
216
+
217
+ return (
218
+ <Combobox
219
+ label="Inline options"
220
+ helperText="Compose your own sheet content using ComboboxOption"
221
+ placeholder="Search a country"
222
+ searchPlaceholder="Search a country"
223
+ value={value}
224
+ onValueChange={setValue}
225
+ >
226
+ <Box>
227
+ {countries.map(option => (
228
+ <ComboboxOption key={option.value} label={option.label} value={option.value} />
229
+ ))}
230
+ </Box>
231
+ </Combobox>
232
+ );
233
+ };
@@ -0,0 +1,446 @@
1
+ import { CloseSmallIcon, SearchMediumIcon } from '@utilitywarehouse/hearth-react-native-icons';
2
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { GestureResponderEvent, Pressable, TextInput, View } from 'react-native';
4
+ import { StyleSheet } from 'react-native-unistyles';
5
+ import { useTheme } from '../../hooks';
6
+ import { BodyText } from '../BodyText';
7
+ import { BottomSheetFlatList, BottomSheetModal, BottomSheetView } from '../BottomSheet';
8
+ import { DetailText } from '../DetailText';
9
+ import { FormField, useFormFieldContext } from '../FormField';
10
+ import { Icon } from '../Icon';
11
+ import { Input } from '../Input';
12
+ import { Spinner } from '../Spinner';
13
+ import { UnstyledIconButton } from '../UnstyledIconButton';
14
+ import { ComboboxContext } from './Combobox.context';
15
+ import { ComboboxSelection } from './Combobox.context';
16
+ import ComboboxProps, {
17
+ ComboboxOptionItemProps,
18
+ ComboboxRenderContentProps,
19
+ } from './Combobox.props';
20
+ import { SafeAreaView } from '../SafeAreaView';
21
+ import ComboboxOption from './ComboboxOption';
22
+
23
+ const DEFAULT_SNAP_POINTS = ['25%', '40%', '80%'];
24
+
25
+ const Combobox = ({
26
+ options = [],
27
+ value,
28
+ onValueChange,
29
+ inputValue,
30
+ onInputValueChange,
31
+ label,
32
+ labelVariant = 'body',
33
+ placeholder = 'Search',
34
+ searchPlaceholder = 'Search',
35
+ disabled = false,
36
+ validationStatus = 'initial',
37
+ helperText,
38
+ helperIcon,
39
+ invalidText,
40
+ validText,
41
+ required = true,
42
+ children,
43
+ bottomSheetProps,
44
+ menuHeading,
45
+ noOptionsFoundText = 'No options found',
46
+ listProps,
47
+ loading = false,
48
+ readonly = false,
49
+ getValueLabel,
50
+ filterOption,
51
+ ...rest
52
+ }: ComboboxProps) => {
53
+ const formFieldContext = useFormFieldContext();
54
+ const validationStatusFromContext = formFieldContext?.validationStatus ?? validationStatus;
55
+ const isRequired = formFieldContext?.required ?? required;
56
+ const isDisabled = formFieldContext?.disabled ?? disabled;
57
+ const isReadonly = formFieldContext?.readonly ?? readonly;
58
+ const { color } = useTheme();
59
+
60
+ const bottomSheetModalRef = useRef<BottomSheetModal>(null);
61
+ const searchInputRef = useRef<TextInput>(null);
62
+ const focusTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
63
+ const [isOpen, setIsOpen] = useState(false);
64
+ const [selectedChildLabel, setSelectedChildLabel] = useState<string | undefined>(undefined);
65
+ const [uncontrolledInputValue, setUncontrolledInputValue] = useState('');
66
+ const isInputControlled = inputValue !== undefined;
67
+
68
+ const selectedOption = options.find(option => option.value === value);
69
+
70
+ useEffect(() => {
71
+ if (typeof children === 'function' || !children || selectedOption || !value || getValueLabel) {
72
+ setSelectedChildLabel(undefined);
73
+ return;
74
+ }
75
+
76
+ let nextLabel: string | undefined;
77
+
78
+ React.Children.forEach(children, child => {
79
+ if (!React.isValidElement(child) || nextLabel) {
80
+ return;
81
+ }
82
+
83
+ if ((child.props as any).value === value) {
84
+ nextLabel = (child.props as any).label;
85
+ }
86
+ });
87
+
88
+ setSelectedChildLabel(nextLabel);
89
+ }, [children, getValueLabel, selectedOption, value]);
90
+
91
+ const selectedLabel = useMemo(() => {
92
+ return getValueLabel?.(value) || selectedOption?.label || selectedChildLabel || '';
93
+ }, [getValueLabel, selectedChildLabel, selectedOption?.label, value]);
94
+
95
+ useEffect(() => {
96
+ if (isInputControlled) {
97
+ return;
98
+ }
99
+
100
+ setUncontrolledInputValue(selectedLabel);
101
+ }, [isInputControlled, selectedLabel]);
102
+
103
+ useEffect(() => {
104
+ return () => {
105
+ if (focusTimeoutRef.current) {
106
+ clearTimeout(focusTimeoutRef.current);
107
+ }
108
+ };
109
+ }, []);
110
+
111
+ const search = isInputControlled ? (inputValue ?? '') : uncontrolledInputValue;
112
+
113
+ const accessibilityLabel = useMemo(() => {
114
+ if (!label) {
115
+ return undefined;
116
+ }
117
+
118
+ return isRequired ? `${label}, required` : label;
119
+ }, [isRequired, label]);
120
+
121
+ const accessibilityHint = useMemo(() => {
122
+ const hints: string[] = [];
123
+
124
+ if (helperText) {
125
+ hints.push(helperText);
126
+ }
127
+
128
+ if (validationStatusFromContext === 'invalid' && invalidText) {
129
+ hints.push(invalidText);
130
+ }
131
+
132
+ if (validationStatusFromContext === 'valid' && validText) {
133
+ hints.push(validText);
134
+ }
135
+
136
+ return hints.length > 0 ? hints.join(', ') : undefined;
137
+ }, [helperText, invalidText, validText, validationStatusFromContext]);
138
+
139
+ styles.useVariants({
140
+ disabled: isDisabled,
141
+ hasValue: search.length > 0,
142
+ readonly: isReadonly,
143
+ validationStatus: validationStatusFromContext,
144
+ });
145
+
146
+ const setSearch = useCallback(
147
+ (nextValue: string) => {
148
+ if (!isInputControlled) {
149
+ setUncontrolledInputValue(nextValue);
150
+ }
151
+
152
+ onInputValueChange?.(nextValue);
153
+ },
154
+ [isInputControlled, onInputValueChange]
155
+ );
156
+
157
+ const handleClose = useCallback(
158
+ (index: number) => {
159
+ if (index === -1) {
160
+ setIsOpen(false);
161
+ setSearch('');
162
+ }
163
+ },
164
+ [setSearch]
165
+ );
166
+
167
+ const focusSearchInput = useCallback(() => {
168
+ if (focusTimeoutRef.current) {
169
+ clearTimeout(focusTimeoutRef.current);
170
+ }
171
+
172
+ focusTimeoutRef.current = setTimeout(() => {
173
+ searchInputRef.current?.focus();
174
+ }, 300);
175
+ }, []);
176
+
177
+ const openBottomSheet = useCallback(() => {
178
+ if (isDisabled || isReadonly) {
179
+ return;
180
+ }
181
+
182
+ bottomSheetModalRef.current?.present();
183
+ setIsOpen(true);
184
+ focusSearchInput();
185
+ }, [focusSearchInput, isDisabled, isReadonly]);
186
+
187
+ const closeBottomSheet = useCallback(() => {
188
+ bottomSheetModalRef.current?.dismiss();
189
+ setIsOpen(false);
190
+ }, []);
191
+
192
+ const selectOption = useCallback(
193
+ ({ label: optionLabel, value: optionValue }: ComboboxSelection) => {
194
+ setSearch(optionLabel);
195
+ onValueChange?.(optionValue);
196
+ closeBottomSheet();
197
+ },
198
+ [closeBottomSheet, onValueChange, setSearch]
199
+ );
200
+
201
+ const handleClear = useCallback(() => {
202
+ setSearch('');
203
+ onValueChange?.(null);
204
+ }, [onValueChange, setSearch]);
205
+
206
+ const handleClearPress = useCallback(
207
+ (event: GestureResponderEvent) => {
208
+ event.stopPropagation();
209
+ handleClear();
210
+ },
211
+ [handleClear]
212
+ );
213
+
214
+ const defaultFilterOption = useCallback((option: ComboboxOptionItemProps, query: string) => {
215
+ const normalizedQuery = query.trim().toLowerCase();
216
+
217
+ if (!normalizedQuery) {
218
+ return true;
219
+ }
220
+
221
+ const haystack = [option.label, ...(option.keywords ?? [])].map(term => term.toLowerCase());
222
+
223
+ return haystack.some(term => term.includes(normalizedQuery));
224
+ }, []);
225
+
226
+ const filteredOptions = useMemo(() => {
227
+ const optionFilter = filterOption ?? defaultFilterOption;
228
+
229
+ return options.filter(option => optionFilter(option, search));
230
+ }, [defaultFilterOption, filterOption, options, search]);
231
+
232
+ const renderSelectOption = useCallback(
233
+ ({ item }: { item: ComboboxOptionItemProps }) => (
234
+ <ComboboxOption
235
+ label={item.label}
236
+ value={item.value}
237
+ disabled={item.disabled}
238
+ leadingIcon={item.leadingIcon}
239
+ trailingIcon={item.trailingIcon}
240
+ />
241
+ ),
242
+ []
243
+ );
244
+
245
+ const renderEmptyComponent = useCallback(
246
+ () => (
247
+ <BottomSheetView style={styles.emptyContainer}>
248
+ <DetailText>{noOptionsFoundText}</DetailText>
249
+ </BottomSheetView>
250
+ ),
251
+ [noOptionsFoundText]
252
+ );
253
+
254
+ const renderContentProps = useMemo<ComboboxRenderContentProps>(
255
+ () => ({
256
+ close: closeBottomSheet,
257
+ search,
258
+ selectedValue: value,
259
+ selectOption,
260
+ setSearch,
261
+ }),
262
+ [closeBottomSheet, search, selectOption, setSearch, value]
263
+ );
264
+
265
+ const customContent = typeof children === 'function' ? children(renderContentProps) : children;
266
+
267
+ return (
268
+ <View {...rest} style={[styles.container, rest.style]}>
269
+ <FormField
270
+ label={label}
271
+ labelVariant={labelVariant}
272
+ helperText={helperText}
273
+ helperIcon={helperIcon}
274
+ validationStatus={validationStatusFromContext}
275
+ required={isRequired}
276
+ disabled={isDisabled}
277
+ readonly={isReadonly}
278
+ invalidText={invalidText}
279
+ validText={validText}
280
+ accessibilityHandledByChildren
281
+ >
282
+ <Pressable
283
+ onPress={openBottomSheet}
284
+ disabled={isDisabled || isReadonly}
285
+ accessibilityRole="button"
286
+ accessibilityLabel={accessibilityLabel}
287
+ accessibilityHint={accessibilityHint}
288
+ accessibilityState={{ expanded: isOpen, disabled: isDisabled || isReadonly }}
289
+ style={({ pressed }) => [styles.trigger, (pressed || isOpen) && styles.triggerFocused]}
290
+ >
291
+ <View style={styles.leadingIconContainer}>
292
+ <Icon as={SearchMediumIcon} style={styles.icon} />
293
+ </View>
294
+
295
+ <View style={styles.valueContainer}>
296
+ <BodyText numberOfLines={1} style={styles.valueText}>
297
+ {search || placeholder}
298
+ </BodyText>
299
+ </View>
300
+
301
+ {loading && <Spinner size="xs" color={color.icon.primary} />}
302
+
303
+ {!!search && (
304
+ <UnstyledIconButton
305
+ accessibilityLabel="Clear search"
306
+ onPress={handleClearPress}
307
+ icon={CloseSmallIcon}
308
+ />
309
+ )}
310
+ </Pressable>
311
+ </FormField>
312
+
313
+ <BottomSheetModal
314
+ ref={bottomSheetModalRef}
315
+ snapPoints={DEFAULT_SNAP_POINTS}
316
+ onChange={handleClose}
317
+ enableDynamicSizing={false}
318
+ {...bottomSheetProps}
319
+ >
320
+ <ComboboxContext.Provider
321
+ value={{
322
+ close: closeBottomSheet,
323
+ search,
324
+ selectedValue: value,
325
+ selectOption,
326
+ setSearch,
327
+ }}
328
+ >
329
+ <SafeAreaView edges={['top']}>
330
+ {menuHeading && (
331
+ <View style={styles.headingContainer}>
332
+ <DetailText size="lg">{menuHeading}</DetailText>
333
+ </View>
334
+ )}
335
+ <View style={styles.searchContainer}>
336
+ <Input
337
+ ref={searchInputRef}
338
+ placeholder={searchPlaceholder}
339
+ value={search}
340
+ inBottomSheet
341
+ onChangeText={setSearch}
342
+ type="search"
343
+ clearable
344
+ onClear={handleClear}
345
+ loading={loading}
346
+ />
347
+ </View>
348
+ </SafeAreaView>
349
+
350
+ {customContent || (
351
+ <BottomSheetFlatList
352
+ data={filteredOptions}
353
+ keyExtractor={(option: ComboboxOptionItemProps) => option.value}
354
+ renderItem={renderSelectOption}
355
+ ListEmptyComponent={renderEmptyComponent}
356
+ {...listProps}
357
+ />
358
+ )}
359
+ </ComboboxContext.Provider>
360
+ </BottomSheetModal>
361
+ </View>
362
+ );
363
+ };
364
+
365
+ const styles = StyleSheet.create(theme => ({
366
+ container: {
367
+ gap: theme.components.select.gap,
368
+ },
369
+ trigger: {
370
+ flexDirection: 'row',
371
+ alignItems: 'center',
372
+ minHeight: theme.components.input.height,
373
+ backgroundColor: theme.color.surface.neutral.strong,
374
+ borderWidth: theme.components.input.borderWidth,
375
+ borderColor: theme.color.border.strong,
376
+ borderRadius: theme.components.input.borderRadius,
377
+ paddingHorizontal: theme.components.input.paddingHorizontal,
378
+ gap: theme.components.input.gap,
379
+ outlineStyle: 'solid',
380
+ outlineWidth: theme.components.input.borderWidth,
381
+ outlineColor: theme.color.border.strong,
382
+ variants: {
383
+ disabled: {
384
+ true: {
385
+ opacity: theme.opacity.disabled,
386
+ },
387
+ },
388
+ readonly: {
389
+ true: {
390
+ borderColor: theme.color.border.subtle,
391
+ },
392
+ },
393
+ validationStatus: {
394
+ initial: {},
395
+ valid: {
396
+ borderColor: theme.color.feedback.positive.border,
397
+ outlineColor: theme.color.feedback.positive.border,
398
+ },
399
+ invalid: {
400
+ borderColor: theme.color.feedback.danger.border,
401
+ outlineColor: theme.color.feedback.danger.border,
402
+ },
403
+ },
404
+ },
405
+ },
406
+ triggerFocused: {
407
+ outlineWidth: theme.components.input.borderWidthFocused,
408
+ },
409
+ leadingIconContainer: {
410
+ alignItems: 'center',
411
+ justifyContent: 'center',
412
+ },
413
+ valueContainer: {
414
+ flex: 1,
415
+ },
416
+ valueText: {
417
+ variants: {
418
+ hasValue: {
419
+ false: {
420
+ color: theme.color.text.secondary,
421
+ },
422
+ },
423
+ },
424
+ },
425
+ icon: {
426
+ color: theme.color.icon.primary,
427
+ },
428
+ headingContainer: {
429
+ paddingHorizontal: theme.components.bottomSheet.padding,
430
+ marginBottom: theme.components.select.gap,
431
+ },
432
+ searchContainer: {
433
+ paddingTop: 1,
434
+ paddingHorizontal: theme.components.bottomSheet.padding,
435
+ paddingBottom: theme.components.select.gap,
436
+ },
437
+ emptyContainer: {
438
+ alignItems: 'center',
439
+ justifyContent: 'center',
440
+ marginTop: theme.space.md,
441
+ },
442
+ }));
443
+
444
+ Combobox.displayName = 'Combobox';
445
+
446
+ export default Combobox;