@lotics/ui 7.3.0 → 7.5.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/AGENTS.md CHANGED
@@ -20,8 +20,8 @@ typeahead, async search, virtualization, and a11y the primitive already ships.
20
20
  per-instance `labels`/`clearLabel`/`selectAllLabel` still overrides for one-offs, and an un-wrapped
21
21
  app stays English. Wired so far: `Pagination`, `SortHeader`/`Table.sortLabels`, `OptionList`
22
22
  (+ `Select`/`Combobox` built on it), `FilterChip`, `Drawer`, `Confidence`, `RemainderMeter`,
23
- `ChangeReview`/`ChangeReviewActions`, and `DateRangeFilterField`/`DateFilter` (presets + footer +
24
- time-segment a11y, one `dateRange` slice). Adding a string to a wired component's `*Labels` forces
23
+ `ChangeReview`/`ChangeReviewActions`, `DateRangeFilterField`/`DateFilter` (presets + footer +
24
+ time-segment a11y, one `dateRange` slice), and `FormField` (the `optional` marker). Adding a string to a wired component's `*Labels` forces
25
25
  both packs in `locale.tsx` to fill it (compile error) — that's how the kit avoids a silent English
26
26
  leak. Not yet on the provider (pass `labels` for now): calendar/gantt, the file/preview/comment labels.
27
27
 
@@ -97,6 +97,13 @@ Pick by capability, not by name. (→ the source file for the API.)
97
97
  recharts), `RingGauge`, `ProgressBar` / `StackedProgressBar` / `StepProgress`, `Breakdown`
98
98
  (a stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
99
99
  "Show N more" toggle — `labels` to localize — so several facet cards align to one height in a row),
