@elik56a/react-native-pickers 0.0.0-stage → 0.1.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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -2
  3. package/lib/date/Calendar.d.ts +89 -0
  4. package/lib/date/Calendar.d.ts.map +1 -0
  5. package/lib/date/Calendar.js +250 -0
  6. package/lib/date/Calendar.js.map +1 -0
  7. package/lib/date/DatePicker.d.ts +14 -0
  8. package/lib/date/DatePicker.d.ts.map +1 -0
  9. package/lib/date/DatePicker.js +17 -0
  10. package/lib/date/DatePicker.js.map +1 -0
  11. package/lib/date/DateWheelPicker.d.ts +29 -0
  12. package/lib/date/DateWheelPicker.d.ts.map +1 -0
  13. package/lib/date/DateWheelPicker.js +83 -0
  14. package/lib/date/DateWheelPicker.js.map +1 -0
  15. package/lib/emoji/EmojiPicker.d.ts +47 -0
  16. package/lib/emoji/EmojiPicker.d.ts.map +1 -0
  17. package/lib/emoji/EmojiPicker.js +161 -0
  18. package/lib/emoji/EmojiPicker.js.map +1 -0
  19. package/lib/emoji/data.d.ts +2 -0
  20. package/lib/emoji/data.d.ts.map +1 -0
  21. package/lib/emoji/data.js +4 -0
  22. package/lib/emoji/data.js.map +1 -0
  23. package/lib/emoji/emojis.d.ts +23 -0
  24. package/lib/emoji/emojis.d.ts.map +1 -0
  25. package/lib/emoji/emojis.js +100 -0
  26. package/lib/emoji/emojis.js.map +1 -0
  27. package/lib/index.d.ts +17 -0
  28. package/lib/index.d.ts.map +1 -0
  29. package/lib/index.js +17 -0
  30. package/lib/index.js.map +1 -0
  31. package/lib/select/SelectList.d.ts +60 -0
  32. package/lib/select/SelectList.d.ts.map +1 -0
  33. package/lib/select/SelectList.js +91 -0
  34. package/lib/select/SelectList.js.map +1 -0
  35. package/lib/select/SelectPicker.d.ts +35 -0
  36. package/lib/select/SelectPicker.d.ts.map +1 -0
  37. package/lib/select/SelectPicker.js +64 -0
  38. package/lib/select/SelectPicker.js.map +1 -0
  39. package/lib/select/filter.d.ts +3 -0
  40. package/lib/select/filter.d.ts.map +1 -0
  41. package/lib/select/filter.js +13 -0
  42. package/lib/select/filter.js.map +1 -0
  43. package/lib/sheet/BottomSheet.d.ts +29 -0
  44. package/lib/sheet/BottomSheet.d.ts.map +1 -0
  45. package/lib/sheet/BottomSheet.js +89 -0
  46. package/lib/sheet/BottomSheet.js.map +1 -0
  47. package/lib/sheet/PickerSheet.d.ts +26 -0
  48. package/lib/sheet/PickerSheet.d.ts.map +1 -0
  49. package/lib/sheet/PickerSheet.js +27 -0
  50. package/lib/sheet/PickerSheet.js.map +1 -0
  51. package/lib/sheet/sheets.d.ts +48 -0
  52. package/lib/sheet/sheets.d.ts.map +1 -0
  53. package/lib/sheet/sheets.js +58 -0
  54. package/lib/sheet/sheets.js.map +1 -0
  55. package/lib/theme.d.ts +81 -0
  56. package/lib/theme.d.ts.map +1 -0
  57. package/lib/theme.js +96 -0
  58. package/lib/theme.js.map +1 -0
  59. package/lib/time/TimePicker.d.ts +41 -0
  60. package/lib/time/TimePicker.d.ts.map +1 -0
  61. package/lib/time/TimePicker.js +105 -0
  62. package/lib/time/TimePicker.js.map +1 -0
  63. package/lib/types.d.ts +9 -0
  64. package/lib/types.d.ts.map +1 -0
  65. package/lib/types.js +2 -0
  66. package/lib/types.js.map +1 -0
  67. package/lib/utils/OwnScroll.d.ts +10 -0
  68. package/lib/utils/OwnScroll.d.ts.map +1 -0
  69. package/lib/utils/OwnScroll.js +13 -0
  70. package/lib/utils/OwnScroll.js.map +1 -0
  71. package/lib/utils/date.d.ts +30 -0
  72. package/lib/utils/date.d.ts.map +1 -0
  73. package/lib/utils/date.js +115 -0
  74. package/lib/utils/date.js.map +1 -0
  75. package/lib/wheel/MultiWheelPicker.d.ts +26 -0
  76. package/lib/wheel/MultiWheelPicker.d.ts.map +1 -0
  77. package/lib/wheel/MultiWheelPicker.js +28 -0
  78. package/lib/wheel/MultiWheelPicker.js.map +1 -0
  79. package/lib/wheel/WheelPicker.d.ts +59 -0
  80. package/lib/wheel/WheelPicker.d.ts.map +1 -0
  81. package/lib/wheel/WheelPicker.js +257 -0
  82. package/lib/wheel/WheelPicker.js.map +1 -0
  83. package/lib/wheel/typeToSelect.d.ts +8 -0
  84. package/lib/wheel/typeToSelect.d.ts.map +1 -0
  85. package/lib/wheel/typeToSelect.js +10 -0
  86. package/lib/wheel/typeToSelect.js.map +1 -0
  87. package/package.json +81 -4
  88. package/src/date/Calendar.tsx +586 -0
  89. package/src/date/DatePicker.tsx +22 -0
  90. package/src/date/DateWheelPicker.tsx +149 -0
  91. package/src/emoji/EmojiPicker.tsx +410 -0
  92. package/src/emoji/data.ts +3 -0
  93. package/src/emoji/emojis.ts +127 -0
  94. package/src/index.ts +16 -0
  95. package/src/select/SelectList.tsx +266 -0
  96. package/src/select/SelectPicker.tsx +203 -0
  97. package/src/select/filter.ts +17 -0
  98. package/src/sheet/BottomSheet.tsx +199 -0
  99. package/src/sheet/PickerSheet.tsx +86 -0
  100. package/src/sheet/sheets.tsx +218 -0
  101. package/src/theme.tsx +160 -0
  102. package/src/time/TimePicker.tsx +199 -0
  103. package/src/types.ts +10 -0
  104. package/src/utils/OwnScroll.tsx +14 -0
  105. package/src/utils/date.ts +131 -0
  106. package/src/wheel/MultiWheelPicker.tsx +96 -0
  107. package/src/wheel/WheelPicker.tsx +534 -0
  108. package/src/wheel/typeToSelect.ts +12 -0
