@lotics/ui 7.2.0 → 7.4.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
@@ -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.2.0",
3
+ "version": "7.4.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",
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
+ });
@@ -18,6 +18,8 @@ interface InlineMemberSelectProps {
18
18
  placeholder?: string;
19
19
  disabled?: boolean;
20
20
  accessibilityLabel?: string;
21
+ /** Show an in-menu search box to filter the roster; default false. */
22
+ searchable?: boolean;
21
23
  }
22
24
 
23
25
  /**
@@ -23,6 +23,8 @@ export interface InlineSelectProps<T extends string> {
23
23
  placeholder?: string;
24
24
  disabled?: boolean;
25
25
  accessibilityLabel?: string;
26
+ /** Show an in-menu search box; default false. */
27
+ searchable?: boolean;
26
28
  }
27
29
 
28
30
  /**
@@ -33,7 +35,7 @@ export interface InlineSelectProps<T extends string> {
33
35
  * standard and custom-rendered pickers are both supported here.
34
36
  */
35
37
  export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
36
- const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel } = props;
38
+ const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false } = props;
37
39
  const [open, setOpen] = useState(false);
38
40
  const [saving, setSaving] = useState(false);
39
41
  const [error, setError] = useState<string | null>(null);
@@ -78,7 +80,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
78
80
  </PopoverTrigger>
79
81
  <PopoverContent disableBodyScroll>
80
82
  <OptionList
81
- search={{ mode: "none" }}
83
+ search={{ mode: searchable ? "internal" : "none" }}
82
84
  options={options}
83
85
  value={value}
84
86
  onValueChange={(next) => void pick(next)}
@@ -32,6 +32,8 @@ interface MemberSelectProps<MULTI extends boolean = false> {
32
32
  placeholder?: string;
33
33
  disabled?: boolean;
34
34
  autoFocus?: boolean;
35
+ /** Show an in-menu search box to filter the roster; default false. */
36
+ searchable?: boolean;
35
37
  includeEmptyOption?: boolean;
36
38
  enableSelectAll?: boolean;
37
39
  /** Label for the "select all" link in multi-select mode. Pass a translated string. */