100
+ `Funnel` (a CONVERSION funnel — ordered stages as bars that NARROW; the step conversion rate is the
101
+ HEADLINE (a bold aligned row across the top, the first stage = the 100% baseline), the count is the
102
+ supporting figure below — the Amplitude/Mixpanel convention, never pin the rate to the fill height.
103
+ `orientation` vertical columns | horizontal bars; pass `onSelect`+`selectedKey` to make the bars
104
+ press-to-drill (the selected stays solid, others dim — the caller renders the records). The
105
+ subset/drop-off sibling of `StackedProgressBar` — nested cohorts that shrink "calls → connected →
106
+ won", NOT a whole split across stages — that's `StackedProgressBar`),
100
107
  `StatusGrid` + `StatusLegend`, `Heatmap` (density: colour-only, "where does it cluster"),
101
108
  `Matrix` (the PIVOT cross-tab: the NUMBER in each cell — optionally a heat wash behind it — plus
102
109
  row/column/grand totals; press a cell to drill).
@@ -801,6 +808,7 @@ step_progress · timeline (heterogeneous event LOG — per-row icon + expandable
801
808
  alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
802
809
  CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
803
810
  bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
811
+ funnel (Funnel — conversion funnel: narrowing bars + the step rate as a bold aligned headline row, count below; `orientation` vertical|horizontal, `onSelect`/`selectedKey` press-to-drill; the subset/drop-off sibling of stacked_progress_bar's whole-split) ·
804
812
  status_grid (StatusGrid + StatusLegend) · heatmap (density cross-tab — colour-only, no numbers) · matrix (Matrix — the PIVOT cross-tab: band-compound `Matrix` root + `Matrix.Header` (corner + axis labels) + `Matrix.Grid` (`display` number|heat|both — the cells, pressable, the value IN the cell) + `Matrix.Totals` (row + column + grand) + `Matrix.Legend`; the data layer `matrixTotals`/`MatrixAxisItem`/`MatrixCellRef` import from `@lotics/ui/matrix_totals`. Pick over `Heatmap` when the NUMBER and totals matter, not just where it clusters) · legend_item · remainder_meter · allocation_row ·
805
813
  scan_field · file_dropzone · files_editor (FilesEditor — THE all-in-one attachment field: FileGrid + a toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode (Select all · a Menu of Download/Share/Delete · Done) + built-in gallery + Alert-confirmed remove; host wires `files` + `onAdd`/`onRemove` (+ optional `uploads`, `onShareSelected`, `readOnly`, `labels`, `galleryLabels`); mirrors the frontend cell_files_editor. Use FileGrid/FileRows bare only when you own the chrome) · file_grid (FileGrid — the upload-aware grid: completed files + a live upload queue in one surface; FileUpload/PendingUpload types; the add-files default) · uploading_thumbnail (UploadingThumbnail — the single in-flight upload tile FileGrid renders; reach for it only when hand-rolling a non-grid upload layout) · file_thumbnail · file_thumbnail_grid · file_row · file_rows (FileRows — batteries-included file list: row press → built-in gallery + a ⋯ Download/Open-external/Remove menu; composes FileRow + ActionMenu + FileGalleryModal) · file_preview ·
806
814
  file_gallery_modal · image_gallery · use_selection_mode · share_or_download · rotate_image · avatar · skeleton · activity_indicator · loading · divider ·
@@ -32,6 +32,7 @@ import { type IconName } from "@lotics/ui/icon";
32
32
  import { KPIStrip } from "@lotics/ui/kpi_strip";
33
33
  import { Peek } from "@lotics/ui/peek";
34
34
  import { StackedProgressBar } from "@lotics/ui/stacked_progress_bar";
35
+ import { Funnel } from "@lotics/ui/funnel";
35
36
  import { formatMoney } from "@lotics/ui/format_money";
36
37
 
37
38
  // ─────────────────────────────────────────────────────────────────────────────
@@ -409,6 +410,22 @@ export function TplDashboard() {
409
410
  </Card>
410
411
  </View>
411
412
 
413
+ {/* chart row 3: the SAME pipeline as a CONVERSION funnel — narrowing
414
+ cohorts + step drop-off rates, the subset lens vs the composition bar above */}
415
+ <View style={{ flexDirection: "row", gap: 16 }}>
416
+ <Card style={{ padding: 0, flex: 1 }}>
417
+ <CardHeader>
418
+ <CardHeaderTitle info="Of all prospects, how many reach each stage — the drop-off from first contact to a closed win">
419
+ Conversion funnel
420
+ </CardHeaderTitle>
421
+ <CardHeaderMeta>This quarter</CardHeaderMeta>
422
+ </CardHeader>
423
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
424
+ <Funnel stages={PHEU_STAGES.map((s) => ({ key: s.key, label: s.label, value: s.count }))} color={solid("blue")} />
425
+ </View>
426
+ </Card>
427
+ </View>
428
+
412
429
  {/* needs attention — each row expands in place to the records behind its count */}
413
430
  <Card style={{ padding: 0 }}>
414
431
  <CardHeader>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "7.3.0",
3
+ "version": "7.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -53,6 +53,7 @@
53
53
  "./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
54
54
  "./legend_item": "./src/legend_item.tsx",
55
55
  "./breakdown": "./src/breakdown.tsx",
56
+ "./funnel": "./src/funnel.tsx",
56
57
  "./status_grid": "./src/status_grid.tsx",
57
58
  "./heatmap": "./src/heatmap.tsx",
58
59
  "./matrix": "./src/matrix.tsx",
@@ -2,6 +2,7 @@ import { createContext, useContext, useId } from "react";
2
2
  import { StyleProp, View, ViewStyle } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Spacer } from "./spacer";
5
+ import { useLoticsLocale } from "./locale";
5
6
 
6
7
  export interface FormFieldProps {
7
8
  label: string;
@@ -9,7 +10,8 @@ export interface FormFieldProps {
9
10
  description?: string;
10
11
  error?: string;
11
12
  style?: StyleProp<ViewStyle>;
12
- /** Label shown next to `label` when `optional` is true. Default: "Optional". Pass a translated string from the consumer. */
13
+ /** Label shown next to `label` when `optional` is true. Defaults to the locale
14
+ * pack's `formField.optional` ("Optional"); pass to override for one instance. */
13
15
  optionalLabel?: string;
14
16
  }
15
17
 
@@ -40,7 +42,8 @@ export function useFormField(): FormFieldBinding | null {
40
42
  }
41
43
 
42
44
  export function FormField(props: FormFieldProps & { children: React.ReactNode }) {
43
- const { label, description, error, optional, style, children, optionalLabel = "Optional" } = props;
45
+ const { label, description, error, optional, style, children, optionalLabel } = props;
46
+ const resolvedOptionalLabel = optionalLabel ?? useLoticsLocale().formField.optional;
44
47
 
45
48
  const inputId = useId();
46
49
  const labelId = `${inputId}-label`;
@@ -72,7 +75,7 @@ export function FormField(props: FormFieldProps & { children: React.ReactNode })
72
75
  )}
73
76
  {!!optional && (
74
77
  <Text numberOfLines={1} size="sm" color="zinc-500">
75
- {optionalLabel}
78
+ {resolvedOptionalLabel}
76
79
  </Text>
77
80
  )}
78
81
  </View>
package/src/funnel.tsx ADDED
@@ -0,0 +1,218 @@
1
+ import { type ReactNode } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { colors } from "./colors";
4
+ import { Text } from "./text";
5
+ import { PressableHighlight } from "./pressable_highlight";
6
+
7
+ export interface FunnelStage {
8
+ /** Identifier — the React key + the `onSelect` value. */
9
+ key: string;
10
+ /** Stage name (the widest / top-of-funnel stage first). */
11
+ label: string;
12
+ /** Count at this stage. Expected to be non-increasing down the list. */
13
+ value: number;
14
+ }
15
+
16
+ export interface FunnelProps {
17
+ /** Ordered stages, widest (top of funnel) first. Each bar is sized `value ÷
18
+ * stages[0].value`, so the bars narrow through the funnel and the drop-off
19
+ * IS the shape. */
20
+ stages: FunnelStage[];
21
+ /** `vertical` = columns side-by-side that fill the width evenly, height ∝
22
+ * value capped at `size`; `horizontal` = stacked rows, bar width ∝ value.
23
+ * Default `vertical`. */
24
+ orientation?: "vertical" | "horizontal";
25
+ /** Bar fill — one accent for the whole funnel (one cohort narrowing, not many
26
+ * categories). Defaults to the neutral ink. */
27
+ color?: string;
28
+ /** Format a stage value (default `toLocaleString`). */
29
+ formatValue?: (n: number) => string;
30
+ /** Format the step conversion rate beside a stage (its value ÷ the stage
31
+ * above). Default `"NN%"`; return `null` to hide a rate. */
32
+ formatRate?: (rate: number) => string | null;
33
+ /** Make the bars press-to-drill: pressing a stage calls `onSelect(key)`, and
34
+ * pressing the selected stage again calls `onSelect(null)`. Pair with
35
+ * `selectedKey` — the selected bar stays solid, the others dim. The drill
36
+ * CONTENT (the records behind the stage) is the caller's to render. */
37
+ onSelect?: (key: string | null) => void;
38
+ /** The drilled stage (dims the others). */
39
+ selectedKey?: string | null;
40
+ /** xs muted uppercase eyebrow above the funnel. Omit inside a band that
41
+ * already names it. */
42
+ title?: string;
43
+ /** Vertical: the column area height (the cap). Horizontal: bar thickness.
44
+ * Defaults 160 / 28. */
45
+ size?: number;
46
+ accessibilityLabel?: string;
47
+ }
48
+
49
+ const defaultFormatValue = (n: number): string => n.toLocaleString("en-US");
50
+ const defaultFormatRate = (rate: number): string => `${Math.round(rate * 100)}%`;
51
+ const RADIUS = 2;
52
+ const DIM = 0.28;
53
+
54
+ /**
55
+ * Conversion funnel — ordered stages as bars that NARROW (each sized value ÷
56
+ * the top stage), with the step conversion rate (value ÷ the stage above)
57
+ * beside each. The drop-off is the shape, so it reads "where do they fall
58
+ * out?" at a glance. `vertical` columns or `horizontal` bars; pass `onSelect`
59
+ * to make the bars press-to-drill (select/deselect, the others dim).
60
+ *
61
+ * The SUBSET/conversion sibling of `StackedProgressBar`: a funnel is nested
62
+ * cohorts that shrink (calls → connected → won), NOT a composition of a whole
63
+ * (StackedProgressBar — items distributed ACROSS stages that sum to a total).
64
+ */
65
+ export function Funnel(props: FunnelProps) {
66
+ const {
67
+ stages,
68
+ orientation = "vertical",
69
+ color = colors.zinc[800],
70
+ formatValue = defaultFormatValue,
71
+ formatRate = defaultFormatRate,
72
+ onSelect,
73
+ selectedKey = null,
74
+ title,
75
+ size = orientation === "vertical" ? 160 : 28,
76
+ accessibilityLabel,
77
+ } = props;
78
+
79
+ const top = stages[0]?.value ?? 0;
80
+ const fractionOf = (v: number) => (top > 0 ? Math.max(0, Math.min(1, v / top)) : 0);
81
+ const rateOf = (i: number) => {
82
+ const prev = i > 0 ? stages[i - 1].value : null;
83
+ return prev != null && prev > 0 ? formatRate(stages[i].value / prev) : null;
84
+ };
85
+ const fillOpacity = (key: string) => (selectedKey != null && selectedKey !== key ? DIM : 1);
86
+
87
+ // A bar is pressable only when onSelect is wired; press the selected one again
88
+ // to clear. `fill` makes the press target span the column (vertical).
89
+ const press = (key: string, label: string, fill: boolean, node: ReactNode) => {
90
+ if (!onSelect) return node;
91
+ return (
92
+ <PressableHighlight
93
+ accessibilityRole="button"
94
+ accessibilityState={{ selected: selectedKey === key }}
95
+ accessibilityLabel={label}
96
+ onPress={() => onSelect(selectedKey === key ? null : key)}
97
+ style={fill ? styles.pressFill : undefined}
98
+ >
99
+ {node}
100
+ </PressableHighlight>
101
+ );
102
+ };
103
+
104
+ const titleNode = title ? (
105
+ <Text size="xs" color="muted" transform="uppercase">
106
+ {title}
107
+ </Text>
108
+ ) : null;
109
+
110
+ if (orientation === "vertical") {
111
+ return (
112
+ <View style={styles.vContainer} accessibilityLabel={accessibilityLabel}>
113
+ {titleNode}
114
+ {/* the conversion rate is the headline — an aligned row across the top
115
+ (the funnel's story, read left→right); the first stage is the 100%
116
+ entry baseline. */}
117
+ <View style={styles.vRow}>
118
+ {stages.map((stage, i) => {
119
+ const rate = i === 0 ? formatRate(1) : rateOf(i);
120
+ return (
121
+ <View key={stage.key} style={styles.vCell}>
122
+ {rate ? (
123
+ <Text size="sm" weight="semibold" tabular>
124
+ {rate}
125
+ </Text>
126
+ ) : null}
127
+ </View>
128
+ );
129
+ })}
130
+ </View>
131
+ <View style={[styles.vRow, { height: size }]}>
132
+ {stages.map((stage) => (
133
+ <View key={stage.key} style={styles.vSlot}>
134
+ {press(
135
+ stage.key,
136
+ `${stage.label}: ${formatValue(stage.value)}`,
137
+ true,
138
+ <View style={styles.vTrack}>
139
+ <View style={[styles.vFill, { height: `${fractionOf(stage.value) * 100}%`, backgroundColor: color, opacity: fillOpacity(stage.key) }]} />
140
+ </View>,
141
+ )}
142
+ </View>
143
+ ))}
144
+ </View>
145
+ {/* count (supporting) + label below each column. */}
146
+ <View style={styles.vRow}>
147
+ {stages.map((stage) => (
148
+ <View key={stage.key} style={styles.vCaption}>
149
+ <Text size="sm" tabular>
150
+ {formatValue(stage.value)}
151
+ </Text>
152
+ <Text size="xs" color="muted" numberOfLines={1} style={styles.center}>
153
+ {stage.label}
154
+ </Text>
155
+ </View>
156
+ ))}
157
+ </View>
158
+ </View>
159
+ );
160
+ }
161
+
162
+ return (
163
+ <View style={styles.hContainer} accessibilityLabel={accessibilityLabel}>
164
+ {titleNode}
165
+ {stages.map((stage, i) => {
166
+ const rate = rateOf(i);
167
+ return (
168
+ <View key={stage.key}>
169
+ {press(
170
+ stage.key,
171
+ `${stage.label}: ${formatValue(stage.value)}`,
172
+ false,
173
+ <View style={styles.hStage}>
174
+ <View style={styles.hHead}>
175
+ <Text size="sm" weight="medium">
176
+ {stage.label}
177
+ </Text>
178
+ {rate ? (
179
+ <Text size="xs" color="muted" tabular>
180
+ {rate}
181
+ </Text>
182
+ ) : null}
183
+ <View style={styles.spacer} />
184
+ <Text size="sm" weight="semibold" tabular>
185
+ {formatValue(stage.value)}
186
+ </Text>
187
+ </View>
188
+ <View style={[styles.hTrack, { height: size }]}>
189
+ <View style={[styles.hFill, { width: `${fractionOf(stage.value) * 100}%`, height: size, backgroundColor: color, opacity: fillOpacity(stage.key) }]} />
190
+ </View>
191
+ </View>,
192
+ )}
193
+ </View>
194
+ );
195
+ })}
196
+ </View>
197
+ );
198
+ }
199
+
200
+ const styles = StyleSheet.create({
201
+ // vertical — columns fill the width evenly (flex:1), height ∝ value, capped at `size`
202
+ vContainer: { gap: 8 },
203
+ vRow: { flexDirection: "row", alignItems: "flex-end", gap: 10 },
204
+ vCell: { flex: 1, alignItems: "center" },
205
+ vSlot: { flex: 1, height: "100%" },
206
+ pressFill: { flex: 1, height: "100%" },
207
+ vTrack: { flex: 1, width: "100%", borderRadius: RADIUS, backgroundColor: colors.zinc[100], overflow: "hidden", justifyContent: "flex-end" },
208
+ vFill: { width: "100%", borderRadius: RADIUS },
209
+ vCaption: { flex: 1, alignItems: "center", gap: 1 },
210
+ center: { textAlign: "center" },
211
+ // horizontal — stacked rows, bar width ∝ value
212
+ hContainer: { gap: 12 },
213
+ hStage: { gap: 6 },
214
+ hHead: { flexDirection: "row", alignItems: "baseline", gap: 8 },
215
+ spacer: { flex: 1 },
216
+ hTrack: { borderRadius: RADIUS, backgroundColor: colors.zinc[100], overflow: "hidden" },
217
+ hFill: { borderRadius: RADIUS },
218
+ });
package/src/locale.tsx CHANGED
@@ -24,12 +24,14 @@ export interface LoticsLocale {
24
24
  /** `SortHeader` a11y prefix + asc/desc suffixes. */
25
25
  sortHeader: Required<SortHeaderLabels>;
26
26
  /** `OptionList` (and everything built on it — `Select`, `Combobox`, the
27
- * in-cell editors): the select-all/deselect-all links, the empty state, and
28
- * the `Combobox` recents header. */
29
- optionList: { selectAll: string; deselectAll: string; noResults: string; recent: string };
27
+ * in-cell editors): the select-all/deselect-all links, the empty state, the
28
+ * internal search-field placeholder, and the `Combobox` recents header. */
29
+ optionList: { selectAll: string; deselectAll: string; noResults: string; recent: string; searchPlaceholder: string };
30
30
  /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
31
31
  * a call site doesn't pass a dimension-specific `clearLabel`. */
32
32
  filterChip: { clear: string };
33
+ /** `FormField`: the "Optional" marker shown next to the label when `optional`. */
34
+ formField: { optional: string };
33
35
  /** `Drawer`: the record prev/next + close controls (screen-reader names). */
34
36
  drawer: { previous: string; next: string; close: string };
35
37
  /** `Confidence`: the full level phrase ("High confidence" …). */
@@ -74,8 +76,9 @@ export const en: LoticsLocale = {
74
76
  ascending: ", ascending",
75
77
  descending: ", descending",
76
78
  },
77
- optionList: { selectAll: "Select all", deselectAll: "Deselect all", noResults: "No results", recent: "Recent" },
79
+ optionList: { selectAll: "Select all", deselectAll: "Deselect all", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
78
80
  filterChip: { clear: "Clear" },
81
+ formField: { optional: "Optional" },
79
82
  drawer: { previous: "Previous record", next: "Next record", close: "Close" },
80
83
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
81
84
  remainderMeter: {
@@ -123,8 +126,9 @@ export const vi: LoticsLocale = {
123
126
  ascending: " (tăng dần)",
124
127
  descending: " (giảm dần)",
125
128
  },
126
- optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", noResults: "Không có kết quả", recent: "Gần đây" },
129
+ optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
127
130
  filterChip: { clear: "Xóa" },
131
+ formField: { optional: "Tùy chọn" },
128
132
  drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
129
133
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
130
134
  remainderMeter: {
@@ -58,7 +58,7 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
58
58
  icon="search"
59
59
  value={list.query}
60
60
  onChangeText={list.setQuery}
61
- placeholder={search.placeholder ?? "Search…"}
61
+ placeholder={search.placeholder ?? loc.searchPlaceholder}
62
62
  autoFocus={!small}
63
63
  autoCapitalize="none"
64
64
  autoCorrect={false}