@@ -0,0 +1,266 @@
1
+ import React, { useCallback, useMemo, useState } from 'react';
2
+ import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
3
+ import type { StyleProp, ViewStyle } from 'react-native';
4
+ import { textStyle, usePickerTheme } from '../theme';
5
+ import type { PickerTheme } from '../theme';
6
+ import type { SlotStyles, ThemableProps } from '../types';
7
+ import { defaultFilter } from './filter';
8
+
9
+ export { defaultFilter };
10
+
11
+ export interface SelectOption<T> {
12
+ label: string;
13
+ value: T;
14
+ description?: string;
15
+ disabled?: boolean;
16
+ /** Leading element: icon, avatar, flag... */
17
+ icon?: React.ReactNode;
18
+ /** Options sharing a group are rendered under a section header */
19
+ group?: string;
20
+ /** Extra search terms */
21
+ keywords?: string[];
22
+ }
23
+
24
+ export type SelectSlot =
25
+ | 'container'
26
+ | 'search'
27
+ | 'searchInput'
28
+ | 'list'
29
+ | 'groupHeader'
30
+ | 'groupHeaderText'
31
+ | 'option'
32
+ | 'selectedOption'
33
+ | 'optionLabel'
34
+ | 'selectedOptionLabel'
35
+ | 'optionDescription'
36
+ | 'check'
37
+ | 'empty'
38
+ | 'emptyText';
39
+
40
+ export interface SelectRenderOptionInfo<T> {
41
+ option: SelectOption<T>;
42
+ selected: boolean;
43
+ disabled: boolean;
44
+ theme: PickerTheme;
45
+ }
46
+
47
+ /** Blocks inference from `onChange`, so `onChange={setState}` doesn't infer `T` as `SetStateAction<…>`. Works before TS 5.4's built-in NoInfer. */
48
+ type NoInferValue<T> = [T][T extends unknown ? 0 : never];
49
+
50
+ export type SelectValueProps<T> =
51
+ | { multiple?: false; value?: T | null; onChange?: (value: NoInferValue<T>, option: SelectOption<T>) => void }
52
+ | { multiple: true; value?: T[]; onChange?: (value: NoInferValue<T>[], options: SelectOption<T>[]) => void; maxSelected?: number };
53
+
54
+ export type SelectListBaseProps<T> = ThemableProps & {
55
+ options: ReadonlyArray<SelectOption<T>>;
56
+ /** Show a search field. Default: true when there are more than 8 options */
57
+ searchable?: boolean;
58
+ searchPlaceholder?: string;
59
+ /** Custom filter. Default: case/diacritics-insensitive match on label, description and keywords */
60
+ filterOption?: (option: SelectOption<T>, query: string) => boolean;
61
+ renderOption?: (info: SelectRenderOptionInfo<T>) => React.ReactNode;
62
+ renderGroupHeader?: (group: string, theme: PickerTheme) => React.ReactNode;
63
+ emptyText?: string;
64
+ /** Show check marks on selected rows. Default: true */
65
+ showCheckmarks?: boolean;
66
+ isEqual?: (a: T, b: T) => boolean;
67
+ /** Height of the list area. Default: grows with content */
68
+ listHeight?: number;
69
+ autoFocusSearch?: boolean;
70
+ style?: StyleProp<ViewStyle>;
71
+ styles?: SlotStyles<SelectSlot>;
72
+ testID?: string;
73
+ };
74
+
75
+ export type SelectListProps<T> = SelectListBaseProps<T> & SelectValueProps<T>;
76
+
77
+ type Row<T> = { type: 'header'; group: string } | { type: 'option'; option: SelectOption<T> };
78
+
79
+ export function SelectList<T>(props: SelectListProps<T>) {
80
+ const {
81
+ options,
82
+ searchable,
83
+ searchPlaceholder = 'Search',
84
+ filterOption = defaultFilter,
85
+ renderOption,
86
+ renderGroupHeader,
87
+ emptyText = 'No results',
88
+ showCheckmarks = true,
89
+ isEqual = Object.is,
90
+ listHeight,
91
+ autoFocusSearch,
92
+ style,
93
+ styles: slots,
94
+ theme: themeOverride,
95
+ testID,
96
+ } = props;
97
+ const theme = usePickerTheme(themeOverride);
98
+ const c = theme.colors;
99
+ const [query, setQuery] = useState('');
100
+ const showSearch = searchable ?? options.length > 8;
101
+
102
+ const selectedValues: T[] = props.multiple ? props.value ?? [] : props.value !== undefined && props.value !== null ? [props.value] : [];
103
+ const isSelected = useCallback((v: T) => selectedValues.some((s) => isEqual(s, v)), [isEqual, selectedValues]);
104
+
105
+ const rows = useMemo<Row<T>[]>(() => {
106
+ const filtered = query ? options.filter((o) => filterOption(o, query)) : options;
107
+ const out: Row<T>[] = [];
108
+ const order: string[] = [];
109
+ const byGroup = new Map<string, SelectOption<T>[]>();
110
+ for (const o of filtered) {
111
+ const g = o.group ?? '';
112
+ if (!byGroup.has(g)) {
113
+ byGroup.set(g, []);
114
+ order.push(g);
115
+ }
116
+ byGroup.get(g)!.push(o);
117
+ }
118
+ for (const g of order) {
119
+ if (g) out.push({ type: 'header', group: g });
120
+ for (const o of byGroup.get(g)!) out.push({ type: 'option', option: o });
121
+ }
122
+ return out;
123
+ }, [filterOption, options, query]);
124
+
125
+ const toggle = useCallback(
126
+ (o: SelectOption<T>) => {
127
+ if (!props.multiple) {
128
+ props.onChange?.(o.value, o);
129
+ return;
130
+ }
131
+ const cur = props.value ?? [];
132
+ const has = cur.some((v) => isEqual(v, o.value));
133
+ if (!has && props.maxSelected !== undefined && cur.length >= props.maxSelected) return;
134
+ const next = has ? cur.filter((v) => !isEqual(v, o.value)) : [...cur, o.value];
135
+ props.onChange?.(
136
+ next,
137
+ next.map((v) => options.find((x) => isEqual(x.value, v))!).filter(Boolean)
138
+ );
139
+ },
140
+ [isEqual, options, props]
141
+ );
142
+
143
+ const atMax = props.multiple && props.maxSelected !== undefined && (props.value?.length ?? 0) >= props.maxSelected;
144
+
145
+ const renderRow = ({ item }: { item: Row<T> }) => {
146
+ if (item.type === 'header') {
147
+ return renderGroupHeader ? (
148
+ <>{renderGroupHeader(item.group, theme)}</>
149
+ ) : (
150
+ <View style={[{ paddingHorizontal: theme.spacing.lg, paddingTop: theme.spacing.md, paddingBottom: theme.spacing.xs }, slots?.groupHeader]}>
151
+ <Text style={[textStyle(theme, 'xs', 'bold'), { color: c.textSecondary, textTransform: 'uppercase', letterSpacing: 0.6 }, slots?.groupHeaderText]}>
152
+ {item.group}
153
+ </Text>
154
+ </View>
155
+ );
156
+ }
157
+ const o = item.option;
158
+ const selected = isSelected(o.value);
159
+ const disabled = !!o.disabled || (!!atMax && !selected);
160
+ return (
161
+ <Pressable
162
+ onPress={disabled ? undefined : () => toggle(o)}
163
+ accessibilityRole={props.multiple ? 'checkbox' : 'button'}
164
+ accessibilityState={{ selected, checked: props.multiple ? selected : undefined, disabled }}
165
+ accessibilityLabel={o.label}
166
+ style={({ pressed }) => [
167
+ s.option,
168
+ {
169
+ paddingHorizontal: theme.spacing.lg,
170
+ marginHorizontal: theme.spacing.sm,
171
+ borderRadius: theme.radius.md,
172
+ backgroundColor: selected ? c.primaryMuted : pressed ? c.surface : 'transparent',
173
+ opacity: disabled ? 0.45 : 1,
174
+ },
175
+ slots?.option,
176
+ selected && slots?.selectedOption,
177
+ ]}
178
+ >
179
+ {renderOption ? (
180
+ renderOption({ option: o, selected, disabled, theme })
181
+ ) : (
182
+ <>
183
+ {o.icon ? <View style={{ marginEnd: theme.spacing.md }}>{o.icon}</View> : null}
184
+ <View style={s.flex}>
185
+ <Text
186
+ numberOfLines={1}
187
+ style={[textStyle(theme, 'md', selected ? 'medium' : 'regular'), slots?.optionLabel, selected && slots?.selectedOptionLabel]}
188
+ >
189
+ {o.label}
190
+ </Text>
191
+ {o.description ? (
192
+ <Text numberOfLines={2} style={[textStyle(theme, 'sm'), { color: c.textSecondary, marginTop: 2 }, slots?.optionDescription]}>
193
+ {o.description}
194
+ </Text>
195
+ ) : null}
196
+ </View>
197
+ {showCheckmarks && (props.multiple || selected) ? (
198
+ <Check selected={selected} multiple={!!props.multiple} theme={theme} style={slots?.check} />
199
+ ) : null}
200
+ </>
201
+ )}
202
+ </Pressable>
203
+ );
204
+ };
205
+
206
+ return (
207
+ <View style={[style, slots?.container]} testID={testID}>
208
+ {showSearch && (
209
+ <View style={[{ paddingHorizontal: theme.spacing.lg, paddingBottom: theme.spacing.sm }, slots?.search]}>
210
+ <TextInput
211
+ value={query}
212
+ onChangeText={setQuery}
213
+ placeholder={searchPlaceholder}
214
+ placeholderTextColor={c.textSecondary}
215
+ autoFocus={autoFocusSearch}
216
+ autoCorrect={false}
217
+ autoCapitalize="none"
218
+ clearButtonMode="while-editing"
219
+ accessibilityLabel={searchPlaceholder}
220
+ style={[
221
+ textStyle(theme, 'md'),
222
+ { backgroundColor: c.surface, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, height: 42 },
223
+ slots?.searchInput,
224
+ ]}
225
+ />
226
+ </View>
227
+ )}
228
+ <FlatList
229
+ data={rows}
230
+ keyExtractor={(r, i) => (r.type === 'header' ? `h:${r.group}` : `o:${i}:${r.option.label}`)}
231
+ renderItem={renderRow}
232
+ keyboardShouldPersistTaps="handled"
233
+ style={[listHeight !== undefined ? { height: listHeight } : null, slots?.list]}
234
+ ListEmptyComponent={
235
+ <View style={[{ padding: theme.spacing.xl, alignItems: 'center' }, slots?.empty]}>
236
+ <Text style={[textStyle(theme, 'md'), { color: c.textSecondary }, slots?.emptyText]}>{emptyText}</Text>
237
+ </View>
238
+ }
239
+ />
240
+ </View>
241
+ );
242
+ }
243
+
244
+ function Check({ selected, multiple, theme, style }: { selected: boolean; multiple: boolean; theme: PickerTheme; style?: StyleProp<any> }) {
245
+ const c = theme.colors;
246
+ if (!multiple) {
247
+ return <Text style={[textStyle(theme, 'md', 'bold'), { color: c.primary, marginStart: theme.spacing.md }, style]}>✓</Text>;
248
+ }
249
+ return (
250
+ <View
251
+ style={[
252
+ s.box,
253
+ { marginStart: theme.spacing.md, borderRadius: 6, borderColor: selected ? c.primary : c.border, backgroundColor: selected ? c.primary : 'transparent' },
254
+ style,
255
+ ]}
256
+ >
257
+ {selected ? <Text style={{ color: c.onPrimary, fontSize: 13, fontWeight: '700', lineHeight: 16 }}>✓</Text> : null}
258
+ </View>
259
+ );
260
+ }
261
+
262
+ const s = StyleSheet.create({
263
+ option: { flexDirection: 'row', alignItems: 'center', minHeight: 48, paddingVertical: 10, marginVertical: 1 },
264
+ flex: { flex: 1 },
265
+ box: { width: 22, height: 22, borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
266
+ });
@@ -0,0 +1,203 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import type { StyleProp, ViewStyle } from 'react-native';
4
+ import { PickerSheet } from '../sheet/PickerSheet';
5
+ import type { SheetChromeProps } from '../sheet/PickerSheet';
6
+ import { textStyle, usePickerTheme } from '../theme';
7
+ import type { SlotStyles } from '../types';
8
+ import { SelectList } from './SelectList';
9
+ import type { SelectListBaseProps, SelectOption, SelectSlot, SelectValueProps } from './SelectList';
10
+
11
+ export type SelectTriggerSlot = 'trigger' | 'triggerText' | 'placeholder' | 'label' | 'chip' | 'chipText' | 'chevron' | 'clear';
12
+
13
+ export interface SelectTriggerInfo<T> {
14
+ open: () => void;
15
+ selected: SelectOption<T>[];
16
+ disabled: boolean;
17
+ }
18
+
19
+ export type SelectPickerProps<T> = Omit<SelectListBaseProps<T>, 'style' | 'styles'> &
20
+ SelectValueProps<T> & {
21
+ placeholder?: string;
22
+ /** Label rendered above the field */
23
+ label?: string;
24
+ disabled?: boolean;
25
+ /** Show a clear (×) button when something is selected */
26
+ clearable?: boolean;
27
+ onClear?: () => void;
28
+ /** Show selected values as chips in multiple mode. Default: true */
29
+ chips?: boolean;
30
+ /** Single mode: close the sheet on select. Default: true */
31
+ closeOnSelect?: boolean;
32
+ /** Custom trigger: render anything and call `open()` */
33
+ renderTrigger?: (info: SelectTriggerInfo<T>) => React.ReactNode;
34
+ /** Sheet title. Default: `label` */
35
+ sheetTitle?: string;
36
+ sheet?: Partial<Omit<SheetChromeProps, 'visible' | 'onClose' | 'theme'>>;
37
+ onOpen?: () => void;
38
+ onClose?: () => void;
39
+ style?: StyleProp<ViewStyle>;
40
+ styles?: SlotStyles<SelectSlot | SelectTriggerSlot>;
41
+ };
42
+
43
+ export function SelectPicker<T>(props: SelectPickerProps<T>) {
44
+ const {
45
+ placeholder = 'Select…',
46
+ label,
47
+ disabled = false,
48
+ clearable,
49
+ onClear,
50
+ chips = true,
51
+ closeOnSelect = true,
52
+ renderTrigger,
53
+ sheetTitle,
54
+ sheet,
55
+ onOpen,
56
+ onClose,
57
+ style,
58
+ styles: slots,
59
+ theme: themeOverride,
60
+ options,
61
+ isEqual = Object.is,
62
+ ...listProps
63
+ } = props;
64
+ const theme = usePickerTheme(themeOverride);
65
+ const c = theme.colors;
66
+ const [open, setOpen] = useState(false);
67
+
68
+ const selected = useMemo(() => {
69
+ const vals: T[] = props.multiple ? props.value ?? [] : props.value !== undefined && props.value !== null ? [props.value] : [];
70
+ return vals.map((v) => options.find((o) => isEqual(o.value, v))).filter((o): o is SelectOption<T> => !!o);
71
+ }, [isEqual, options, props.multiple, props.value]);
72
+
73
+ const show = () => {
74
+ if (disabled) return;
75
+ setOpen(true);
76
+ onOpen?.();
77
+ };
78
+ const hide = () => {
79
+ setOpen(false);
80
+ onClose?.();
81
+ };
82
+
83
+ const onChange = props.multiple
84
+ ? props.onChange
85
+ : (v: T, o: SelectOption<T>) => {
86
+ (props.onChange as ((v: T, o: SelectOption<T>) => void) | undefined)?.(v, o);
87
+ if (closeOnSelect) hide();
88
+ };
89
+
90
+ const removeChip = (o: SelectOption<T>) => {
91
+ if (!props.multiple) return;
92
+ const next = (props.value ?? []).filter((v) => !isEqual(v, o.value));
93
+ props.onChange?.(next, selected.filter((s) => !isEqual(s.value, o.value)));
94
+ };
95
+
96
+ const [pressed, setPressed] = useState(false);
97
+ const trigger = renderTrigger ? (
98
+ renderTrigger({ open: show, selected, disabled })
99
+ ) : (
100
+ <View
101
+ style={[
102
+ s.trigger,
103
+ {
104
+ backgroundColor: c.background,
105
+ borderColor: open ? c.primary : c.border,
106
+ borderRadius: theme.radius.md,
107
+ paddingHorizontal: theme.spacing.md,
108
+ opacity: disabled ? 0.5 : pressed ? 0.8 : 1,
109
+ },
110
+ slots?.trigger,
111
+ ]}
112
+ >
113
+ {/* Full-size press target behind the content, so chips and clear stay sibling buttons */}
114
+ <Pressable
115
+ style={StyleSheet.absoluteFill}
116
+ onPress={show}
117
+ onPressIn={() => setPressed(true)}
118
+ onPressOut={() => setPressed(false)}
119
+ disabled={disabled}
120
+ accessibilityRole="button"
121
+ accessibilityLabel={label ?? placeholder}
122
+ accessibilityHint={selected.map((o) => o.label).join(', ') || undefined}
123
+ accessibilityState={{ disabled, expanded: open }}
124
+ />
125
+ <View style={s.triggerContent} pointerEvents="box-none">
126
+ {selected.length === 0 ? (
127
+ <Text pointerEvents="none" style={[textStyle(theme, 'md'), { color: c.textSecondary }, slots?.placeholder]} numberOfLines={1}>
128
+ {placeholder}
129
+ </Text>
130
+ ) : props.multiple && chips ? (
131
+ <View style={s.chips} pointerEvents="box-none">
132
+ {selected.map((o) => (
133
+ <Pressable
134
+ key={String(o.label)}
135
+ onPress={() => removeChip(o)}
136
+ disabled={disabled}
137
+ accessibilityRole="button"
138
+ accessibilityLabel={`Remove ${o.label}`}
139
+ style={[s.chip, { backgroundColor: c.primaryMuted, borderRadius: theme.radius.full }, slots?.chip]}
140
+ >
141
+ <Text style={[textStyle(theme, 'sm', 'medium'), { color: c.primary }, slots?.chipText]}>{o.label} ×</Text>
142
+ </Pressable>
143
+ ))}
144
+ </View>
145
+ ) : (
146
+ <View style={s.row} pointerEvents="none">
147
+ {!props.multiple && selected[0]?.icon ? <View style={{ marginEnd: 8 }}>{selected[0].icon}</View> : null}
148
+ <Text style={[textStyle(theme, 'md'), slots?.triggerText]} numberOfLines={1}>
149
+ {selected.map((o) => o.label).join(', ')}
150
+ </Text>
151
+ </View>
152
+ )}
153
+ </View>
154
+ {clearable && selected.length > 0 && !disabled ? (
155
+ <Pressable
156
+ hitSlop={10}
157
+ onPress={() => {
158
+ if (props.multiple) props.onChange?.([], []);
159
+ onClear?.();
160
+ }}
161
+ accessibilityRole="button"
162
+ accessibilityLabel="Clear"
163
+ >
164
+ <Text style={[textStyle(theme, 'lg'), { color: c.textSecondary, paddingHorizontal: 6 }, slots?.clear]}>×</Text>
165
+ </Pressable>
166
+ ) : null}
167
+ <Text pointerEvents="none" style={[textStyle(theme, 'sm'), { color: c.textSecondary, marginStart: 4 }, slots?.chevron]}>▾</Text>
168
+ </View>
169
+ );
170
+
171
+ return (
172
+ <View style={style}>
173
+ {label ? <Text style={[textStyle(theme, 'sm', 'medium'), { color: c.textSecondary, marginBottom: 6 }, slots?.label]}>{label}</Text> : null}
174
+ {trigger}
175
+ <PickerSheet
176
+ visible={open}
177
+ onClose={hide}
178
+ title={sheetTitle ?? label}
179
+ theme={themeOverride}
180
+ showCancel={false}
181
+ onConfirm={props.multiple || !closeOnSelect ? hide : undefined}
182
+ {...sheet}
183
+ >
184
+ <SelectList<T>
185
+ {...(listProps as SelectListBaseProps<T>)}
186
+ {...({ multiple: props.multiple, value: props.value, onChange, maxSelected: (props as { maxSelected?: number }).maxSelected } as SelectValueProps<T>)}
187
+ options={options}
188
+ isEqual={isEqual}
189
+ theme={themeOverride}
190
+ styles={slots}
191
+ />
192
+ </PickerSheet>
193
+ </View>
194
+ );
195
+ }
196
+
197
+ const s = StyleSheet.create({
198
+ trigger: { flexDirection: 'row', alignItems: 'center', minHeight: 48, borderWidth: 1, paddingVertical: 6 },
199
+ triggerContent: { flex: 1 },
200
+ row: { flexDirection: 'row', alignItems: 'center' },
201
+ chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
202
+ chip: { paddingHorizontal: 10, paddingVertical: 4 },
203
+ });
@@ -0,0 +1,17 @@
1
+ import type { SelectOption } from './SelectList';
2
+
3
+ /** Lowercase and strip Latin diacritics and Hebrew niqqud. */
4
+ const fold = (s: string) =>
5
+ s
6
+ .toLowerCase()
7
+ .normalize('NFD')
8
+ .replace(/[̀-֑ͯ-ׇ]/g, '');
9
+
10
+ export function defaultFilter<T>(o: SelectOption<T>, q: string): boolean {
11
+ const hay = fold([o.label, o.description ?? '', ...(o.keywords ?? [])].join(' '));
12
+ return fold(q)
13
+ .split(/\s+/)
14
+ .filter(Boolean)
15
+ .every((term) => hay.includes(term));
16
+ }
17
+
@@ -0,0 +1,199 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ KeyboardAvoidingView,
6
+ Modal,
7
+ PanResponder,
8
+ Platform,
9
+ Pressable,
10
+ StyleSheet,
11
+ Text,
12
+ View,
13
+ useWindowDimensions,
14
+ } from 'react-native';
15
+ import { textStyle, usePickerTheme } from '../theme';
16
+ import type { SlotStyles, ThemableProps } from '../types';
17
+
18
+ export type BottomSheetSlot = 'backdrop' | 'sheet' | 'handle' | 'header' | 'title' | 'content' | 'footer';
19
+
20
+ export interface BottomSheetProps extends ThemableProps {
21
+ visible: boolean;
22
+ onClose: () => void;
23
+ children: React.ReactNode;
24
+ title?: React.ReactNode;
25
+ /** Rendered on the right side of the header (e.g. a Done button) */
26
+ headerRight?: React.ReactNode;
27
+ headerLeft?: React.ReactNode;
28
+ footer?: React.ReactNode;
29
+ /** Drag the sheet down to dismiss. Default: true */
30
+ enableSwipeToClose?: boolean;
31
+ /** Tap the backdrop to dismiss. Default: true */
32
+ closeOnBackdropPress?: boolean;
33
+ /** Max sheet height as a fraction of the window. Default: 0.9 */
34
+ maxHeightRatio?: number;
35
+ /** Fixed sheet height (px or fraction of the window when <= 1) */
36
+ height?: number;
37
+ /** Extra bottom padding for home indicators. Default: 24 on iOS, 12 elsewhere */
38
+ bottomInset?: number;
39
+ styles?: SlotStyles<BottomSheetSlot>;
40
+ onOpened?: () => void;
41
+ onClosed?: () => void;
42
+ testID?: string;
43
+ }
44
+
45
+ export function BottomSheet({
46
+ visible,
47
+ onClose,
48
+ children,
49
+ title,
50
+ headerLeft,
51
+ headerRight,
52
+ footer,
53
+ enableSwipeToClose = true,
54
+ closeOnBackdropPress = true,
55
+ maxHeightRatio = 0.9,
56
+ height,
57
+ bottomInset = Platform.OS === 'ios' ? 24 : 12,
58
+ styles: slots,
59
+ theme: themeOverride,
60
+ onOpened,
61
+ onClosed,
62
+ testID,
63
+ }: BottomSheetProps) {
64
+ const theme = usePickerTheme(themeOverride);
65
+ const { height: windowHeight } = useWindowDimensions();
66
+ const [mounted, setMounted] = useState(visible);
67
+ const progress = useRef(new Animated.Value(0)).current; // 0 hidden, 1 shown
68
+ const drag = useRef(new Animated.Value(0)).current;
69
+ const sheetHeight = useRef(windowHeight);
70
+ const native = Platform.OS !== 'web';
71
+
72
+ useEffect(() => {
73
+ if (visible) {
74
+ setMounted(true);
75
+ drag.setValue(0);
76
+ Animated.timing(progress, {
77
+ toValue: 1,
78
+ duration: theme.animation.duration + 60,
79
+ easing: Easing.out(Easing.cubic),
80
+ useNativeDriver: native,
81
+ }).start(({ finished }) => finished && onOpened?.());
82
+ } else if (mounted) {
83
+ Animated.timing(progress, {
84
+ toValue: 0,
85
+ duration: theme.animation.duration,
86
+ easing: Easing.in(Easing.cubic),
87
+ useNativeDriver: native,
88
+ }).start(({ finished }) => {
89
+ if (finished) {
90
+ setMounted(false);
91
+ onClosed?.();
92
+ }
93
+ });
94
+ }
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ }, [visible]);
97
+
98
+ const closeRef = useRef(onClose);
99
+ closeRef.current = onClose;
100
+
101
+ const pan = useRef(
102
+ PanResponder.create({
103
+ onMoveShouldSetPanResponder: (_, g) => enableSwipeToClose && g.dy > 6 && Math.abs(g.dy) > Math.abs(g.dx),
104
+ onPanResponderMove: (_, g) => drag.setValue(Math.max(0, g.dy)),
105
+ onPanResponderRelease: (_, g) => {
106
+ if (g.dy > Math.min(140, sheetHeight.current * 0.3) || g.vy > 0.9) {
107
+ closeRef.current();
108
+ } else {
109
+ Animated.spring(drag, { toValue: 0, useNativeDriver: native, bounciness: 4 }).start();
110
+ }
111
+ },
112
+ onPanResponderTerminate: () => {
113
+ Animated.spring(drag, { toValue: 0, useNativeDriver: native }).start();
114
+ },
115
+ })
116
+ ).current;
117
+
118
+ const onLayout = useCallback((e: { nativeEvent: { layout: { height: number } } }) => {
119
+ sheetHeight.current = e.nativeEvent.layout.height;
120
+ }, []);
121
+
122
+ if (!mounted) return null;
123
+
124
+ const translateY = Animated.add(
125
+ progress.interpolate({ inputRange: [0, 1], outputRange: [windowHeight, 0] }),
126
+ drag
127
+ );
128
+ const resolvedHeight = height === undefined ? undefined : height <= 1 ? windowHeight * height : height;
129
+
130
+ return (
131
+ <Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={onClose} testID={testID}>
132
+ <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={StyleSheet.absoluteFill}>
133
+ <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: theme.colors.backdrop, opacity: progress }, slots?.backdrop]}>
134
+ <Pressable
135
+ style={StyleSheet.absoluteFill}
136
+ onPress={closeOnBackdropPress ? onClose : undefined}
137
+ accessibilityRole="button"
138
+ accessibilityLabel="Close"
139
+ />
140
+ </Animated.View>
141
+ <View style={s.container} pointerEvents="box-none">
142
+ <Animated.View
143
+ onLayout={onLayout}
144
+ style={[
145
+ s.sheet,
146
+ {
147
+ backgroundColor: theme.colors.sheet,
148
+ borderTopLeftRadius: theme.radius.sheet,
149
+ borderTopRightRadius: theme.radius.sheet,
150
+ maxHeight: windowHeight * maxHeightRatio,
151
+ height: resolvedHeight,
152
+ paddingBottom: bottomInset,
153
+ transform: [{ translateY }],
154
+ },
155
+ slots?.sheet,
156
+ ]}
157
+ accessibilityViewIsModal
158
+ >
159
+ <View {...pan.panHandlers}>
160
+ <View style={s.handleArea}>
161
+ <View style={[s.handle, { backgroundColor: theme.colors.handle }, slots?.handle]} />
162
+ </View>
163
+ {(title || headerLeft || headerRight) && (
164
+ <View style={[s.header, { paddingHorizontal: theme.spacing.lg }, slots?.header]}>
165
+ <View style={s.headerSide}>{headerLeft}</View>
166
+ <View style={s.headerCenter}>
167
+ {typeof title === 'string' ? (
168
+ <Text style={[textStyle(theme, 'md', 'bold'), slots?.title]} numberOfLines={1} accessibilityRole="header">
169
+ {title}
170
+ </Text>
171
+ ) : (
172
+ title
173
+ )}
174
+ </View>
175
+ <View style={[s.headerSide, s.headerRight]}>{headerRight}</View>
176
+ </View>
177
+ )}
178
+ </View>
179
+ <View style={[resolvedHeight ? s.flex : s.shrink, slots?.content]}>{children}</View>
180
+ {footer ? <View style={[{ paddingHorizontal: theme.spacing.lg, paddingTop: theme.spacing.md }, slots?.footer]}>{footer}</View> : null}
181
+ </Animated.View>
182
+ </View>
183
+ </KeyboardAvoidingView>
184
+ </Modal>
185
+ );
186
+ }
187
+
188
+ const s = StyleSheet.create({
189
+ container: { flex: 1, justifyContent: 'flex-end' },
190
+ sheet: { width: '100%', alignSelf: 'center', maxWidth: 640, overflow: 'hidden' },
191
+ handleArea: { alignItems: 'center', paddingTop: 8, paddingBottom: 6 },
192
+ handle: { width: 40, height: 5, borderRadius: 3 },
193
+ header: { flexDirection: 'row', alignItems: 'center', minHeight: 44, paddingBottom: 4 },
194
+ headerSide: { flex: 1, flexDirection: 'row', alignItems: 'center' },
195
+ headerRight: { justifyContent: 'flex-end' },
196
+ headerCenter: { flex: 2, alignItems: 'center' },
197
+ flex: { flex: 1 },
198
+ shrink: { flexShrink: 1 },
199
+ });