@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/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
|
-
}
|
package/src/chart_bar.tsx
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { View } from "react-native";
|
|
2
|
-
import {
|
|
3
|
-
Bar,
|
|
4
|
-
BarChart,
|
|
5
|
-
CartesianGrid,
|
|
6
|
-
Cell,
|
|
7
|
-
ResponsiveContainer,
|
|
8
|
-
Tooltip,
|
|
9
|
-
XAxis,
|
|
10
|
-
YAxis,
|
|
11
|
-
} from "recharts";
|
|
12
|
-
import { colors } from "./colors";
|
|
13
|
-
import { useLoticsTheme } from "./theme";
|
|
14
|
-
import {
|
|
15
|
-
chartAxisTickStyle,
|
|
16
|
-
chartTooltipLabelStyle,
|
|
17
|
-
chartTooltipStyle,
|
|
18
|
-
chartYAxisCategoryTickStyle,
|
|
19
|
-
} from "./chart_internals";
|
|
20
|
-
|
|
21
|
-
interface ChartBarProps<T> {
|
|
22
|
-
data: T[];
|
|
23
|
-
/** Property to use as the category axis. */
|
|
24
|
-
categoryKey: keyof T & string;
|
|
25
|
-
/** Property to use as the value axis. */
|
|
26
|
-
valueKey: keyof T & string;
|
|
27
|
-
/** Per-row fill color. Optional — when omitted, all bars use the theme
|
|
28
|
-
* accent. Set this when each category needs its own color (funnel
|
|
29
|
-
* stages, status breakdowns, etc.). */
|
|
30
|
-
fillKey?: keyof T & string;
|
|
31
|
-
/** Orientation. Default `vertical` (category on X, value on Y). Use
|
|
32
|
-
* `horizontal` for "rank by value" lists where category names are
|
|
33
|
-
* long (member names, account labels). */
|
|
34
|
-
orientation?: "vertical" | "horizontal";
|
|
35
|
-
/** Pixel height. Default 220 vertical / row-count × 56 horizontal.
|
|
36
|
-
* Pass explicit value for fixed-height layouts. */
|
|
37
|
-
height?: number;
|
|
38
|
-
/** Override the theme accent for this chart instance. */
|
|
39
|
-
color?: string;
|
|
40
|
-
/** Format the value in tooltips + value labels. */
|
|
41
|
-
formatValue?: (value: number) => string;
|
|
42
|
-
/** Label shown next to the value in the tooltip. Default `Value`. */
|
|
43
|
-
valueLabel?: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Shadcn-style bar chart. Vertical (categories on X axis) is the default;
|
|
48
|
-
* pass `orientation="horizontal"` for rank-by-value lists where labels are
|
|
49
|
-
* long.
|
|
50
|
-
*
|
|
51
|
-
* Vertical: rounded TOP corners only (radius 6) — the shadcn pattern.
|
|
52
|
-
* Horizontal: rounded RIGHT corners only.
|
|
53
|
-
*
|
|
54
|
-
* Per-bar colors via `fillKey`: each data row carries its own color. Use
|
|
55
|
-
* for funnel/category breakdowns. Omit for single-hue bar charts (the
|
|
56
|
-
* theme accent fills every bar).
|
|
57
|
-
*/
|
|
58
|
-
export function ChartBar<T extends Record<string, unknown>>(props: ChartBarProps<T>) {
|
|
59
|
-
const {
|
|
60
|
-
data,
|
|
61
|
-
categoryKey,
|
|
62
|
-
valueKey,
|
|
63
|
-
fillKey,
|
|
64
|
-
orientation = "vertical",
|
|
65
|
-
height,
|
|
66
|
-
color,
|
|
67
|
-
formatValue,
|
|
68
|
-
valueLabel = "Value",
|
|
69
|
-
} = props;
|
|
70
|
-
const theme = useLoticsTheme();
|
|
71
|
-
const fill = color ?? theme.accent;
|
|
72
|
-
const isHorizontal = orientation === "horizontal";
|
|
73
|
-
const resolvedHeight = height ?? (isHorizontal ? Math.max(120, data.length * 56) : 220);
|
|
74
|
-
return (
|
|
75
|
-
<View style={{ height: resolvedHeight }}>
|
|
76
|
-
<ResponsiveContainer width="100%" height="100%">
|
|
77
|
-
<BarChart
|
|
78
|
-
data={data}
|
|
79
|
-
layout={isHorizontal ? "vertical" : "horizontal"}
|
|
80
|
-
margin={
|
|
81
|
-
isHorizontal
|
|
82
|
-
? { top: 4, right: 40, left: 4, bottom: 4 }
|
|
83
|
-
: { top: 8, right: 0, left: 0, bottom: 0 }
|
|
84
|
-
}
|
|
85
|
-
barCategoryGap={isHorizontal ? "35%" : undefined}
|
|
86
|
-
>
|
|
87
|
-
<CartesianGrid
|
|
88
|
-
vertical={isHorizontal ? false : false}
|
|
89
|
-
horizontal={isHorizontal ? false : true}
|
|
90
|
-
stroke={colors.zinc[200]}
|
|
91
|
-
strokeDasharray="3 3"
|
|
92
|
-
/>
|
|
93
|
-
{isHorizontal ? (
|
|
94
|
-
<>
|
|
95
|
-
<XAxis type="number" hide />
|
|
96
|
-
<YAxis
|
|
97
|
-
type="category"
|
|
98
|
-
dataKey={categoryKey as string}
|
|
99
|
-
axisLine={false}
|
|
100
|
-
tickLine={false}
|
|
101
|
-
width={160}
|
|
102
|
-
tick={chartYAxisCategoryTickStyle}
|
|
103
|
-
/>
|
|
104
|
-
</>
|
|
105
|
-
) : (
|
|
106
|
-
<>
|
|
107
|
-
{/* Recharts v3's dataKey expects a TypedDataKey resolved from the
|
|
108
|
-
generic; our wrapper is generic over T so we pass the raw
|
|
109
|
-
string key. Cast at the boundary — consumers' data shape is
|
|
110
|
-
validated via the categoryKey: keyof T constraint. */}
|
|
111
|
-
<XAxis
|
|
112
|
-
dataKey={categoryKey as string}
|
|
113
|
-
axisLine={false}
|
|
114
|
-
tickLine={false}
|
|
115
|
-
tickMargin={8}
|
|
116
|
-
tick={chartAxisTickStyle}
|
|
117
|
-
/>
|
|
118
|
-
<YAxis hide />
|
|
119
|
-
</>
|
|
120
|
-
)}
|
|
121
|
-
<Tooltip
|
|
122
|
-
cursor={{ fill: colors.zinc[100] }}
|
|
123
|
-
contentStyle={chartTooltipStyle}
|
|
124
|
-
labelStyle={chartTooltipLabelStyle}
|
|
125
|
-
formatter={(val: unknown) => {
|
|
126
|
-
if (typeof val !== "number") return [String(val), valueLabel];
|
|
127
|
-
return [formatValue ? formatValue(val) : val.toLocaleString(), valueLabel];
|
|
128
|
-
}}
|
|
129
|
-
/>
|
|
130
|
-
<Bar
|
|
131
|
-
dataKey={valueKey as string}
|
|
132
|
-
fill={fill}
|
|
133
|
-
radius={isHorizontal ? [0, 4, 4, 0] : [6, 6, 0, 0]}
|
|
134
|
-
label={
|
|
135
|
-
isHorizontal
|
|
136
|
-
? {
|
|
137
|
-
position: "right",
|
|
138
|
-
fill: colors.zinc[700],
|
|
139
|
-
fontSize: 13,
|
|
140
|
-
fontWeight: 500,
|
|
141
|
-
}
|
|
142
|
-
: undefined
|
|
143
|
-
}
|
|
144
|
-
>
|
|
145
|
-
{fillKey &&
|
|
146
|
-
data.map((row, i) => (
|
|
147
|
-
<Cell key={i} fill={String(row[fillKey] ?? fill)} />
|
|
148
|
-
))}
|
|
149
|
-
</Bar>
|
|
150
|
-
</BarChart>
|
|
151
|
-
</ResponsiveContainer>
|
|
152
|
-
</View>
|
|
153
|
-
);
|
|
154
|
-
}
|