@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.
@@ -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
- /** Called whenever the search text changes. Provide it to drive server-side
37
- * search the consumer refreshes `options` in response. Omit for the default
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 dropdown menu component for the custom picker.
57
- * Can be used standalone (inside a Popover) or as part of the Picker component.
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 [searchQuery, setSearchQuery] = useState("");
105
-
106
- // Free entry: a trailing selectable row carrying the raw query as its value
107
- // (recognised below for distinct styling). See `customOptionFor` for the rules.
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 result;
132
- }, [options, enableSearch, searchQuery, includeEmptyOption, serverFiltered, customOpt]);
86
+ return options;
87
+ }, [options, includeEmptyOption]);
133
88
 
134
89
  const [focusedIndex, setFocusedIndex] = useState<number>(() => {
135
- const selectedIndex = filteredOptions.findIndex((opt) => {
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 = filteredOptions.findIndex((opt) => opt && !opt.disabled);
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
- let newValue: T[];
172
- if (currentValues.includes(itemValue)) {
173
- newValue = currentValues.filter((v) => v !== itemValue);
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 < filteredOptions.length) {
220
- const opt = filteredOptions[next];
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 = filteredOptions[next];
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 < filteredOptions.length) {
245
- const focusedOption = filteredOptions[focusedIndex];
186
+ if (focusedIndex >= 0 && focusedIndex < displayOptions.length) {
187
+ const focusedOption = displayOptions[focusedIndex];
246
188
  if (focusedOption && !focusedOption.disabled) {
247
- if (multi) {
248
- handleMultiSelect(focusedOption.value);
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, filteredOptions, multi, handleMultiSelect, handleSingleSelect, handleClose, scrollToIndex],
204
+ [focusedIndex, displayOptions, multi, handleMultiSelect, handleSingleSelect, handleClose, scrollToIndex],
266
205
  );
267
206
 
268
- const hasOptions = filteredOptions.some((opt) => opt && !opt.disabled);
269
- const hasSelectedOptions = multi && Array.isArray(multiValue) && multiValue.length > 0;
270
- const showSelectAllLink = multi && hasOptions && enableSelectAll;
271
- const showDeselectAllLink = multi && hasOptions && hasSelectedOptions && enableSelectAll;
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 handleSearchKeyPress = useCallback(
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
- if (handleKeyNavigation(e.nativeEvent.key)) {
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
- {enableSearch && (
289
- <TextInputField
290
- autoFocus={!screenSize.small}
291
- icon="search"
292
- value={searchQuery}
293
- onChangeText={handleSearchChange}
294
- placeholder={searchPlaceholder}
295
- placeholderTextColor={colors.zinc["400"]}
296
- autoCapitalize="none"
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 && filteredOptions.length === 0 && (
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
- {filteredOptions.map((item, index) => {
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={isCustom ? "picker-custom-option" : item.testID || `picker-option-${item.value}`}
287
+ testID={item.testID || `picker-option-${item.value}`}
327
288
  icon={multi ? <Checkbox checked={isSelected} /> : undefined}
328
289
  title={
329
- renderOptionContent && !isCustom ? (
290
+ renderOptionContent ? (
330
291
  renderOptionContent(item)
331
292
  ) : (
332
- <Text userSelect="none" numberOfLines={1} color={isCustom ? "zinc-500" : undefined}>
293
+ <Text userSelect="none" numberOfLines={1}>
333
294
  {item.label}
334
295
  </Text>
335
296
  )
336
297
  }
337
298
  right={
338
- isCustom ? (
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 { Pressable, StyleSheet } from "react-native";
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
- <Pressable style={[styles.trigger, { maxWidth: maxTriggerWidth }]}>
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
- </Pressable>
76
+ </PressableHighlight>
76
77
  </PopoverTrigger>
77
78
  <PopoverContent>
78
79
  <Stack style={styles.popoverBody}>
@@ -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
- }