@lotics/ui 1.26.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 +15 -11
- package/src/app_icon.tsx +59 -0
- 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/dynamic_icon.tsx +20 -0
- package/src/dynamic_icon.web.tsx +40 -0
- 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
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Icon, type IconName } from "./icon";
|
|
2
|
+
|
|
3
|
+
interface DynamicIconProps {
|
|
4
|
+
/** Any Lucide icon name (kebab-case). */
|
|
5
|
+
name: string;
|
|
6
|
+
size?: number;
|
|
7
|
+
color?: string;
|
|
8
|
+
testID?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Native variant of `DynamicIcon`. There's no lazy lucide loader on native, so
|
|
13
|
+
* names resolve against the curated `Icon` set (which falls back to a neutral
|
|
14
|
+
* glyph for names outside it). The web variant (`dynamic_icon.web.tsx`) renders
|
|
15
|
+
* the full Lucide library. The runtime name is a boundary value — `Icon` handles
|
|
16
|
+
* an unknown name gracefully.
|
|
17
|
+
*/
|
|
18
|
+
export function DynamicIcon({ name, size, color, testID }: DynamicIconProps) {
|
|
19
|
+
return <Icon name={name as IconName} size={size} color={color} testID={testID} />;
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { View } from "react-native";
|
|
2
|
+
import { DynamicIcon as LucideDynamicIcon } from "lucide-react/dynamic";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
|
|
5
|
+
type LucideIconName = React.ComponentProps<typeof LucideDynamicIcon>["name"];
|
|
6
|
+
|
|
7
|
+
interface DynamicIconProps {
|
|
8
|
+
/** Any Lucide icon name (kebab-case). Unknown names render a blank placeholder. */
|
|
9
|
+
name: string;
|
|
10
|
+
size?: number;
|
|
11
|
+
color?: string;
|
|
12
|
+
testID?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renders any icon from the FULL Lucide library by runtime name. Web variant:
|
|
17
|
+
* lucide-react's lazy `DynamicIcon` code-splits per icon, so only icons actually
|
|
18
|
+
* shown are fetched — the full set costs ~nothing in the initial bundle. The
|
|
19
|
+
* native variant (`dynamic_icon.tsx`) resolves against the curated `Icon` set.
|
|
20
|
+
*
|
|
21
|
+
* Icons are decorative — the enclosing control carries the accessible name
|
|
22
|
+
* (matches `Icon`).
|
|
23
|
+
*/
|
|
24
|
+
export function DynamicIcon({ name, size = 24, color = colors.zinc["900"], testID }: DynamicIconProps) {
|
|
25
|
+
return (
|
|
26
|
+
<View
|
|
27
|
+
testID={testID}
|
|
28
|
+
accessibilityElementsHidden
|
|
29
|
+
importantForAccessibility="no-hide-descendants"
|
|
30
|
+
aria-hidden
|
|
31
|
+
>
|
|
32
|
+
<LucideDynamicIcon
|
|
33
|
+
name={name as LucideIconName}
|
|
34
|
+
size={size}
|
|
35
|
+
color={color}
|
|
36
|
+
fallback={() => <View style={{ width: size, height: size }} />}
|
|
37
|
+
/>
|
|
38
|
+
</View>
|
|
39
|
+
);
|
|
40
|
+
}
|
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.
|