@ecohouse/ui 0.1.2 → 0.1.4
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/dist/index.cjs +1734 -155
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +287 -13
- package/dist/index.d.ts +287 -13
- package/dist/index.js +1720 -159
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Avatar/Avatar.stories.tsx +133 -0
- package/src/components/Avatar/Avatar.tsx +389 -0
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/Button/Button.tsx +106 -88
- package/src/components/Checkbox/Checkbox.stories.tsx +112 -0
- package/src/components/Checkbox/Checkbox.tsx +150 -0
- package/src/components/Checkbox/index.ts +2 -0
- package/src/components/Input/Input.stories.tsx +11 -40
- package/src/components/Input/Input.tsx +58 -54
- package/src/components/Input/index.ts +1 -1
- package/src/components/Select/Select.stories.tsx +196 -0
- package/src/components/Select/Select.tsx +851 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/Textarea/Textarea.stories.tsx +71 -0
- package/src/components/Textarea/Textarea.tsx +241 -0
- package/src/components/Textarea/index.ts +2 -0
- package/src/components/Toggle/Toggle.stories.tsx +100 -0
- package/src/components/Toggle/Toggle.tsx +176 -0
- package/src/components/Toggle/index.ts +2 -0
- package/src/components/index.ts +16 -1
- package/src/icons/Calendar/CalendarIcon.tsx +21 -0
- package/src/icons/Calendar/CalendarIcon.web.tsx +27 -0
- package/src/icons/Calendar/calendarPath.ts +3 -0
- package/src/icons/Calendar/index.ts +1 -0
- package/src/icons/Check/CheckIcon.tsx +26 -0
- package/src/icons/Check/CheckIcon.web.tsx +32 -0
- package/src/icons/Check/checkPath.ts +2 -0
- package/src/icons/Check/index.ts +1 -0
- package/src/icons/ChevronDown/ChevronDownIcon.tsx +30 -0
- package/src/icons/ChevronDown/ChevronDownIcon.web.tsx +36 -0
- package/src/icons/ChevronDown/chevronDownPath.ts +2 -0
- package/src/icons/ChevronDown/index.ts +1 -0
- package/src/icons/Heart/HeartIcon.tsx +23 -0
- package/src/icons/Heart/HeartIcon.web.tsx +29 -0
- package/src/icons/Heart/heartPath.ts +3 -0
- package/src/icons/Heart/index.ts +1 -0
- package/src/icons/HelpCircle/HelpCircleIcon.tsx +25 -0
- package/src/icons/HelpCircle/HelpCircleIcon.web.tsx +31 -0
- package/src/icons/HelpCircle/helpCirclePath.ts +3 -0
- package/src/icons/HelpCircle/index.ts +1 -0
- package/src/icons/LayoutGrid/LayoutGridIcon.tsx +25 -0
- package/src/icons/LayoutGrid/LayoutGridIcon.web.tsx +31 -0
- package/src/icons/LayoutGrid/index.ts +1 -0
- package/src/icons/LayoutGrid/layoutGridPath.ts +3 -0
- package/src/icons/LogOut/LogOutIcon.tsx +21 -0
- package/src/icons/LogOut/LogOutIcon.web.tsx +27 -0
- package/src/icons/LogOut/index.ts +1 -0
- package/src/icons/LogOut/logOutPath.ts +3 -0
- package/src/icons/Search/SearchIcon.tsx +37 -0
- package/src/icons/Search/SearchIcon.web.tsx +43 -0
- package/src/icons/Search/index.ts +1 -0
- package/src/icons/Search/searchPath.ts +5 -0
- package/src/icons/Settings/SettingsIcon.tsx +21 -0
- package/src/icons/Settings/SettingsIcon.web.tsx +27 -0
- package/src/icons/Settings/index.ts +1 -0
- package/src/icons/Settings/settingsPath.ts +3 -0
- package/src/icons/Video/VideoIcon.tsx +24 -0
- package/src/icons/Video/VideoIcon.web.tsx +30 -0
- package/src/icons/Video/index.ts +1 -0
- package/src/icons/Video/videoPath.ts +5 -0
- package/src/icons/index.ts +10 -0
- package/src/icons/registry.ts +24 -3
- package/src/index.ts +31 -3
- package/src/theme/colors.ts +1 -0
- package/src/theme/index.ts +8 -1
- package/src/theme/typography.ts +77 -2
- package/src/utils/mergeRefs.ts +20 -0
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ComponentRef,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import {
|
|
12
|
+
Platform,
|
|
13
|
+
Pressable,
|
|
14
|
+
ScrollView,
|
|
15
|
+
StyleSheet,
|
|
16
|
+
Text,
|
|
17
|
+
TextInput,
|
|
18
|
+
View,
|
|
19
|
+
type LayoutChangeEvent,
|
|
20
|
+
type StyleProp,
|
|
21
|
+
type TextStyle,
|
|
22
|
+
type ViewProps,
|
|
23
|
+
type ViewStyle,
|
|
24
|
+
} from "react-native";
|
|
25
|
+
import { Checkbox } from "../Checkbox";
|
|
26
|
+
import { ChevronDownIcon, SearchIcon, UserIcon } from "../../icons";
|
|
27
|
+
import { colors, selectTypography } from "../../theme";
|
|
28
|
+
import { mergeRefs } from "../../utils/mergeRefs";
|
|
29
|
+
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
30
|
+
|
|
31
|
+
export type SelectOption = {
|
|
32
|
+
label: string;
|
|
33
|
+
value: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type SelectBaseProps = Omit<ViewProps, "style" | "children"> & {
|
|
37
|
+
label?: string;
|
|
38
|
+
showLabel?: boolean;
|
|
39
|
+
hint?: string;
|
|
40
|
+
showHint?: boolean;
|
|
41
|
+
placeholder?: string;
|
|
42
|
+
options: SelectOption[];
|
|
43
|
+
open?: boolean;
|
|
44
|
+
defaultOpen?: boolean;
|
|
45
|
+
onOpenChange?: (open: boolean) => void;
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
error?: boolean;
|
|
48
|
+
leftIcon?: ReactNode;
|
|
49
|
+
showLeftIcon?: boolean;
|
|
50
|
+
showSearch?: boolean;
|
|
51
|
+
searchPlaceholder?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Called with the raw query on every keystroke. When provided, the parent owns
|
|
54
|
+
* filtering (e.g. server search) and the options prop is rendered as-is;
|
|
55
|
+
* when omitted, options are filtered locally by label.
|
|
56
|
+
*/
|
|
57
|
+
onSearchChange?: (query: string) => void;
|
|
58
|
+
/** Shows a loading row in the menu while the parent fetches options. */
|
|
59
|
+
loading?: boolean;
|
|
60
|
+
/** Text shown when no options match. */
|
|
61
|
+
emptyText?: string;
|
|
62
|
+
style?: StyleProp<ViewStyle>;
|
|
63
|
+
className?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type SelectSingleProps = SelectBaseProps & {
|
|
67
|
+
multiple?: false;
|
|
68
|
+
value?: string;
|
|
69
|
+
defaultValue?: string;
|
|
70
|
+
onValueChange?: (value: string) => void;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type SelectMultipleProps = SelectBaseProps & {
|
|
74
|
+
multiple: true;
|
|
75
|
+
value?: string[];
|
|
76
|
+
defaultValue?: string[];
|
|
77
|
+
onValueChange?: (value: string[]) => void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type SelectProps = SelectSingleProps | SelectMultipleProps;
|
|
81
|
+
|
|
82
|
+
const DEFAULT_WIDTH = 308;
|
|
83
|
+
const TRIGGER_HEIGHT = 44;
|
|
84
|
+
const TRIGGER_RADIUS = 60;
|
|
85
|
+
const MENU_RADIUS = 24;
|
|
86
|
+
const MENU_MAX_LIST_HEIGHT = 248;
|
|
87
|
+
const ITEM_RADIUS = 12;
|
|
88
|
+
const ICON_SIZE = 20;
|
|
89
|
+
const CHIP_GAP = 6;
|
|
90
|
+
const FALLBACK_ELLIPSIS_WIDTH = 20;
|
|
91
|
+
|
|
92
|
+
type ItemVisualState = "default" | "hover" | "selected";
|
|
93
|
+
|
|
94
|
+
function itemBackground(state: ItemVisualState): string {
|
|
95
|
+
switch (state) {
|
|
96
|
+
case "hover":
|
|
97
|
+
return colors.grey600;
|
|
98
|
+
case "selected":
|
|
99
|
+
return colors.primaryMuted;
|
|
100
|
+
default:
|
|
101
|
+
return "transparent";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toArray(value: string | string[] | undefined): string[] {
|
|
106
|
+
if (value == null || value === "") return [];
|
|
107
|
+
return Array.isArray(value) ? value : [value];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveVisibleOptions(
|
|
111
|
+
options: SelectOption[],
|
|
112
|
+
containerWidth: number,
|
|
113
|
+
chipWidths: Record<string, number>,
|
|
114
|
+
ellipsisWidth: number,
|
|
115
|
+
): { visible: SelectOption[]; hasOverflow: boolean } {
|
|
116
|
+
if (options.length === 0) return { visible: [], hasOverflow: false };
|
|
117
|
+
if (containerWidth <= 0) return { visible: options.slice(0, 1), hasOverflow: options.length > 1 };
|
|
118
|
+
|
|
119
|
+
const visible: SelectOption[] = [];
|
|
120
|
+
let used = 0;
|
|
121
|
+
|
|
122
|
+
for (let index = 0; index < options.length; index += 1) {
|
|
123
|
+
const option = options[index];
|
|
124
|
+
const chipWidth = chipWidths[option.value];
|
|
125
|
+
if (chipWidth == null) {
|
|
126
|
+
if (visible.length === 0) visible.push(option);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const remainingAfter = options.length - index - 1;
|
|
131
|
+
const gap = visible.length > 0 ? CHIP_GAP : 0;
|
|
132
|
+
const reserve = remainingAfter > 0 ? CHIP_GAP + ellipsisWidth : 0;
|
|
133
|
+
const nextUsed = used + gap + chipWidth;
|
|
134
|
+
|
|
135
|
+
if (visible.length > 0 && nextUsed + reserve > containerWidth) {
|
|
136
|
+
return { visible, hasOverflow: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
visible.push(option);
|
|
140
|
+
used = nextUsed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { visible, hasOverflow: visible.length < options.length };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type MultiValueChipsProps = {
|
|
147
|
+
options: SelectOption[];
|
|
148
|
+
disabled?: boolean;
|
|
149
|
+
onRemove: (value: string) => void;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
function MultiValueChips({ options, disabled, onRemove }: MultiValueChipsProps) {
|
|
153
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
154
|
+
const [chipWidths, setChipWidths] = useState<Record<string, number>>({});
|
|
155
|
+
const [ellipsisWidth, setEllipsisWidth] = useState(FALLBACK_ELLIPSIS_WIDTH);
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
setChipWidths((current) => {
|
|
159
|
+
const validValues = new Set(options.map((option) => option.value));
|
|
160
|
+
const keys = Object.keys(current);
|
|
161
|
+
if (keys.every((key) => validValues.has(key))) return current;
|
|
162
|
+
|
|
163
|
+
const next: Record<string, number> = {};
|
|
164
|
+
for (const key of keys) {
|
|
165
|
+
if (validValues.has(key)) next[key] = current[key];
|
|
166
|
+
}
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
}, [options]);
|
|
170
|
+
|
|
171
|
+
const { visible, hasOverflow } = useMemo(
|
|
172
|
+
() => resolveVisibleOptions(options, containerWidth, chipWidths, ellipsisWidth),
|
|
173
|
+
[chipWidths, containerWidth, ellipsisWidth, options],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const handleContainerLayout = (event: LayoutChangeEvent) => {
|
|
177
|
+
const next = Math.floor(event.nativeEvent.layout.width);
|
|
178
|
+
setContainerWidth((current) => (current === next ? current : next));
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const handleChipLayout = (value: string, event: LayoutChangeEvent) => {
|
|
182
|
+
const next = Math.ceil(event.nativeEvent.layout.width);
|
|
183
|
+
setChipWidths((current) => (current[value] === next ? current : { ...current, [value]: next }));
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const handleEllipsisLayout = (event: LayoutChangeEvent) => {
|
|
187
|
+
const next = Math.ceil(event.nativeEvent.layout.width);
|
|
188
|
+
setEllipsisWidth((current) => (current === next ? current : next));
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<View style={styles.multiValueRoot}>
|
|
193
|
+
{/* Hidden copy of every chip; RN has no synchronous text measurement. */}
|
|
194
|
+
<View pointerEvents="none" style={styles.measureRow}>
|
|
195
|
+
{options.map((option) => (
|
|
196
|
+
<View
|
|
197
|
+
key={`measure-${option.value}`}
|
|
198
|
+
style={styles.chip}
|
|
199
|
+
onLayout={(event) => handleChipLayout(option.value, event)}
|
|
200
|
+
>
|
|
201
|
+
<Text style={styles.chipLabel} numberOfLines={1}>
|
|
202
|
+
{option.label}
|
|
203
|
+
</Text>
|
|
204
|
+
<Text style={styles.chipRemove}>×</Text>
|
|
205
|
+
</View>
|
|
206
|
+
))}
|
|
207
|
+
<View style={styles.ellipsis} onLayout={handleEllipsisLayout}>
|
|
208
|
+
<Text style={styles.ellipsisText}>...</Text>
|
|
209
|
+
</View>
|
|
210
|
+
</View>
|
|
211
|
+
|
|
212
|
+
<View style={styles.chipsRow} onLayout={handleContainerLayout}>
|
|
213
|
+
{visible.map((option) => (
|
|
214
|
+
<View key={option.value} style={styles.chip}>
|
|
215
|
+
<Text style={styles.chipLabel} numberOfLines={1}>
|
|
216
|
+
{option.label}
|
|
217
|
+
</Text>
|
|
218
|
+
<Pressable
|
|
219
|
+
onPress={(event) => {
|
|
220
|
+
// Keep the surrounding trigger Pressable from toggling the menu.
|
|
221
|
+
event?.stopPropagation?.();
|
|
222
|
+
onRemove(option.value);
|
|
223
|
+
}}
|
|
224
|
+
disabled={disabled}
|
|
225
|
+
hitSlop={6}
|
|
226
|
+
accessibilityRole="button"
|
|
227
|
+
accessibilityLabel={`Remove ${option.label}`}
|
|
228
|
+
>
|
|
229
|
+
<Text style={styles.chipRemove}>×</Text>
|
|
230
|
+
</Pressable>
|
|
231
|
+
</View>
|
|
232
|
+
))}
|
|
233
|
+
|
|
234
|
+
{hasOverflow ? (
|
|
235
|
+
<View style={styles.ellipsis}>
|
|
236
|
+
<Text style={styles.ellipsisText}>...</Text>
|
|
237
|
+
</View>
|
|
238
|
+
) : null}
|
|
239
|
+
</View>
|
|
240
|
+
</View>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
245
|
+
function Select(props, forwardedRef) {
|
|
246
|
+
const {
|
|
247
|
+
label = "Label",
|
|
248
|
+
showLabel = true,
|
|
249
|
+
hint,
|
|
250
|
+
showHint = false,
|
|
251
|
+
placeholder = "placeholder",
|
|
252
|
+
options,
|
|
253
|
+
open: openProp,
|
|
254
|
+
defaultOpen = false,
|
|
255
|
+
onOpenChange,
|
|
256
|
+
disabled = false,
|
|
257
|
+
error = false,
|
|
258
|
+
leftIcon,
|
|
259
|
+
showLeftIcon = true,
|
|
260
|
+
showSearch = true,
|
|
261
|
+
searchPlaceholder = "Search...",
|
|
262
|
+
onSearchChange,
|
|
263
|
+
loading = false,
|
|
264
|
+
emptyText = "No results",
|
|
265
|
+
style,
|
|
266
|
+
className,
|
|
267
|
+
accessibilityLabel,
|
|
268
|
+
// Pulled out only to keep them off `rest` (which is spread onto the root View);
|
|
269
|
+
// the real values are read from `props` directly below to preserve discriminated typing.
|
|
270
|
+
multiple: _multiple,
|
|
271
|
+
value: _value,
|
|
272
|
+
defaultValue: _defaultValue,
|
|
273
|
+
onValueChange: _onValueChange,
|
|
274
|
+
...rest
|
|
275
|
+
} = props;
|
|
276
|
+
|
|
277
|
+
const multiple = props.multiple === true;
|
|
278
|
+
const valueProp = props.value;
|
|
279
|
+
const rootRef = useRef<ComponentRef<typeof View>>(null);
|
|
280
|
+
const triggerRef = useRef<ComponentRef<typeof Pressable>>(null);
|
|
281
|
+
const searchInputRef = useRef<ComponentRef<typeof TextInput>>(null);
|
|
282
|
+
const setRootRef = useMemo(() => mergeRefs(rootRef, forwardedRef), [forwardedRef]);
|
|
283
|
+
const isOpenControlled = typeof openProp === "boolean";
|
|
284
|
+
const isValueControlled = valueProp !== undefined;
|
|
285
|
+
|
|
286
|
+
const [uncontrolledSingle, setUncontrolledSingle] = useState(
|
|
287
|
+
!multiple ? ((props as SelectSingleProps).defaultValue ?? "") : "",
|
|
288
|
+
);
|
|
289
|
+
const [uncontrolledMultiple, setUncontrolledMultiple] = useState<string[]>(
|
|
290
|
+
multiple ? ((props as SelectMultipleProps).defaultValue ?? []) : [],
|
|
291
|
+
);
|
|
292
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
293
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
294
|
+
const [hoveredValue, setHoveredValue] = useState<string | null>(null);
|
|
295
|
+
|
|
296
|
+
const selectedValues = useMemo(() => {
|
|
297
|
+
const current = isValueControlled
|
|
298
|
+
? valueProp
|
|
299
|
+
: multiple
|
|
300
|
+
? uncontrolledMultiple
|
|
301
|
+
: uncontrolledSingle;
|
|
302
|
+
return toArray(current);
|
|
303
|
+
}, [isValueControlled, multiple, valueProp, uncontrolledMultiple, uncontrolledSingle]);
|
|
304
|
+
|
|
305
|
+
const isOpen = isOpenControlled ? openProp : uncontrolledOpen;
|
|
306
|
+
|
|
307
|
+
// Mapped from selectedValues (not filtered from options) to preserve selection order.
|
|
308
|
+
const selectedOptions = useMemo(
|
|
309
|
+
() =>
|
|
310
|
+
selectedValues
|
|
311
|
+
.map((value) => options.find((option) => option.value === value))
|
|
312
|
+
.filter((option): option is SelectOption => option != null),
|
|
313
|
+
[options, selectedValues],
|
|
314
|
+
);
|
|
315
|
+
const hasValue = selectedOptions.length > 0;
|
|
316
|
+
|
|
317
|
+
const isExternalSearch = onSearchChange != null;
|
|
318
|
+
|
|
319
|
+
const filteredOptions = useMemo(() => {
|
|
320
|
+
if (isExternalSearch) return options;
|
|
321
|
+
const query = searchQuery.trim().toLowerCase();
|
|
322
|
+
if (!query) return options;
|
|
323
|
+
return options.filter((option) => option.label.toLowerCase().includes(query));
|
|
324
|
+
}, [isExternalSearch, options, searchQuery]);
|
|
325
|
+
|
|
326
|
+
useApplyWebClassName(rootRef, className);
|
|
327
|
+
|
|
328
|
+
const setOpen = useCallback(
|
|
329
|
+
(next: boolean) => {
|
|
330
|
+
if (!isOpenControlled) setUncontrolledOpen(next);
|
|
331
|
+
onOpenChange?.(next);
|
|
332
|
+
if (next) {
|
|
333
|
+
// Seed keyboard highlight from the current selection so Arrow keys
|
|
334
|
+
// start from a sensible position.
|
|
335
|
+
setHoveredValue(selectedValues[0] ?? null);
|
|
336
|
+
} else {
|
|
337
|
+
setSearchQuery("");
|
|
338
|
+
setHoveredValue(null);
|
|
339
|
+
// Let the parent reset its externally fetched options.
|
|
340
|
+
onSearchChange?.("");
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
[isOpenControlled, onOpenChange, onSearchChange, selectedValues],
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const handleSearchChange = (text: string) => {
|
|
347
|
+
setSearchQuery(text);
|
|
348
|
+
onSearchChange?.(text);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// Close on outside click (web only; native has no document-level events).
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (Platform.OS !== "web" || !isOpen) return;
|
|
354
|
+
|
|
355
|
+
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
|
356
|
+
const root = rootRef.current as unknown as { contains?: (node: Node) => boolean } | null;
|
|
357
|
+
const target = event.target;
|
|
358
|
+
if (root?.contains && target instanceof Node && !root.contains(target)) {
|
|
359
|
+
setOpen(false);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
document.addEventListener("mousedown", handlePointerDown);
|
|
364
|
+
document.addEventListener("touchstart", handlePointerDown);
|
|
365
|
+
return () => {
|
|
366
|
+
document.removeEventListener("mousedown", handlePointerDown);
|
|
367
|
+
document.removeEventListener("touchstart", handlePointerDown);
|
|
368
|
+
};
|
|
369
|
+
}, [isOpen, setOpen]);
|
|
370
|
+
|
|
371
|
+
const commitValues = useCallback(
|
|
372
|
+
(nextValues: string[]) => {
|
|
373
|
+
if (multiple) {
|
|
374
|
+
if (!isValueControlled) setUncontrolledMultiple(nextValues);
|
|
375
|
+
(props as SelectMultipleProps).onValueChange?.(nextValues);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const nextValue = nextValues[0] ?? "";
|
|
380
|
+
if (!isValueControlled) setUncontrolledSingle(nextValue);
|
|
381
|
+
(props as SelectSingleProps).onValueChange?.(nextValue);
|
|
382
|
+
},
|
|
383
|
+
[isValueControlled, multiple, props],
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const handleTriggerPress = () => {
|
|
387
|
+
if (disabled) return;
|
|
388
|
+
setOpen(!isOpen);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const handleSelect = useCallback(
|
|
392
|
+
(optionValue: string) => {
|
|
393
|
+
if (disabled) return;
|
|
394
|
+
if (multiple) {
|
|
395
|
+
const exists = selectedValues.includes(optionValue);
|
|
396
|
+
const nextValues = exists
|
|
397
|
+
? selectedValues.filter((value) => value !== optionValue)
|
|
398
|
+
: [...selectedValues, optionValue];
|
|
399
|
+
commitValues(nextValues);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
commitValues([optionValue]);
|
|
404
|
+
setOpen(false);
|
|
405
|
+
},
|
|
406
|
+
[commitValues, disabled, multiple, selectedValues, setOpen],
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const handleRemoveChip = (optionValue: string) => {
|
|
410
|
+
if (disabled || !multiple) return;
|
|
411
|
+
commitValues(selectedValues.filter((value) => value !== optionValue));
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const focusTrigger = useCallback(() => {
|
|
415
|
+
const node = triggerRef.current as unknown as { focus?: () => void } | null;
|
|
416
|
+
node?.focus?.();
|
|
417
|
+
}, []);
|
|
418
|
+
|
|
419
|
+
const closeAndFocusTrigger = useCallback(() => {
|
|
420
|
+
setOpen(false);
|
|
421
|
+
focusTrigger();
|
|
422
|
+
}, [focusTrigger, setOpen]);
|
|
423
|
+
|
|
424
|
+
// ARIA combobox keyboard pattern (web only): Arrow keys move the highlighted
|
|
425
|
+
// option, Enter/Space selects it, Escape closes and returns focus to the trigger.
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
if (Platform.OS !== "web") return;
|
|
428
|
+
|
|
429
|
+
const isTriggerFocused = () => document.activeElement === triggerRef.current;
|
|
430
|
+
const isSearchFocused = () => document.activeElement === searchInputRef.current;
|
|
431
|
+
|
|
432
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
433
|
+
if (!isOpen) {
|
|
434
|
+
if (!isTriggerFocused() || disabled) return;
|
|
435
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") {
|
|
436
|
+
event.preventDefault();
|
|
437
|
+
setOpen(true);
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (event.key === "Escape") {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
closeAndFocusTrigger();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
449
|
+
event.preventDefault();
|
|
450
|
+
setHoveredValue((current) => {
|
|
451
|
+
if (filteredOptions.length === 0) return current;
|
|
452
|
+
const currentIndex = filteredOptions.findIndex((option) => option.value === current);
|
|
453
|
+
const delta = event.key === "ArrowDown" ? 1 : -1;
|
|
454
|
+
const nextIndex =
|
|
455
|
+
currentIndex === -1
|
|
456
|
+
? delta === 1
|
|
457
|
+
? 0
|
|
458
|
+
: filteredOptions.length - 1
|
|
459
|
+
: Math.min(Math.max(currentIndex + delta, 0), filteredOptions.length - 1);
|
|
460
|
+
return filteredOptions[nextIndex]?.value ?? current;
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Home/End and Space are left alone while typing in the search field
|
|
466
|
+
// (text-cursor navigation and literal spaces must keep working).
|
|
467
|
+
if (
|
|
468
|
+
isSearchFocused() &&
|
|
469
|
+
(event.key === "Home" || event.key === "End" || event.key === " ")
|
|
470
|
+
) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (event.key === "Home" || event.key === "End") {
|
|
475
|
+
if (filteredOptions.length === 0) return;
|
|
476
|
+
event.preventDefault();
|
|
477
|
+
setHoveredValue(
|
|
478
|
+
event.key === "Home"
|
|
479
|
+
? filteredOptions[0].value
|
|
480
|
+
: filteredOptions[filteredOptions.length - 1].value,
|
|
481
|
+
);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
486
|
+
if (hoveredValue == null) return;
|
|
487
|
+
event.preventDefault();
|
|
488
|
+
handleSelect(hoveredValue);
|
|
489
|
+
if (!multiple) focusTrigger();
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
494
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
495
|
+
}, [
|
|
496
|
+
isOpen,
|
|
497
|
+
disabled,
|
|
498
|
+
filteredOptions,
|
|
499
|
+
hoveredValue,
|
|
500
|
+
multiple,
|
|
501
|
+
closeAndFocusTrigger,
|
|
502
|
+
focusTrigger,
|
|
503
|
+
handleSelect,
|
|
504
|
+
setOpen,
|
|
505
|
+
]);
|
|
506
|
+
|
|
507
|
+
const leftIconNode = leftIcon ?? (
|
|
508
|
+
<UserIcon
|
|
509
|
+
size={ICON_SIZE}
|
|
510
|
+
color={error ? colors.red : isOpen ? colors.primary : colors.grey100}
|
|
511
|
+
/>
|
|
512
|
+
);
|
|
513
|
+
const hasLeftIcon = showLeftIcon || leftIcon != null;
|
|
514
|
+
|
|
515
|
+
const triggerBorderColor = error
|
|
516
|
+
? colors.redMuted
|
|
517
|
+
: isOpen
|
|
518
|
+
? colors.primaryMuted
|
|
519
|
+
: colors.grey700;
|
|
520
|
+
const triggerTextColor = hasValue
|
|
521
|
+
? error
|
|
522
|
+
? colors.red
|
|
523
|
+
: colors.white
|
|
524
|
+
: error
|
|
525
|
+
? colors.red
|
|
526
|
+
: colors.grey100;
|
|
527
|
+
const labelColor = error ? colors.red : colors.white;
|
|
528
|
+
const hintColor = error ? colors.red : colors.grey100;
|
|
529
|
+
|
|
530
|
+
const singleLabel = selectedOptions[0]?.label ?? placeholder;
|
|
531
|
+
const resolvedAccessibilityLabel =
|
|
532
|
+
accessibilityLabel ?? (typeof label === "string" ? label : undefined);
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<View
|
|
536
|
+
{...rest}
|
|
537
|
+
ref={setRootRef}
|
|
538
|
+
style={[styles.root, isOpen && styles.rootOpen, disabled && styles.disabled, style]}
|
|
539
|
+
accessibilityState={{ disabled, expanded: isOpen }}
|
|
540
|
+
>
|
|
541
|
+
{showLabel && label ? (
|
|
542
|
+
<Text style={[styles.label, { color: labelColor }]}>{label}</Text>
|
|
543
|
+
) : null}
|
|
544
|
+
|
|
545
|
+
<View style={[styles.triggerWrap, isOpen && styles.triggerWrapOpen]}>
|
|
546
|
+
<Pressable
|
|
547
|
+
ref={triggerRef}
|
|
548
|
+
onPress={handleTriggerPress}
|
|
549
|
+
disabled={disabled}
|
|
550
|
+
accessibilityRole="combobox"
|
|
551
|
+
accessibilityLabel={resolvedAccessibilityLabel}
|
|
552
|
+
accessibilityState={{ disabled, expanded: isOpen }}
|
|
553
|
+
style={[
|
|
554
|
+
styles.trigger,
|
|
555
|
+
{
|
|
556
|
+
borderColor: triggerBorderColor,
|
|
557
|
+
backgroundColor: colors.grey700,
|
|
558
|
+
},
|
|
559
|
+
]}
|
|
560
|
+
>
|
|
561
|
+
{hasLeftIcon ? <View style={styles.iconSlot}>{leftIconNode}</View> : null}
|
|
562
|
+
|
|
563
|
+
<View style={styles.triggerContent}>
|
|
564
|
+
{multiple && hasValue ? (
|
|
565
|
+
<MultiValueChips
|
|
566
|
+
options={selectedOptions}
|
|
567
|
+
disabled={disabled}
|
|
568
|
+
onRemove={handleRemoveChip}
|
|
569
|
+
/>
|
|
570
|
+
) : (
|
|
571
|
+
<Text style={[styles.triggerText, { color: triggerTextColor }]} numberOfLines={1}>
|
|
572
|
+
{multiple ? placeholder : singleLabel}
|
|
573
|
+
</Text>
|
|
574
|
+
)}
|
|
575
|
+
</View>
|
|
576
|
+
|
|
577
|
+
<View style={[styles.iconSlot, isOpen && styles.chevronOpen]}>
|
|
578
|
+
<ChevronDownIcon size={ICON_SIZE} color={isOpen ? colors.primary : colors.grey100} />
|
|
579
|
+
</View>
|
|
580
|
+
</Pressable>
|
|
581
|
+
|
|
582
|
+
{isOpen ? (
|
|
583
|
+
<View style={styles.menu}>
|
|
584
|
+
{showSearch ? (
|
|
585
|
+
<View style={styles.searchField}>
|
|
586
|
+
<View style={styles.iconSlot}>
|
|
587
|
+
<SearchIcon size={ICON_SIZE} color={colors.grey100} />
|
|
588
|
+
</View>
|
|
589
|
+
<TextInput
|
|
590
|
+
ref={searchInputRef}
|
|
591
|
+
value={searchQuery}
|
|
592
|
+
onChangeText={handleSearchChange}
|
|
593
|
+
editable={!disabled}
|
|
594
|
+
placeholder={searchPlaceholder}
|
|
595
|
+
placeholderTextColor={colors.grey100}
|
|
596
|
+
style={[
|
|
597
|
+
styles.searchInput,
|
|
598
|
+
Platform.OS === "web" ? styles.searchInputWeb : null,
|
|
599
|
+
Platform.OS === "android" ? styles.searchInputAndroid : null,
|
|
600
|
+
]}
|
|
601
|
+
accessibilityLabel={searchPlaceholder}
|
|
602
|
+
/>
|
|
603
|
+
</View>
|
|
604
|
+
) : null}
|
|
605
|
+
|
|
606
|
+
<ScrollView
|
|
607
|
+
style={styles.optionList}
|
|
608
|
+
contentContainerStyle={styles.optionListContent}
|
|
609
|
+
keyboardShouldPersistTaps="handled"
|
|
610
|
+
showsVerticalScrollIndicator={false}
|
|
611
|
+
>
|
|
612
|
+
{loading ? (
|
|
613
|
+
<View style={styles.statusRow}>
|
|
614
|
+
<Text style={styles.statusText}>Loading...</Text>
|
|
615
|
+
</View>
|
|
616
|
+
) : filteredOptions.length === 0 ? (
|
|
617
|
+
<View style={styles.statusRow}>
|
|
618
|
+
<Text style={styles.statusText}>{emptyText}</Text>
|
|
619
|
+
</View>
|
|
620
|
+
) : null}
|
|
621
|
+
|
|
622
|
+
{!loading &&
|
|
623
|
+
filteredOptions.map((option) => {
|
|
624
|
+
const isSelected = selectedValues.includes(option.value);
|
|
625
|
+
const isHovered = hoveredValue === option.value;
|
|
626
|
+
const visualState: ItemVisualState = isSelected
|
|
627
|
+
? "selected"
|
|
628
|
+
: isHovered
|
|
629
|
+
? "hover"
|
|
630
|
+
: "default";
|
|
631
|
+
|
|
632
|
+
return (
|
|
633
|
+
<Pressable
|
|
634
|
+
key={option.value}
|
|
635
|
+
onPress={() => handleSelect(option.value)}
|
|
636
|
+
disabled={disabled}
|
|
637
|
+
onHoverIn={() => setHoveredValue(option.value)}
|
|
638
|
+
onHoverOut={() =>
|
|
639
|
+
setHoveredValue((current) => (current === option.value ? null : current))
|
|
640
|
+
}
|
|
641
|
+
onPressIn={() => setHoveredValue(option.value)}
|
|
642
|
+
onPressOut={() =>
|
|
643
|
+
setHoveredValue((current) => (current === option.value ? null : current))
|
|
644
|
+
}
|
|
645
|
+
accessibilityRole={multiple ? "checkbox" : "button"}
|
|
646
|
+
accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}
|
|
647
|
+
style={[
|
|
648
|
+
styles.item,
|
|
649
|
+
{
|
|
650
|
+
backgroundColor: itemBackground(visualState),
|
|
651
|
+
},
|
|
652
|
+
]}
|
|
653
|
+
>
|
|
654
|
+
{multiple ? (
|
|
655
|
+
<View pointerEvents="none" style={styles.itemCheckbox}>
|
|
656
|
+
<Checkbox value={isSelected} variant="light" />
|
|
657
|
+
</View>
|
|
658
|
+
) : null}
|
|
659
|
+
<Text style={styles.itemLabel} numberOfLines={1}>
|
|
660
|
+
{option.label}
|
|
661
|
+
</Text>
|
|
662
|
+
</Pressable>
|
|
663
|
+
);
|
|
664
|
+
})}
|
|
665
|
+
</ScrollView>
|
|
666
|
+
</View>
|
|
667
|
+
) : null}
|
|
668
|
+
</View>
|
|
669
|
+
|
|
670
|
+
{showHint && hint ? <Text style={[styles.hint, { color: hintColor }]}>{hint}</Text> : null}
|
|
671
|
+
</View>
|
|
672
|
+
);
|
|
673
|
+
},
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const styles = StyleSheet.create({
|
|
677
|
+
root: {
|
|
678
|
+
width: DEFAULT_WIDTH,
|
|
679
|
+
gap: 8,
|
|
680
|
+
alignSelf: "flex-start",
|
|
681
|
+
},
|
|
682
|
+
rootOpen: {
|
|
683
|
+
zIndex: 30,
|
|
684
|
+
},
|
|
685
|
+
disabled: {
|
|
686
|
+
opacity: 0.6,
|
|
687
|
+
},
|
|
688
|
+
label: {
|
|
689
|
+
...selectTypography.label,
|
|
690
|
+
},
|
|
691
|
+
triggerWrap: {
|
|
692
|
+
position: "relative",
|
|
693
|
+
zIndex: 1,
|
|
694
|
+
},
|
|
695
|
+
triggerWrapOpen: {
|
|
696
|
+
zIndex: 20,
|
|
697
|
+
},
|
|
698
|
+
trigger: {
|
|
699
|
+
height: TRIGGER_HEIGHT,
|
|
700
|
+
borderRadius: TRIGGER_RADIUS,
|
|
701
|
+
borderWidth: 1,
|
|
702
|
+
paddingVertical: 12,
|
|
703
|
+
paddingHorizontal: 16,
|
|
704
|
+
flexDirection: "row",
|
|
705
|
+
alignItems: "center",
|
|
706
|
+
gap: 10,
|
|
707
|
+
overflow: "hidden",
|
|
708
|
+
},
|
|
709
|
+
triggerContent: {
|
|
710
|
+
flex: 1,
|
|
711
|
+
justifyContent: "center",
|
|
712
|
+
minWidth: 0,
|
|
713
|
+
overflow: "hidden",
|
|
714
|
+
},
|
|
715
|
+
triggerText: {
|
|
716
|
+
...selectTypography.field,
|
|
717
|
+
},
|
|
718
|
+
multiValueRoot: {
|
|
719
|
+
width: "100%",
|
|
720
|
+
position: "relative",
|
|
721
|
+
},
|
|
722
|
+
measureRow: {
|
|
723
|
+
position: "absolute",
|
|
724
|
+
left: 0,
|
|
725
|
+
top: 0,
|
|
726
|
+
flexDirection: "row",
|
|
727
|
+
opacity: 0,
|
|
728
|
+
zIndex: -1,
|
|
729
|
+
},
|
|
730
|
+
chipsRow: {
|
|
731
|
+
flexDirection: "row",
|
|
732
|
+
alignItems: "center",
|
|
733
|
+
gap: CHIP_GAP,
|
|
734
|
+
overflow: "hidden",
|
|
735
|
+
},
|
|
736
|
+
chip: {
|
|
737
|
+
flexDirection: "row",
|
|
738
|
+
alignItems: "center",
|
|
739
|
+
gap: 4,
|
|
740
|
+
paddingVertical: 2,
|
|
741
|
+
paddingHorizontal: 8,
|
|
742
|
+
borderRadius: 999,
|
|
743
|
+
backgroundColor: colors.grey600,
|
|
744
|
+
flexShrink: 0,
|
|
745
|
+
},
|
|
746
|
+
chipLabel: {
|
|
747
|
+
...selectTypography.hint,
|
|
748
|
+
color: colors.white,
|
|
749
|
+
maxWidth: 120,
|
|
750
|
+
},
|
|
751
|
+
chipRemove: {
|
|
752
|
+
color: colors.grey100,
|
|
753
|
+
fontSize: 14,
|
|
754
|
+
lineHeight: 14,
|
|
755
|
+
},
|
|
756
|
+
ellipsis: {
|
|
757
|
+
flexShrink: 0,
|
|
758
|
+
paddingHorizontal: 2,
|
|
759
|
+
},
|
|
760
|
+
ellipsisText: {
|
|
761
|
+
...selectTypography.field,
|
|
762
|
+
color: colors.white,
|
|
763
|
+
lineHeight: 16,
|
|
764
|
+
},
|
|
765
|
+
iconSlot: {
|
|
766
|
+
width: ICON_SIZE,
|
|
767
|
+
height: ICON_SIZE,
|
|
768
|
+
alignItems: "center",
|
|
769
|
+
justifyContent: "center",
|
|
770
|
+
flexShrink: 0,
|
|
771
|
+
},
|
|
772
|
+
chevronOpen: {
|
|
773
|
+
transform: [{ rotate: "180deg" }],
|
|
774
|
+
},
|
|
775
|
+
menu: {
|
|
776
|
+
position: "absolute",
|
|
777
|
+
top: "100%",
|
|
778
|
+
left: 0,
|
|
779
|
+
right: 0,
|
|
780
|
+
marginTop: 8,
|
|
781
|
+
zIndex: 10,
|
|
782
|
+
borderRadius: MENU_RADIUS,
|
|
783
|
+
padding: 12,
|
|
784
|
+
gap: 8,
|
|
785
|
+
backgroundColor: colors.grey700,
|
|
786
|
+
...Platform.select({
|
|
787
|
+
web: {
|
|
788
|
+
boxShadow: `0 8px 24px ${colors.black}73`,
|
|
789
|
+
},
|
|
790
|
+
default: {
|
|
791
|
+
elevation: 8,
|
|
792
|
+
},
|
|
793
|
+
}),
|
|
794
|
+
},
|
|
795
|
+
optionList: {
|
|
796
|
+
maxHeight: MENU_MAX_LIST_HEIGHT,
|
|
797
|
+
},
|
|
798
|
+
optionListContent: {
|
|
799
|
+
gap: 8,
|
|
800
|
+
},
|
|
801
|
+
statusRow: {
|
|
802
|
+
minHeight: 43,
|
|
803
|
+
padding: 12,
|
|
804
|
+
justifyContent: "center",
|
|
805
|
+
},
|
|
806
|
+
statusText: {
|
|
807
|
+
...selectTypography.field,
|
|
808
|
+
color: colors.grey100,
|
|
809
|
+
},
|
|
810
|
+
searchField: {
|
|
811
|
+
height: 40,
|
|
812
|
+
borderRadius: ITEM_RADIUS,
|
|
813
|
+
paddingHorizontal: 12,
|
|
814
|
+
flexDirection: "row",
|
|
815
|
+
alignItems: "center",
|
|
816
|
+
gap: 8,
|
|
817
|
+
backgroundColor: colors.grey800,
|
|
818
|
+
},
|
|
819
|
+
searchInput: {
|
|
820
|
+
...selectTypography.field,
|
|
821
|
+
flex: 1,
|
|
822
|
+
color: colors.white,
|
|
823
|
+
paddingVertical: 0,
|
|
824
|
+
margin: 0,
|
|
825
|
+
},
|
|
826
|
+
searchInputWeb: {
|
|
827
|
+
outlineStyle: "none",
|
|
828
|
+
} as TextStyle,
|
|
829
|
+
searchInputAndroid: {
|
|
830
|
+
includeFontPadding: false,
|
|
831
|
+
},
|
|
832
|
+
item: {
|
|
833
|
+
minHeight: 43,
|
|
834
|
+
borderRadius: ITEM_RADIUS,
|
|
835
|
+
padding: 12,
|
|
836
|
+
flexDirection: "row",
|
|
837
|
+
alignItems: "center",
|
|
838
|
+
gap: 8,
|
|
839
|
+
},
|
|
840
|
+
itemCheckbox: {
|
|
841
|
+
flexShrink: 0,
|
|
842
|
+
},
|
|
843
|
+
itemLabel: {
|
|
844
|
+
...selectTypography.field,
|
|
845
|
+
color: colors.white,
|
|
846
|
+
flex: 1,
|
|
847
|
+
},
|
|
848
|
+
hint: {
|
|
849
|
+
...selectTypography.hint,
|
|
850
|
+
},
|
|
851
|
+
});
|