@lotics/ui 1.27.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/package.json +10 -11
- package/src/card.tsx +7 -23
- package/src/card_select_item.tsx +52 -0
- package/src/column_filter.tsx +0 -1
- package/src/combobox.tsx +365 -109
- package/src/legend_item.tsx +2 -2
- package/src/picker.tsx +2 -17
- package/src/picker_menu.tsx +84 -121
- package/src/switcher.tsx +4 -3
- package/src/chart_area.tsx +0 -105
- package/src/chart_bar.tsx +0 -154
- package/src/chart_internals.tsx +0 -43
- package/src/custom_option.test.ts +0 -50
- package/src/custom_option.ts +0 -30
- package/src/search_select.tsx +0 -348
- package/src/select_item.tsx +0 -55
package/src/combobox.tsx
CHANGED
|
@@ -1,164 +1,420 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
View,
|
|
3
|
+
ScrollView,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
Pressable,
|
|
6
|
+
type StyleProp,
|
|
7
|
+
type ViewStyle,
|
|
8
|
+
type TextInput as RNTextInput,
|
|
9
|
+
} from "react-native";
|
|
10
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
|
3
11
|
import { colors } from "./colors";
|
|
4
12
|
import { Text } from "./text";
|
|
5
13
|
import { Icon } from "./icon";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
14
|
+
import { TextInputField } from "./text_input_field";
|
|
15
|
+
import { MenuButton } from "./menu_button";
|
|
16
|
+
import { ActivityIndicator } from "./activity_indicator";
|
|
17
|
+
import { Popover, PopoverContent } from "./popover";
|
|
18
|
+
import type { PickerOption } from "./picker";
|
|
9
19
|
import { useDebouncedCallback } from "./use_debounced_callback";
|
|
20
|
+
import { useListKeyboardNav } from "./use_list_keyboard_nav";
|
|
10
21
|
|
|
11
|
-
export interface ComboboxProps<T extends string = string> {
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
value
|
|
16
|
-
onValueChange: (option: PickerOption<T>) => void;
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
19
|
-
options: PickerOption<T>[];
|
|
20
|
-
/** Called (debounced) when the search text changes. Provide it to drive
|
|
21
|
-
* server-side search — the consumer re-queries and updates `options`. Omit to
|
|
22
|
-
* filter the given `options` locally (static list). */
|
|
22
|
+
export interface ComboboxProps<T extends string = string, D = unknown> {
|
|
23
|
+
/** Result options for the current query. With `onSearchChange` the consumer
|
|
24
|
+
* refreshes these server-side; without it, they're filtered locally by label. */
|
|
25
|
+
options: PickerOption<T, D>[];
|
|
26
|
+
/** Fires when the user picks an option (or a custom value, if `allowCustom`). */
|
|
27
|
+
onValueChange: (option: PickerOption<T, D>) => void;
|
|
28
|
+
/** Server-side search: debounced, fires as the user types. Omit for local
|
|
29
|
+
* label filtering of `options`. */
|
|
23
30
|
onSearchChange?: (query: string) => void;
|
|
24
|
-
/**
|
|
31
|
+
/** Multi-select: selected options render as dismissible chips inside the input;
|
|
32
|
+
* the input stays a search field. Pair with `value` (the selected array) and
|
|
33
|
+
* `onRemove`. */
|
|
34
|
+
multi?: boolean;
|
|
35
|
+
/** Selected option(s). Single → the option to reflect in the input (or null).
|
|
36
|
+
* Multi → the array of selected options rendered as chips. */
|
|
37
|
+
value?: PickerOption<T, D> | PickerOption<T, D>[] | null;
|
|
38
|
+
/** Multi: remove a selected chip. */
|
|
39
|
+
onRemove?: (option: PickerOption<T, D>) => void;
|
|
40
|
+
/** Single only: reflect the picked label in the input (classic autocomplete).
|
|
41
|
+
* Default true. Set false to keep the input a pure search and render the
|
|
42
|
+
* selection yourself (e.g. in a list below). */
|
|
43
|
+
reflectSelection?: boolean;
|
|
44
|
+
/** Custom rendering of an option row in the dropdown. The input itself is a
|
|
45
|
+
* plain text field (it can only show the label string) — to display a
|
|
46
|
+
* selection with custom rendering, use `reflectSelection={false}` and render
|
|
47
|
+
* the picked option(s) yourself below. */
|
|
48
|
+
renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
|
|
49
|
+
/** Row subtitle pulled from the option (single-line, under the title). */
|
|
50
|
+
getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
|
|
51
|
+
/** Accept free text: when the query matches no option, offer it as a custom
|
|
52
|
+
* value — `onValueChange` receives `{ value: query, label: query }`. */
|
|
53
|
+
allowCustom?: boolean;
|
|
54
|
+
/** Label for the free-entry row (default: `Add "<query>"`). Return null to
|
|
55
|
+
* suppress it for a given query. */
|
|
56
|
+
customOptionLabel?: (query: string) => string | null;
|
|
57
|
+
/** Shown — under a heading — when the input is focused but empty. */
|
|
58
|
+
recentOptions?: PickerOption<T, D>[];
|
|
25
59
|
loading?: boolean;
|
|
26
|
-
/** Debounce applied to `onSearchChange`, in ms. Default 200. */
|
|
27
60
|
searchDebounceMs?: number;
|
|
28
61
|
placeholder?: string;
|
|
29
|
-
|
|
30
|
-
/** Shown when there are no results and not loading. Default: "No results". */
|
|
62
|
+
recentsLabel?: string;
|
|
31
63
|
emptyText?: string;
|
|
32
|
-
|
|
33
|
-
* custom value. `onValueChange` then receives `{ value: query, label: query }`.
|
|
34
|
-
* The consumer decides what an unknown value means (e.g. a manually-typed id). */
|
|
35
|
-
allowCustom?: boolean;
|
|
36
|
-
/** Label for the free-entry row (default: the raw query). Return `null` to
|
|
37
|
-
* suppress it for a given query (e.g. still incomplete/invalid). */
|
|
38
|
-
customOptionLabel?: (query: string) => string | null;
|
|
64
|
+
accessibilityLabel?: string;
|
|
39
65
|
disabled?: boolean;
|
|
40
66
|
autoFocus?: boolean;
|
|
41
67
|
testID?: string;
|
|
42
68
|
style?: StyleProp<ViewStyle>;
|
|
43
69
|
}
|
|
44
70
|
|
|
71
|
+
const OPTION_HEIGHT = 52;
|
|
72
|
+
const CUSTOM_VALUE = "__combobox_custom__";
|
|
73
|
+
|
|
45
74
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
75
|
+
* The ARIA combobox: an editable input whose typing drives a (debounced) search,
|
|
76
|
+
* with results in a Popover listbox below. The input owns the keyboard (↑/↓ move
|
|
77
|
+
* the active row, Enter selects, Esc closes) — not a button trigger.
|
|
78
|
+
*
|
|
79
|
+
* Selection display is flexible:
|
|
80
|
+
* - single (default): the picked label reflects into the input (`reflectSelection`),
|
|
81
|
+
* or set `reflectSelection={false}` to keep the input a pure search and render
|
|
82
|
+
* the selection below yourself;
|
|
83
|
+
* - `multi`: selected options render as dismissible chips inside the input and
|
|
84
|
+
* the input stays a search field.
|
|
49
85
|
*
|
|
50
|
-
*
|
|
51
|
-
* and refreshes `options`) — the right pattern for large/growing datasets where
|
|
52
|
-
* prefetching the whole table to the client is wrong. Omit it to filter the
|
|
53
|
-
* given `options` locally, so the same component also serves static lists.
|
|
86
|
+
* For a "select from a known list" control (no search box, typeahead), use `Picker`.
|
|
54
87
|
*/
|
|
55
|
-
export function Combobox<T extends string>(props: ComboboxProps<T>) {
|
|
88
|
+
export function Combobox<T extends string = string, D = unknown>(props: ComboboxProps<T, D>) {
|
|
56
89
|
const {
|
|
57
|
-
value,
|
|
58
|
-
onValueChange,
|
|
59
90
|
options,
|
|
91
|
+
onValueChange,
|
|
60
92
|
onSearchChange,
|
|
61
|
-
|
|
93
|
+
multi = false,
|
|
94
|
+
value,
|
|
95
|
+
onRemove,
|
|
96
|
+
reflectSelection = true,
|
|
97
|
+
renderOptionContent,
|
|
98
|
+
getOptionDescription,
|
|
99
|
+
allowCustom = false,
|
|
100
|
+
customOptionLabel,
|
|
101
|
+
recentOptions,
|
|
102
|
+
loading = false,
|
|
62
103
|
searchDebounceMs = 200,
|
|
63
104
|
placeholder,
|
|
64
|
-
|
|
65
|
-
emptyText,
|
|
66
|
-
|
|
67
|
-
customOptionLabel,
|
|
105
|
+
recentsLabel = "Recent",
|
|
106
|
+
emptyText = "No results",
|
|
107
|
+
accessibilityLabel = "Results",
|
|
68
108
|
disabled = false,
|
|
69
109
|
autoFocus = false,
|
|
70
110
|
testID,
|
|
71
111
|
style,
|
|
72
112
|
} = props;
|
|
73
113
|
|
|
114
|
+
const chips = multi && Array.isArray(value) ? value : [];
|
|
115
|
+
const single = !multi && value && !Array.isArray(value) ? value : null;
|
|
116
|
+
|
|
117
|
+
const [query, setQuery] = useState(
|
|
118
|
+
!multi && reflectSelection && single ? (single.label ?? single.value) : "",
|
|
119
|
+
);
|
|
74
120
|
const [open, setOpen] = useState(autoFocus);
|
|
75
|
-
const
|
|
121
|
+
const triggerRef = useRef<View>(null);
|
|
122
|
+
const inputRef = useRef<RNTextInput>(null);
|
|
123
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
124
|
+
const baseId = useId();
|
|
125
|
+
const listboxId = `${baseId}-listbox`;
|
|
126
|
+
const optionId = (i: number) => `${baseId}-option-${i}`;
|
|
76
127
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
}, [disabled]);
|
|
128
|
+
const searching = query.trim().length > 0;
|
|
129
|
+
const isServer = onSearchChange !== undefined;
|
|
80
130
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
131
|
+
// Local filtering when not server-driven. Reflected single value shouldn't
|
|
132
|
+
// filter the list to itself, so only filter once the query diverges.
|
|
133
|
+
const filtered = useMemo(() => {
|
|
134
|
+
if (isServer || !searching) return options;
|
|
135
|
+
if (!multi && reflectSelection && single && query === (single.label ?? single.value)) {
|
|
136
|
+
return options;
|
|
137
|
+
}
|
|
138
|
+
const q = query.toLowerCase();
|
|
139
|
+
return options.filter((o) => (o.label ?? o.value).toLowerCase().includes(q));
|
|
140
|
+
}, [isServer, searching, options, multi, reflectSelection, single, query]);
|
|
141
|
+
|
|
142
|
+
// Free-entry row, appended when the query matches no option label exactly.
|
|
143
|
+
const customRow = useMemo((): PickerOption<T, D> | null => {
|
|
144
|
+
if (!allowCustom || !searching) return null;
|
|
145
|
+
const q = query.trim();
|
|
146
|
+
const exact = options.some((o) => (o.label ?? o.value).toLowerCase() === q.toLowerCase());
|
|
147
|
+
if (exact) return null;
|
|
148
|
+
const label = customOptionLabel ? customOptionLabel(q) : `Add "${q}"`;
|
|
149
|
+
if (label === null) return null;
|
|
150
|
+
return { value: q as T, label, testID: CUSTOM_VALUE };
|
|
151
|
+
}, [allowCustom, searching, query, options, customOptionLabel]);
|
|
152
|
+
|
|
153
|
+
const list = useMemo(() => {
|
|
154
|
+
const base = searching ? filtered : (recentOptions ?? []);
|
|
155
|
+
return customRow ? [...base, customRow] : base;
|
|
156
|
+
}, [searching, filtered, recentOptions, customRow]);
|
|
157
|
+
|
|
158
|
+
const debouncedSearch = useDebouncedCallback(onSearchChange ?? (() => {}), searchDebounceMs);
|
|
159
|
+
|
|
160
|
+
const scrollToIndex = useCallback((index: number) => {
|
|
161
|
+
scrollRef.current?.scrollTo({ y: Math.max(0, index * OPTION_HEIGHT - 80), animated: false });
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
const commit = useCallback(
|
|
165
|
+
(opt: PickerOption<T, D>) => {
|
|
166
|
+
onValueChange(opt);
|
|
167
|
+
if (isServer) {
|
|
168
|
+
debouncedSearch.cancel();
|
|
169
|
+
onSearchChange?.("");
|
|
170
|
+
}
|
|
171
|
+
if (multi) {
|
|
172
|
+
// Input stays a search field; the chip is rendered from `value`.
|
|
173
|
+
setQuery("");
|
|
174
|
+
} else if (reflectSelection) {
|
|
175
|
+
setQuery(opt.label ?? opt.value);
|
|
176
|
+
} else {
|
|
177
|
+
setQuery("");
|
|
178
|
+
}
|
|
86
179
|
setOpen(false);
|
|
180
|
+
inputRef.current?.focus();
|
|
87
181
|
},
|
|
88
|
-
[
|
|
182
|
+
[onValueChange, isServer, debouncedSearch, onSearchChange, multi, reflectSelection],
|
|
89
183
|
);
|
|
90
184
|
|
|
185
|
+
const handleSelect = useCallback(
|
|
186
|
+
(index: number) => {
|
|
187
|
+
const opt = list[index];
|
|
188
|
+
if (!opt || opt.disabled) return;
|
|
189
|
+
// The custom row displays `Add "x"` but emits the raw query as both value
|
|
190
|
+
// and label (so it reflects/round-trips as the typed text, not "Add …").
|
|
191
|
+
const emitted =
|
|
192
|
+
opt.testID === CUSTOM_VALUE
|
|
193
|
+
? ({ value: opt.value, label: opt.value } as PickerOption<T, D>)
|
|
194
|
+
: opt;
|
|
195
|
+
commit(emitted);
|
|
196
|
+
},
|
|
197
|
+
[list, commit],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const { activeIndex, setActiveIndex, handleKey } = useListKeyboardNav({
|
|
201
|
+
count: list.length,
|
|
202
|
+
isDisabled: (i) => list[i]?.disabled ?? false,
|
|
203
|
+
onSelect: handleSelect,
|
|
204
|
+
onClose: () => setOpen(false),
|
|
205
|
+
onActiveChange: scrollToIndex,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const firstValue = list[0]?.value;
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
setActiveIndex(0);
|
|
211
|
+
}, [searching, list.length, firstValue, setActiveIndex]);
|
|
212
|
+
|
|
213
|
+
const handleChangeText = useCallback(
|
|
214
|
+
(text: string) => {
|
|
215
|
+
setQuery(text);
|
|
216
|
+
if (!disabled) setOpen(true);
|
|
217
|
+
if (isServer) debouncedSearch(text);
|
|
218
|
+
},
|
|
219
|
+
[disabled, isServer, debouncedSearch],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const handleKeyPress = useCallback(
|
|
223
|
+
(e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
|
|
224
|
+
const key = e.nativeEvent.key;
|
|
225
|
+
// Backspace on an empty query removes the last chip (token-input idiom).
|
|
226
|
+
if (multi && key === "Backspace" && query.length === 0 && chips.length > 0) {
|
|
227
|
+
onRemove?.(chips[chips.length - 1]);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!open && (key === "ArrowDown" || key === "ArrowUp")) setOpen(true);
|
|
231
|
+
if (handleKey(key)) e.preventDefault();
|
|
232
|
+
},
|
|
233
|
+
[multi, query, chips, open, handleKey, onRemove],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const showList = open && !disabled && (searching || list.length > 0);
|
|
237
|
+
const activeOptionId =
|
|
238
|
+
activeIndex >= 0 && activeIndex < list.length ? optionId(activeIndex) : undefined;
|
|
239
|
+
|
|
91
240
|
return (
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
241
|
+
<View style={style}>
|
|
242
|
+
<View ref={triggerRef} style={multi ? styles.multiBox : undefined}>
|
|
243
|
+
{multi
|
|
244
|
+
? chips.map((chip) => (
|
|
245
|
+
<View key={chip.value} style={styles.chip}>
|
|
246
|
+
<Text size="sm" numberOfLines={1}>
|
|
247
|
+
{chip.label ?? chip.value}
|
|
248
|
+
</Text>
|
|
249
|
+
<Pressable
|
|
250
|
+
accessibilityRole="button"
|
|
251
|
+
accessibilityLabel={`Remove ${chip.label ?? chip.value}`}
|
|
252
|
+
onPress={() => onRemove?.(chip)}
|
|
253
|
+
style={styles.chipRemove}
|
|
254
|
+
>
|
|
255
|
+
<Icon name="x" size={12} color={colors.zinc["500"]} />
|
|
256
|
+
</Pressable>
|
|
257
|
+
</View>
|
|
258
|
+
))
|
|
259
|
+
: null}
|
|
260
|
+
<TextInputField
|
|
261
|
+
ref={inputRef}
|
|
101
262
|
testID={testID}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
{
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
</Pressable>
|
|
122
|
-
</PopoverTrigger>
|
|
123
|
-
<PopoverContent>
|
|
124
|
-
<PickerMenu
|
|
125
|
-
options={options}
|
|
126
|
-
value={value?.value ?? null}
|
|
127
|
-
onValueChange={handleSelect}
|
|
128
|
-
onRequestClose={() => setOpen(false)}
|
|
129
|
-
enableSearch
|
|
130
|
-
allowCustom={allowCustom}
|
|
131
|
-
customOptionLabel={customOptionLabel}
|
|
132
|
-
onSearchChange={debouncedSearch}
|
|
133
|
-
// Local filtering is wrong when the consumer drives search server-side.
|
|
134
|
-
serverFiltered={onSearchChange !== undefined}
|
|
135
|
-
loading={loading}
|
|
136
|
-
emptyText={emptyText}
|
|
137
|
-
searchPlaceholder={searchPlaceholder}
|
|
263
|
+
icon={multi ? undefined : "search"}
|
|
264
|
+
value={query}
|
|
265
|
+
onChangeText={handleChangeText}
|
|
266
|
+
onFocus={() => {
|
|
267
|
+
if (!disabled) setOpen(true);
|
|
268
|
+
}}
|
|
269
|
+
onKeyPress={handleKeyPress}
|
|
270
|
+
placeholder={chips.length > 0 ? undefined : placeholder}
|
|
271
|
+
placeholderTextColor={colors.zinc["400"]}
|
|
272
|
+
editable={!disabled}
|
|
273
|
+
autoFocus={autoFocus}
|
|
274
|
+
autoCapitalize="none"
|
|
275
|
+
autoCorrect={false}
|
|
276
|
+
style={multi ? styles.multiInput : undefined}
|
|
277
|
+
role="combobox"
|
|
278
|
+
aria-expanded={showList}
|
|
279
|
+
aria-controls={listboxId}
|
|
280
|
+
aria-activedescendant={activeOptionId}
|
|
281
|
+
aria-autocomplete="list"
|
|
138
282
|
/>
|
|
139
|
-
</
|
|
140
|
-
|
|
283
|
+
</View>
|
|
284
|
+
<Popover
|
|
285
|
+
open={showList}
|
|
286
|
+
onOpenChange={setOpen}
|
|
287
|
+
triggerRef={triggerRef}
|
|
288
|
+
side="bottom"
|
|
289
|
+
align="start"
|
|
290
|
+
offset={4}
|
|
291
|
+
inheritTriggerWidth
|
|
292
|
+
>
|
|
293
|
+
<PopoverContent manageFocus={false} disableBodyScroll testID={testID ? `${testID}-popover` : undefined}>
|
|
294
|
+
<View style={styles.menu}>
|
|
295
|
+
{!searching && (recentOptions?.length ?? 0) > 0 ? (
|
|
296
|
+
<View style={styles.sectionHeader}>
|
|
297
|
+
<Text size="xs" weight="medium" color="zinc-500">
|
|
298
|
+
{recentsLabel}
|
|
299
|
+
</Text>
|
|
300
|
+
</View>
|
|
301
|
+
) : null}
|
|
302
|
+
<ScrollView
|
|
303
|
+
ref={scrollRef}
|
|
304
|
+
style={styles.scroll}
|
|
305
|
+
nativeID={listboxId}
|
|
306
|
+
accessibilityLabel={searching ? accessibilityLabel : recentsLabel}
|
|
307
|
+
keyboardShouldPersistTaps="handled"
|
|
308
|
+
role={"listbox" as "list"}
|
|
309
|
+
>
|
|
310
|
+
{loading ? (
|
|
311
|
+
<View style={styles.statusRow}>
|
|
312
|
+
<ActivityIndicator />
|
|
313
|
+
</View>
|
|
314
|
+
) : list.length === 0 ? (
|
|
315
|
+
searching ? (
|
|
316
|
+
<View style={styles.statusRow}>
|
|
317
|
+
<Text size="sm" color="zinc-500">
|
|
318
|
+
{emptyText}
|
|
319
|
+
</Text>
|
|
320
|
+
</View>
|
|
321
|
+
) : null
|
|
322
|
+
) : (
|
|
323
|
+
list.map((opt, i) => {
|
|
324
|
+
const isCustom = opt.testID === CUSTOM_VALUE;
|
|
325
|
+
const desc = !isCustom ? getOptionDescription?.(opt) : undefined;
|
|
326
|
+
const label = opt.label ?? opt.value;
|
|
327
|
+
const title =
|
|
328
|
+
!isCustom && renderOptionContent ? (
|
|
329
|
+
renderOptionContent(opt)
|
|
330
|
+
) : desc ? (
|
|
331
|
+
<View>
|
|
332
|
+
<Text userSelect="none" numberOfLines={1}>
|
|
333
|
+
{label}
|
|
334
|
+
</Text>
|
|
335
|
+
<Text size="xs" color="zinc-500" numberOfLines={1}>
|
|
336
|
+
{desc}
|
|
337
|
+
</Text>
|
|
338
|
+
</View>
|
|
339
|
+
) : (
|
|
340
|
+
<Text userSelect="none" numberOfLines={1} color={isCustom ? "zinc-500" : undefined}>
|
|
341
|
+
{label}
|
|
342
|
+
</Text>
|
|
343
|
+
);
|
|
344
|
+
return (
|
|
345
|
+
<MenuButton
|
|
346
|
+
key={opt.value}
|
|
347
|
+
nativeID={optionId(i)}
|
|
348
|
+
testID={isCustom ? "combobox-custom-option" : `combobox-option-${opt.value}`}
|
|
349
|
+
role="option"
|
|
350
|
+
accessibilityLabel={label}
|
|
351
|
+
icon={isCustom ? <Icon name="plus" size={16} color={colors.zinc["400"]} /> : undefined}
|
|
352
|
+
title={title}
|
|
353
|
+
focused={i === activeIndex}
|
|
354
|
+
selected={!multi && !isCustom && single?.value === opt.value}
|
|
355
|
+
disabled={opt.disabled}
|
|
356
|
+
onPress={() => handleSelect(i)}
|
|
357
|
+
onHoverIn={() => setActiveIndex(i)}
|
|
358
|
+
/>
|
|
359
|
+
);
|
|
360
|
+
})
|
|
361
|
+
)}
|
|
362
|
+
</ScrollView>
|
|
363
|
+
</View>
|
|
364
|
+
</PopoverContent>
|
|
365
|
+
</Popover>
|
|
366
|
+
</View>
|
|
141
367
|
);
|
|
142
368
|
}
|
|
143
369
|
|
|
144
370
|
const styles = StyleSheet.create({
|
|
145
|
-
|
|
371
|
+
multiBox: {
|
|
146
372
|
flexDirection: "row",
|
|
373
|
+
flexWrap: "wrap",
|
|
147
374
|
alignItems: "center",
|
|
148
|
-
|
|
149
|
-
gap: 6,
|
|
375
|
+
gap: 4,
|
|
150
376
|
paddingHorizontal: 6,
|
|
377
|
+
paddingVertical: 4,
|
|
378
|
+
minHeight: 40,
|
|
151
379
|
borderWidth: 1,
|
|
152
380
|
borderColor: colors.border,
|
|
153
381
|
borderRadius: 8,
|
|
154
|
-
|
|
382
|
+
},
|
|
383
|
+
multiInput: {
|
|
384
|
+
flexGrow: 1,
|
|
385
|
+
flexBasis: 80,
|
|
386
|
+
borderWidth: 0,
|
|
387
|
+
height: 28,
|
|
388
|
+
},
|
|
389
|
+
chip: {
|
|
390
|
+
flexDirection: "row",
|
|
391
|
+
alignItems: "center",
|
|
392
|
+
gap: 4,
|
|
393
|
+
paddingLeft: 8,
|
|
394
|
+
paddingRight: 4,
|
|
395
|
+
paddingVertical: 2,
|
|
396
|
+
borderRadius: 6,
|
|
397
|
+
backgroundColor: colors.zinc["100"],
|
|
398
|
+
},
|
|
399
|
+
chipRemove: {
|
|
400
|
+
padding: 2,
|
|
401
|
+
borderRadius: 4,
|
|
155
402
|
cursor: "pointer",
|
|
156
403
|
},
|
|
157
|
-
|
|
158
|
-
|
|
404
|
+
menu: {
|
|
405
|
+
gap: 2,
|
|
406
|
+
},
|
|
407
|
+
sectionHeader: {
|
|
408
|
+
paddingHorizontal: 8,
|
|
409
|
+
paddingTop: 4,
|
|
410
|
+
paddingBottom: 2,
|
|
159
411
|
},
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
412
|
+
scroll: {
|
|
413
|
+
maxHeight: 320,
|
|
414
|
+
},
|
|
415
|
+
statusRow: {
|
|
416
|
+
alignItems: "center",
|
|
417
|
+
justifyContent: "center",
|
|
418
|
+
paddingVertical: 16,
|
|
163
419
|
},
|
|
164
420
|
});
|
package/src/legend_item.tsx
CHANGED
|
@@ -14,8 +14,8 @@ interface LegendItemProps {
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Color swatch + label + optional count. Used as the legend row below a
|
|
17
|
-
* `StackedProgressBar`, a `
|
|
18
|
-
* other chart where consumers need to map color → meaning.
|
|
17
|
+
* `StackedProgressBar`, a `BarChart`/`PieChart` with per-series colors, or
|
|
18
|
+
* any other chart where consumers need to map color → meaning.
|
|
19
19
|
*
|
|
20
20
|
* Tabular nums on the value (via Metric) keep counts aligned when several
|
|
21
21
|
* legend items sit in a row.
|
package/src/picker.tsx
CHANGED
|
@@ -39,14 +39,11 @@ export interface PickerProps<T extends string = string, MULTI extends boolean =
|
|
|
39
39
|
disabled?: boolean;
|
|
40
40
|
autoFocus?: boolean;
|
|
41
41
|
enableSelectAll?: boolean;
|
|
42
|
-
enableSearch?: boolean;
|
|
43
42
|
multi?: MULTI;
|
|
44
43
|
includeEmptyOption?: boolean;
|
|
45
44
|
value?: PickerValue<T, MULTI> | null;
|
|
46
45
|
onValueChange?: PickerOnValueChange<T, MULTI>;
|
|
47
46
|
onClose?: PickerOnClose<T, MULTI>;
|
|
48
|
-
/** Placeholder for the search input. Pass a translated string. Default: "Search..." */
|
|
49
|
-
searchPlaceholder?: string;
|
|
50
47
|
/** Label for the "select all" link in multi-select mode. Default: "Select all" */
|
|
51
48
|
selectAllLabel?: string;
|
|
52
49
|
/** Label for the "deselect all" link in multi-select mode. Default: "Deselect all" */
|
|
@@ -59,7 +56,7 @@ export interface PickerProps<T extends string = string, MULTI extends boolean =
|
|
|
59
56
|
export function Picker<T extends string, MULTI extends boolean = false>(
|
|
60
57
|
props: PickerProps<T, MULTI>,
|
|
61
58
|
) {
|
|
62
|
-
if (props.multi || props.
|
|
59
|
+
if (props.multi || props.renderOptionContent) {
|
|
63
60
|
return <CustomPicker {...props} />;
|
|
64
61
|
}
|
|
65
62
|
return <StandardPicker {...(props as PickerProps<T, false>)} />;
|
|
@@ -154,10 +151,8 @@ function CustomPicker<T extends string, MULTI extends boolean = false>(
|
|
|
154
151
|
disabled = false,
|
|
155
152
|
autoFocus = false,
|
|
156
153
|
enableSelectAll = false,
|
|
157
|
-
enableSearch = false,
|
|
158
154
|
onValueChange,
|
|
159
155
|
onClose,
|
|
160
|
-
searchPlaceholder,
|
|
161
156
|
selectAllLabel,
|
|
162
157
|
deselectAllLabel,
|
|
163
158
|
} = props;
|
|
@@ -189,7 +184,6 @@ function CustomPicker<T extends string, MULTI extends boolean = false>(
|
|
|
189
184
|
testID={testID}
|
|
190
185
|
open={open}
|
|
191
186
|
style={style}
|
|
192
|
-
enableSearch={enableSearch}
|
|
193
187
|
onPress={handleToggle}
|
|
194
188
|
renderOptionContent={renderOptionContent}
|
|
195
189
|
selectedItems={selectedItems}
|
|
@@ -207,9 +201,7 @@ function CustomPicker<T extends string, MULTI extends boolean = false>(
|
|
|
207
201
|
onRequestClose={handleClose}
|
|
208
202
|
renderOptionContent={renderOptionContent}
|
|
209
203
|
enableSelectAll={enableSelectAll}
|
|
210
|
-
enableSearch={enableSearch}
|
|
211
204
|
includeEmptyOption={includeEmptyOption}
|
|
212
|
-
searchPlaceholder={searchPlaceholder}
|
|
213
205
|
selectAllLabel={selectAllLabel}
|
|
214
206
|
deselectAllLabel={deselectAllLabel}
|
|
215
207
|
/>
|
|
@@ -250,7 +242,6 @@ function PickerTrigger<T extends string>({
|
|
|
250
242
|
style,
|
|
251
243
|
onPress,
|
|
252
244
|
renderOptionContent,
|
|
253
|
-
enableSearch,
|
|
254
245
|
selectedItems,
|
|
255
246
|
placeholder,
|
|
256
247
|
disabled = false,
|
|
@@ -261,7 +252,6 @@ function PickerTrigger<T extends string>({
|
|
|
261
252
|
style?: StyleProp<ViewStyle>;
|
|
262
253
|
onPress: () => void;
|
|
263
254
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
264
|
-
enableSearch?: boolean;
|
|
265
255
|
selectedItems: PickerOption<T>[];
|
|
266
256
|
placeholder?: string;
|
|
267
257
|
disabled?: boolean;
|
|
@@ -279,12 +269,7 @@ function PickerTrigger<T extends string>({
|
|
|
279
269
|
// comes from the visible selection/placeholder text below.
|
|
280
270
|
accessibilityRole="button"
|
|
281
271
|
accessibilityState={{ expanded: open, disabled }}
|
|
282
|
-
style={[
|
|
283
|
-
styles.pressable,
|
|
284
|
-
open && !enableSearch && styles.opened,
|
|
285
|
-
disabled && styles.disabled,
|
|
286
|
-
style,
|
|
287
|
-
]}
|
|
272
|
+
style={[styles.pressable, open && styles.opened, disabled && styles.disabled, style]}
|
|
288
273
|
onPress={!disabled ? onPress : undefined}
|
|
289
274
|
disabled={disabled}
|
|
290
275
|
>
|