@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/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
|
>
|
package/src/picker_menu.tsx
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
import { StyleSheet, View, ScrollView, Pressable } from "react-native";
|
|
1
|
+
import { StyleSheet, View, ScrollView, Pressable, TextInput } from "react-native";
|
|
2
2
|
import { useState, useCallback, useMemo, useRef } from "react";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { Icon } from "./icon";
|
|
6
6
|
import { Checkbox } from "./checkbox";
|
|
7
7
|
import { Spacer } from "./spacer";
|
|
8
|
-
import { TextInputField } from "./text_input_field";
|
|
9
8
|
import { MenuButton } from "./menu_button";
|
|
10
9
|
import { ActivityIndicator } from "./activity_indicator";
|
|
11
10
|
import { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
|
|
12
11
|
import { useScreenSize } from "./use_screen_size";
|
|
13
|
-
import { customOptionFor } from "./custom_option";
|
|
14
12
|
|
|
15
13
|
export interface PickerMenuProps<T extends string = string, MULTI extends boolean = false> {
|
|
16
14
|
testID?: string;
|
|
@@ -21,31 +19,13 @@ export interface PickerMenuProps<T extends string = string, MULTI extends boolea
|
|
|
21
19
|
onClose?: PickerOnClose<T, MULTI>;
|
|
22
20
|
onRequestClose?: () => void;
|
|
23
21
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
24
|
-
/** Enable search input to filter options */
|
|
25
|
-
enableSearch?: boolean;
|
|
26
|
-
/** Single-select + search only: when the typed query matches no option, append
|
|
27
|
-
* a trailing row that selects the raw query as a custom value (free entry).
|
|
28
|
-
* `onValueChange` then receives the typed string. */
|
|
29
|
-
allowCustom?: boolean;
|
|
30
|
-
/** Label for the free-entry row (default: the raw query). Return `null` to
|
|
31
|
-
* suppress the row for a given query — e.g. while it is still incomplete or
|
|
32
|
-
* invalid — so "add" only appears when it is a meaningful thing to add. */
|
|
33
|
-
customOptionLabel?: (query: string) => string | null;
|
|
34
22
|
enableSelectAll?: boolean;
|
|
35
23
|
includeEmptyOption?: boolean;
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
* local filtering of `options`. */
|
|
39
|
-
onSearchChange?: (query: string) => void;
|
|
40
|
-
/** When true, `options` are treated as already filtered (server-side), so the
|
|
41
|
-
* local label filter is skipped and the full result set shows. */
|
|
42
|
-
serverFiltered?: boolean;
|
|
43
|
-
/** Show a loading indicator (e.g. while async results are in flight). */
|
|
24
|
+
/** Show a loading indicator (e.g. while async results are in flight — the
|
|
25
|
+
* parent owns fetching and passes the resulting `options`). */
|
|
44
26
|
loading?: boolean;
|
|
45
27
|
/** Shown when there are no options and not loading. Default: "No results". */
|
|
46
28
|
emptyText?: string;
|
|
47
|
-
/** Placeholder for the search input. Pass a translated string. Default: "Search..." */
|
|
48
|
-
searchPlaceholder?: string;
|
|
49
29
|
/** Label for the "select all" link in multi-select mode. Default: "Select all" */
|
|
50
30
|
selectAllLabel?: string;
|
|
51
31
|
/** Label for the "deselect all" link in multi-select mode. Default: "Deselect all" */
|
|
@@ -53,8 +33,11 @@ export interface PickerMenuProps<T extends string = string, MULTI extends boolea
|
|
|
53
33
|
}
|
|
54
34
|
|
|
55
35
|
/**
|
|
56
|
-
* The
|
|
57
|
-
*
|
|
36
|
+
* The option list for a Picker — a keyboard-navigable list with native-`<select>`
|
|
37
|
+
* typeahead (type to jump focus). It has NO search box: filtering a long/async
|
|
38
|
+
* list is the job of `Combobox`, whose input drives the search and passes the
|
|
39
|
+
* already-filtered `options` to render. Use standalone inside a Popover, or via
|
|
40
|
+
* the `Picker` component.
|
|
58
41
|
*/
|
|
59
42
|
export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
60
43
|
props: PickerMenuProps<T, MULTI>,
|
|
@@ -69,15 +52,9 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
69
52
|
onClose,
|
|
70
53
|
onRequestClose,
|
|
71
54
|
enableSelectAll,
|
|
72
|
-
enableSearch,
|
|
73
|
-
allowCustom,
|
|
74
|
-
customOptionLabel,
|
|
75
55
|
includeEmptyOption,
|
|
76
|
-
onSearchChange,
|
|
77
|
-
serverFiltered,
|
|
78
56
|
loading,
|
|
79
57
|
emptyText = "No results",
|
|
80
|
-
searchPlaceholder = "Search...",
|
|
81
58
|
selectAllLabel = "Select all",
|
|
82
59
|
deselectAllLabel = "Deselect all",
|
|
83
60
|
} = props;
|
|
@@ -101,38 +78,16 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
101
78
|
};
|
|
102
79
|
}, [multi, value]);
|
|
103
80
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const customOpt = useMemo(
|
|
109
|
-
() => customOptionFor({ allowCustom, multi, query: searchQuery, options, customOptionLabel }),
|
|
110
|
-
[allowCustom, multi, searchQuery, options, customOptionLabel],
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
const filteredOptions = useMemo(() => {
|
|
114
|
-
let result = options;
|
|
115
|
-
// Skip the local filter in server-driven mode — `options` already reflect
|
|
116
|
-
// the query for `searchQuery`.
|
|
117
|
-
if (enableSearch && searchQuery && !serverFiltered) {
|
|
118
|
-
const query = searchQuery.toLowerCase();
|
|
119
|
-
result = options.filter((opt) => opt && opt.label?.toLowerCase().includes(query));
|
|
120
|
-
}
|
|
121
|
-
if (includeEmptyOption && (!enableSearch || !searchQuery)) {
|
|
122
|
-
const emptyOption: PickerOption<T> = {
|
|
123
|
-
label: "",
|
|
124
|
-
value: "" as T,
|
|
125
|
-
};
|
|
126
|
-
result = [emptyOption, ...result];
|
|
127
|
-
}
|
|
128
|
-
if (customOpt) {
|
|
129
|
-
result = [...result, customOpt];
|
|
81
|
+
const displayOptions = useMemo(() => {
|
|
82
|
+
if (includeEmptyOption) {
|
|
83
|
+
const emptyOption: PickerOption<T> = { label: "", value: "" as T };
|
|
84
|
+
return [emptyOption, ...options];
|
|
130
85
|
}
|
|
131
|
-
return
|
|
132
|
-
}, [options,
|
|
86
|
+
return options;
|
|
87
|
+
}, [options, includeEmptyOption]);
|
|
133
88
|
|
|
134
89
|
const [focusedIndex, setFocusedIndex] = useState<number>(() => {
|
|
135
|
-
const selectedIndex =
|
|
90
|
+
const selectedIndex = displayOptions.findIndex((opt) => {
|
|
136
91
|
if (!opt || opt.disabled) return false;
|
|
137
92
|
if (multi && Array.isArray(multiValue)) {
|
|
138
93
|
return multiValue.includes(opt.value);
|
|
@@ -140,19 +95,10 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
140
95
|
return opt.value === singleValue;
|
|
141
96
|
});
|
|
142
97
|
if (selectedIndex >= 0) return selectedIndex;
|
|
143
|
-
const firstValid =
|
|
98
|
+
const firstValid = displayOptions.findIndex((opt) => opt && !opt.disabled);
|
|
144
99
|
return firstValid >= 0 ? firstValid : 0;
|
|
145
100
|
});
|
|
146
101
|
|
|
147
|
-
const handleSearchChange = useCallback(
|
|
148
|
-
(text: string) => {
|
|
149
|
-
setSearchQuery(text);
|
|
150
|
-
setFocusedIndex(0);
|
|
151
|
-
onSearchChange?.(text);
|
|
152
|
-
},
|
|
153
|
-
[onSearchChange],
|
|
154
|
-
);
|
|
155
|
-
|
|
156
102
|
const handleClose = useCallback(() => {
|
|
157
103
|
onRequestClose?.();
|
|
158
104
|
if (onClose) {
|
|
@@ -168,13 +114,9 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
168
114
|
(itemValue: T) => {
|
|
169
115
|
if (!multi || !onValueChange) return;
|
|
170
116
|
const currentValues = multiValue ?? [];
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
} else {
|
|
175
|
-
newValue = [...currentValues, itemValue];
|
|
176
|
-
}
|
|
177
|
-
|
|
117
|
+
const newValue = currentValues.includes(itemValue)
|
|
118
|
+
? currentValues.filter((v) => v !== itemValue)
|
|
119
|
+
: [...currentValues, itemValue];
|
|
178
120
|
(onValueChange as (value: T[]) => void)(newValue);
|
|
179
121
|
},
|
|
180
122
|
[multi, multiValue, onValueChange],
|
|
@@ -216,8 +158,8 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
216
158
|
switch (key) {
|
|
217
159
|
case "ArrowDown": {
|
|
218
160
|
let next = focusedIndex + 1;
|
|
219
|
-
while (next <
|
|
220
|
-
const opt =
|
|
161
|
+
while (next < displayOptions.length) {
|
|
162
|
+
const opt = displayOptions[next];
|
|
221
163
|
if (opt && !opt.disabled) {
|
|
222
164
|
setFocusedIndex(next);
|
|
223
165
|
scrollToIndex(next);
|
|
@@ -230,7 +172,7 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
230
172
|
case "ArrowUp": {
|
|
231
173
|
let next = focusedIndex - 1;
|
|
232
174
|
while (next >= 0) {
|
|
233
|
-
const opt =
|
|
175
|
+
const opt = displayOptions[next];
|
|
234
176
|
if (opt && !opt.disabled) {
|
|
235
177
|
setFocusedIndex(next);
|
|
236
178
|
scrollToIndex(next);
|
|
@@ -241,14 +183,11 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
241
183
|
return true;
|
|
242
184
|
}
|
|
243
185
|
case "Enter":
|
|
244
|
-
if (focusedIndex >= 0 && focusedIndex <
|
|
245
|
-
const focusedOption =
|
|
186
|
+
if (focusedIndex >= 0 && focusedIndex < displayOptions.length) {
|
|
187
|
+
const focusedOption = displayOptions[focusedIndex];
|
|
246
188
|
if (focusedOption && !focusedOption.disabled) {
|
|
247
|
-
if (multi)
|
|
248
|
-
|
|
249
|
-
} else {
|
|
250
|
-
handleSingleSelect(focusedOption.value);
|
|
251
|
-
}
|
|
189
|
+
if (multi) handleMultiSelect(focusedOption.value);
|
|
190
|
+
else handleSingleSelect(focusedOption.value);
|
|
252
191
|
}
|
|
253
192
|
}
|
|
254
193
|
return true;
|
|
@@ -262,90 +201,108 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
262
201
|
return false;
|
|
263
202
|
}
|
|
264
203
|
},
|
|
265
|
-
[focusedIndex,
|
|
204
|
+
[focusedIndex, displayOptions, multi, handleMultiSelect, handleSingleSelect, handleClose, scrollToIndex],
|
|
266
205
|
);
|
|
267
206
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
const showLinks = showSelectAllLink || showDeselectAllLink;
|
|
207
|
+
// Native-select typeahead: accumulate printable keys into a short-lived buffer
|
|
208
|
+
// and jump focus to the first option whose label starts with it.
|
|
209
|
+
const typeaheadBufferRef = useRef("");
|
|
210
|
+
const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
273
211
|
|
|
274
|
-
const
|
|
212
|
+
const handleTypeahead = useCallback(
|
|
213
|
+
(char: string) => {
|
|
214
|
+
if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current);
|
|
215
|
+
typeaheadBufferRef.current += char.toLowerCase();
|
|
216
|
+
const buffer = typeaheadBufferRef.current;
|
|
217
|
+
const match = displayOptions.findIndex(
|
|
218
|
+
(opt) => opt && !opt.disabled && (opt.label ?? "").toLowerCase().startsWith(buffer),
|
|
219
|
+
);
|
|
220
|
+
if (match >= 0) {
|
|
221
|
+
setFocusedIndex(match);
|
|
222
|
+
scrollToIndex(match);
|
|
223
|
+
}
|
|
224
|
+
typeaheadTimerRef.current = setTimeout(() => {
|
|
225
|
+
typeaheadBufferRef.current = "";
|
|
226
|
+
}, 600);
|
|
227
|
+
},
|
|
228
|
+
[displayOptions, scrollToIndex],
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const handleKeyPress = useCallback(
|
|
275
232
|
(e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
|
|
276
|
-
|
|
233
|
+
const key = e.nativeEvent.key;
|
|
234
|
+
if (handleKeyNavigation(key)) {
|
|
277
235
|
e.preventDefault();
|
|
236
|
+
return;
|
|
278
237
|
}
|
|
238
|
+
if (key.length === 1) handleTypeahead(key);
|
|
279
239
|
},
|
|
280
|
-
[handleKeyNavigation],
|
|
240
|
+
[handleKeyNavigation, handleTypeahead],
|
|
281
241
|
);
|
|
282
242
|
|
|
243
|
+
const hasOptions = displayOptions.some((opt) => opt && !opt.disabled);
|
|
244
|
+
const hasSelectedOptions = multi && Array.isArray(multiValue) && multiValue.length > 0;
|
|
245
|
+
const showSelectAllLink = multi && hasOptions && enableSelectAll;
|
|
246
|
+
const showDeselectAllLink = multi && hasOptions && hasSelectedOptions && enableSelectAll;
|
|
247
|
+
const showLinks = showSelectAllLink || showDeselectAllLink;
|
|
248
|
+
|
|
283
249
|
return (
|
|
284
250
|
<View
|
|
285
251
|
testID={testID}
|
|
286
252
|
style={[styles.container, screenSize.small ? { flex: 1 } : { maxHeight: 480 }]}
|
|
287
253
|
>
|
|
288
|
-
{
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
autoCorrect={false}
|
|
298
|
-
onKeyPress={handleSearchKeyPress}
|
|
299
|
-
/>
|
|
300
|
-
)}
|
|
254
|
+
{/* Visually hidden but focusable: captures typeahead + arrow/enter keys
|
|
255
|
+
while the popover is open, giving native-<select> behavior. */}
|
|
256
|
+
<TextInput
|
|
257
|
+
autoFocus={!screenSize.small}
|
|
258
|
+
value=""
|
|
259
|
+
onChangeText={() => {}}
|
|
260
|
+
onKeyPress={handleKeyPress}
|
|
261
|
+
style={styles.typeaheadCapture}
|
|
262
|
+
/>
|
|
301
263
|
<ScrollView ref={scrollViewRef} style={styles.optionsList}>
|
|
302
264
|
{loading && (
|
|
303
265
|
<View style={styles.statusRow}>
|
|
304
266
|
<ActivityIndicator />
|
|
305
267
|
</View>
|
|
306
268
|
)}
|
|
307
|
-
{!loading &&
|
|
269
|
+
{!loading && displayOptions.length === 0 && (
|
|
308
270
|
<View style={styles.statusRow}>
|
|
309
271
|
<Text color="zinc-500" size="sm">
|
|
310
272
|
{emptyText}
|
|
311
273
|
</Text>
|
|
312
274
|
</View>
|
|
313
275
|
)}
|
|
314
|
-
{
|
|
276
|
+
{displayOptions.map((item, index) => {
|
|
315
277
|
if (!item) return null;
|
|
316
278
|
|
|
317
279
|
const isSelected = multi
|
|
318
280
|
? Array.isArray(multiValue) && multiValue.includes(item.value)
|
|
319
281
|
: item.value === singleValue;
|
|
320
282
|
const isFocused = index === focusedIndex;
|
|
321
|
-
const isCustom = !!customOpt && item.value === customOpt.value;
|
|
322
283
|
|
|
323
284
|
return (
|
|
324
285
|
<MenuButton
|
|
325
286
|
key={item.value}
|
|
326
|
-
testID={
|
|
287
|
+
testID={item.testID || `picker-option-${item.value}`}
|
|
327
288
|
icon={multi ? <Checkbox checked={isSelected} /> : undefined}
|
|
328
289
|
title={
|
|
329
|
-
renderOptionContent
|
|
290
|
+
renderOptionContent ? (
|
|
330
291
|
renderOptionContent(item)
|
|
331
292
|
) : (
|
|
332
|
-
<Text userSelect="none" numberOfLines={1}
|
|
293
|
+
<Text userSelect="none" numberOfLines={1}>
|
|
333
294
|
{item.label}
|
|
334
295
|
</Text>
|
|
335
296
|
)
|
|
336
297
|
}
|
|
337
298
|
right={
|
|
338
|
-
|
|
339
|
-
<Icon name="plus" size={16} color={colors.zinc["400"]} />
|
|
340
|
-
) : !multi && isSelected ? (
|
|
299
|
+
!multi && isSelected ? (
|
|
341
300
|
<Icon name="check" size={18} color={colors.zinc["950"]} />
|
|
342
301
|
) : undefined
|
|
343
302
|
}
|
|
344
303
|
focused={isFocused}
|
|
345
304
|
disabled={item.disabled}
|
|
346
|
-
onPress={() =>
|
|
347
|
-
multi ? handleMultiSelect(item.value) : handleSingleSelect(item.value)
|
|
348
|
-
}
|
|
305
|
+
onPress={() => (multi ? handleMultiSelect(item.value) : handleSingleSelect(item.value))}
|
|
349
306
|
onHoverIn={() => setFocusedIndex(index)}
|
|
350
307
|
style={styles.option}
|
|
351
308
|
/>
|
|
@@ -397,4 +354,10 @@ const styles = StyleSheet.create({
|
|
|
397
354
|
justifyContent: "center",
|
|
398
355
|
paddingVertical: 16,
|
|
399
356
|
},
|
|
357
|
+
typeaheadCapture: {
|
|
358
|
+
position: "absolute",
|
|
359
|
+
width: 1,
|
|
360
|
+
height: 1,
|
|
361
|
+
opacity: 0,
|
|
362
|
+
},
|
|
400
363
|
});
|
package/src/switcher.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useCallback, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { StyleSheet } from "react-native";
|
|
3
3
|
import { Icon } from "./icon";
|
|
4
4
|
import { MenuButton } from "./menu_button";
|
|
5
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
5
6
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
6
7
|
import { Stack } from "./stack";
|
|
7
8
|
import { Text } from "./text";
|
|
@@ -67,12 +68,12 @@ export function Switcher(props: SwitcherProps) {
|
|
|
67
68
|
return (
|
|
68
69
|
<Popover open={open} onOpenChange={setOpen} side={side} align={align}>
|
|
69
70
|
<PopoverTrigger>
|
|
70
|
-
<
|
|
71
|
+
<PressableHighlight style={[styles.trigger, { maxWidth: maxTriggerWidth }]}>
|
|
71
72
|
<Text size="sm" weight="medium" color="zinc-700" numberOfLines={1}>
|
|
72
73
|
{label}
|
|
73
74
|
</Text>
|
|
74
75
|
<Icon name="chevrons-up-down" size={14} color={colors.zinc["500"]} />
|
|
75
|
-
</
|
|
76
|
+
</PressableHighlight>
|
|
76
77
|
</PopoverTrigger>
|
|
77
78
|
<PopoverContent>
|
|
78
79
|
<Stack style={styles.popoverBody}>
|
package/src/chart_area.tsx
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { useId } from "react";
|
|
2
|
-
import { View } from "react-native";
|
|
3
|
-
import {
|
|
4
|
-
Area,
|
|
5
|
-
AreaChart,
|
|
6
|
-
CartesianGrid,
|
|
7
|
-
ResponsiveContainer,
|
|
8
|
-
Tooltip,
|
|
9
|
-
XAxis,
|
|
10
|
-
YAxis,
|
|
11
|
-
} from "recharts";
|
|
12
|
-
import { colors } from "./colors";
|
|
13
|
-
import { useLoticsTheme } from "./theme";
|
|
14
|
-
import { chartAxisTickStyle, chartTooltipLabelStyle, chartTooltipStyle } from "./chart_internals";
|
|
15
|
-
|
|
16
|
-
interface ChartAreaProps<T> {
|
|
17
|
-
data: T[];
|
|
18
|
-
/** Property to use as the X-axis category (e.g. month label). */
|
|
19
|
-
xKey: keyof T & string;
|
|
20
|
-
/** Property to use as the Y-axis value. */
|
|
21
|
-
yKey: keyof T & string;
|
|
22
|
-
/** Pixel height. Default 200. Charts shorter than ~120 lose readability;
|
|
23
|
-
* taller than ~280 dominate the card. */
|
|
24
|
-
height?: number;
|
|
25
|
-
/** Override the theme accent for this chart instance. */
|
|
26
|
-
color?: string;
|
|
27
|
-
/** Format the Y value in tooltips. Defaults to `Intl.NumberFormat` for
|
|
28
|
-
* numbers, identity for strings. */
|
|
29
|
-
formatValue?: (value: number) => string;
|
|
30
|
-
/** Label shown next to the value in the tooltip. Default `Value`. */
|
|
31
|
-
valueLabel?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Shadcn-style area chart — gradient fill, dashed horizontal-only
|
|
36
|
-
* gridlines, clean axes (no axisLine, no tickLine), tooltip with the
|
|
37
|
-
* standard chart tooltip chrome.
|
|
38
|
-
*
|
|
39
|
-
* Defaults pulled from the `useLoticsTheme()` accent so consumers don't
|
|
40
|
-
* need to think about color choice. Override via the `color` prop for
|
|
41
|
-
* one-off charts that need a different hue.
|
|
42
|
-
*
|
|
43
|
-
* Why a wrapper instead of just exposing Recharts to consumers: the
|
|
44
|
-
* "shadcn pattern" is a dozen non-obvious Recharts props (axisLine={false},
|
|
45
|
-
* tickLine={false}, monotone interpolation, gradient stops at 5% and 95%,
|
|
46
|
-
* stroke-dasharray "3 3" on grid, …). Without the wrapper, every
|
|
47
|
-
* consumer has to remember the full recipe; with it, they pass data and
|
|
48
|
-
* get the polish.
|
|
49
|
-
*/
|
|
50
|
-
export function ChartArea<T extends Record<string, unknown>>(props: ChartAreaProps<T>) {
|
|
51
|
-
const { data, xKey, yKey, height = 200, color, formatValue, valueLabel = "Value" } = props;
|
|
52
|
-
const theme = useLoticsTheme();
|
|
53
|
-
const fill = color ?? theme.accent;
|
|
54
|
-
// `useId` guarantees the gradient `id` is unique even when multiple
|
|
55
|
-
// ChartArea instances render on the same page — without it, two charts
|
|
56
|
-
// would share the same `url(#revenueFill)` and the second would lose
|
|
57
|
-
// its gradient.
|
|
58
|
-
const gradientId = `chartArea-${useId()}`;
|
|
59
|
-
return (
|
|
60
|
-
<View style={{ height }}>
|
|
61
|
-
<ResponsiveContainer width="100%" height="100%">
|
|
62
|
-
<AreaChart data={data} margin={{ top: 12, right: 8, left: 8, bottom: 0 }}>
|
|
63
|
-
<defs>
|
|
64
|
-
{/* 3-stop gradient: deeper top (55%), accelerated mid-fade,
|
|
65
|
-
clean bottom. Single 5%→95% linear stop reads as "tinted",
|
|
66
|
-
3-stop with the middle inflection at 50%/18% reads as
|
|
67
|
-
"designed". Stripe / Mercury use the multi-stop shape; the
|
|
68
|
-
eye picks up the non-linear falloff as intentional. */}
|
|
69
|
-
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
70
|
-
<stop offset="0%" stopColor={fill} stopOpacity={0.55} />
|
|
71
|
-
<stop offset="50%" stopColor={fill} stopOpacity={0.18} />
|
|
72
|
-
<stop offset="100%" stopColor={fill} stopOpacity={0} />
|
|
73
|
-
</linearGradient>
|
|
74
|
-
</defs>
|
|
75
|
-
<CartesianGrid vertical={false} stroke={colors.zinc[200]} strokeDasharray="3 3" />
|
|
76
|
-
<XAxis
|
|
77
|
-
dataKey={xKey as string}
|
|
78
|
-
axisLine={false}
|
|
79
|
-
tickLine={false}
|
|
80
|
-
tickMargin={10}
|
|
81
|
-
tick={chartAxisTickStyle}
|
|
82
|
-
/>
|
|
83
|
-
<YAxis hide />
|
|
84
|
-
<Tooltip
|
|
85
|
-
cursor={{ stroke: colors.zinc[300], strokeDasharray: "3 3" }}
|
|
86
|
-
contentStyle={chartTooltipStyle}
|
|
87
|
-
labelStyle={chartTooltipLabelStyle}
|
|
88
|
-
formatter={(val: unknown) => {
|
|
89
|
-
if (typeof val !== "number") return [String(val), valueLabel];
|
|
90
|
-
return [formatValue ? formatValue(val) : val.toLocaleString(), valueLabel];
|
|
91
|
-
}}
|
|
92
|
-
/>
|
|
93
|
-
<Area
|
|
94
|
-
type="monotone"
|
|
95
|
-
dataKey={yKey as string}
|
|
96
|
-
stroke={fill}
|
|
97
|
-
strokeWidth={2.5}
|
|
98
|
-
fill={`url(#${gradientId})`}
|
|
99
|
-
animationDuration={600}
|
|
100
|
-
/>
|
|
101
|
-
</AreaChart>
|
|
102
|
-
</ResponsiveContainer>
|
|
103
|
-
</View>
|
|
104
|
-
);
|
|
105
|
-
}
|