@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/src/combobox.tsx CHANGED
@@ -1,164 +1,420 @@
1
- import { Pressable, StyleProp, ViewStyle, StyleSheet } from "react-native";
2
- import { useState, useCallback } from "react";
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 { Popover, PopoverTrigger, PopoverContent } from "./popover";
7
- import { PickerMenu } from "./picker_menu";
8
- import { PickerOption } from "./picker";
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
- /** The selected option. It carries its own label, so the trigger still shows
13
- * the selection even when that option is no longer in the current (async)
14
- * result set. */
15
- value: PickerOption<T> | null;
16
- onValueChange: (option: PickerOption<T>) => void;
17
- /** Result options to show in the list. In server-search mode the consumer
18
- * refreshes these in response to `onSearchChange`. */
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
- /** Show a loading indicator while async results are in flight. */
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
- searchPlaceholder?: string;
30
- /** Shown when there are no results and not loading. Default: "No results". */
62
+ recentsLabel?: string;
31
63
  emptyText?: string;
32
- /** Accept free entry: when the typed query matches no option, offer it as a
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
- * Async-search select a keyboard-accessible combobox. The trigger opens a
47
- * Popover with a search box and a result list (arrows to move, Enter to pick,
48
- * Esc to close; the trigger is tab-focusable and opens on Enter/Space).
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
- * Pass `onSearchChange` to drive search **server-side** (the consumer re-queries
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
- loading,
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
- searchPlaceholder,
65
- emptyText,
66
- allowCustom,
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 debouncedSearch = useDebouncedCallback(onSearchChange, searchDebounceMs);
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 handleToggle = useCallback(() => {
78
- if (!disabled) setOpen((o) => !o);
79
- }, [disabled]);
128
+ const searching = query.trim().length > 0;
129
+ const isServer = onSearchChange !== undefined;
80
130
 
81
- const handleSelect = useCallback(
82
- (v: T) => {
83
- const opt = options.find((o) => o.value === v);
84
- if (opt) onValueChange(opt);
85
- else if (allowCustom && v) onValueChange({ value: v, label: v });
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
- [options, onValueChange, allowCustom],
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
- <Popover
93
- open={open && !disabled}
94
- onOpenChange={setOpen}
95
- side="bottom"
96
- align="start"
97
- inheritTriggerWidth={true}
98
- >
99
- <PopoverTrigger>
100
- <Pressable
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
- // A bare Pressable renders as an unfocusable <div> on web; `button`
103
- // puts it in the tab order and maps Enter/Space to onPress, while
104
- // `expanded` announces open/closed.
105
- accessibilityRole="button"
106
- accessibilityState={{ expanded: open, disabled }}
107
- style={[styles.trigger, open && styles.opened, disabled && styles.disabled, style]}
108
- onPress={!disabled ? handleToggle : undefined}
109
- disabled={disabled}
110
- >
111
- {value ? (
112
- <Text userSelect="none" numberOfLines={1}>
113
- {value.label ?? value.value}
114
- </Text>
115
- ) : (
116
- <Text color="zinc-500" userSelect="none" numberOfLines={1}>
117
- {placeholder}
118
- </Text>
119
- )}
120
- <Icon name="chevron-down" size={18} color={colors.zinc["500"]} />
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
- </PopoverContent>
140
- </Popover>
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
- trigger: {
371
+ multiBox: {
146
372
  flexDirection: "row",
373
+ flexWrap: "wrap",
147
374
  alignItems: "center",
148
- justifyContent: "space-between",
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
- height: 40,
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
- opened: {
158
- borderColor: colors.zinc["900"],
404
+ menu: {
405
+ gap: 2,
406
+ },
407
+ sectionHeader: {
408
+ paddingHorizontal: 8,
409
+ paddingTop: 4,
410
+ paddingBottom: 2,
159
411
  },
160
- disabled: {
161
- opacity: 0.5,
162
- cursor: "auto",
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
+ }
@@ -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 `ChartBar` with categorical colors, or any
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.