@lotics/ui 40.2.0 → 41.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/MIGRATION.md CHANGED
@@ -4,6 +4,48 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 41.0.0 — `RingGauge` is `ProgressRing`, and circular progress is one component
8
+
9
+ `RingGauge` is **deleted**. `ProgressRing` replaces it and covers both what it did and the
10
+ bare arc it could not.
11
+
12
+ ```diff
13
+ - import { RingGauge } from "@lotics/ui/ring_gauge";
14
+ + import { ProgressRing } from "@lotics/ui/progress_ring";
15
+
16
+ - <RingGauge value={87} label="On time" caption="33 of 38 deliveries" />
17
+ + <ProgressRing value={87} label="On time" caption="33 of 38 deliveries" />
18
+ ```
19
+
20
+ For that shape nothing else changes — same 140px default, same figure, same label column.
21
+
22
+ **What is new is the other end of the axis.** `format="none"` with no label leaves the arc
23
+ bare, at any size, which is what makes one component enough:
24
+
25
+ ```tsx
26
+ <ProgressRing value={15200} max={1000000} size={16} format="none" />
27
+ ```
28
+
29
+ **Why it had to be one.** `RingGauge` and `ProgressBar` were the same concept in two
30
+ geometries built to two APIs — the ring took 0–100 where the bar takes `value`/`max`, hardcoded
31
+ `Math.round(value)%` in its centre where the bar has a `format` axis, and *required* a label
32
+ where the bar's title is optional. Reaching for a second, smaller ring component made the
33
+ duplication obvious rather than fixing it. `ProgressRing` now mirrors `ProgressBar`: a reader
34
+ who knows one knows the other.
35
+
36
+ Two consequences of that mirroring worth knowing:
37
+
38
+ - **Pass the real pair, not a percentage.** `value`/`max` (default `max: 100`, so an existing
39
+ 0–100 value still reads directly). The arc and the figure then derive from the same untouched
40
+ numbers, which is how they stay in agreement.
41
+ - **`thickness` derives from `size`** — 10% of the diameter, floored at 2 — because no fixed
42
+ default serves both 16 and 140, and 14 at icon scale is absurd. The centre figure's type
43
+ scales the same way. Both are still overridable.
44
+
45
+ A bare ring renders no text and so announces nothing to a screen reader. That is correct for a
46
+ decoration inside a labelled control, and it means a bare ring must never be the only thing
47
+ carrying a meaning — whatever owns it says what it measures.
48
+
7
49
  ## 40.0.0 — `PopoverContent` decides its own presentation; `small` is gone
8
50
 
9
51
  `PopoverContent` took a `small` prop meaning "render as a bottom sheet". It now asks
package/docs/catalog.md CHANGED
@@ -209,7 +209,7 @@ Two columnar shapes, and the choice is about data size:
209
209
  `KPIStrip` (the dashboard stat band), `SummaryLine` (the light inline register/list summary
210
210
  — below the toolbar, from the filtered rows), `KPICard` / `Metric` (headline figures),
211
211
  `TrendChip` (delta), `Sparkline`, `BarChart` / `LineChart` / `PieChart` (the canonical SVG
212
- set — no recharts), `RingGauge`, `ProgressBar` (its `compact` prop = ONE row, track + a
212
+ set — no recharts), `ProgressRing`, `ProgressBar` (its `compact` prop = ONE row, track + a
213
213
  plain sm tabular count beside it — the cell/heading/peek-trigger meter; a caption floating
214
214
  above a tiny bar reads misaligned. **The track clamps at 100%, the caption does not** — over
215
215
  its max it reads `2,100 / 2,000 (105%)`, because a meter that says "100%" when you are over
@@ -1329,9 +1329,17 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1329
1329
  - **`sparkline`** — `Sparkline`: the inline mini trend line.
1330
1330
  - **`bar_chart`** / **`line_chart`** / **`pie_chart`** — `BarChart` / `LineChart` /
1331
1331
  `PieChart`: the canonical SVG chart set (no recharts).
1332
- - **`ring_gauge`** — `RingGauge`: single-fraction circular gauge.
1333
1332
  - **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
1334
1333
  plain sm tabular count beside it.
1334
+ - **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one
1335
+ (it replaced `RingGauge`). Same API shape as the bar — a real `value`/`max` rather than a
1336
+ pre-computed percentage, optional `label`/`caption`, and a `format` that turns the centre
1337
+ figure off. `format="none"` with no label leaves the arc bare at any size: that is the
1338
+ TRIGGER form, for a control row where a figure would be unreadable and a bar would not
1339
+ read. `thickness` derives from `size` (10%, floored at 2) because no fixed default serves
1340
+ both 16 and 140, and the centre figure's type scales with the ring for the same reason. A
1341
+ bare ring renders no text, so it announces nothing — whatever owns it must say what it
1342
+ measures.
1335
1343
  - **`stacked_progress_bar`** — `StackedProgressBar`: one whole split across segments (a
1336
1344
  status mix on one bar).
1337
1345
  - **`step_progress`** — `StepProgress`: N-of-M dots/segments progress. A real `progressbar`
package/docs/templates.md CHANGED
@@ -142,7 +142,7 @@ the pipeline (`StackedProgressBar`, blue = pipeline), a **Needs attention** `Acc
142
142
  (icon + label + count `Badge`; expanding opens the records behind the count, each row
143
143
  carrying its domain `⋯ ActionMenu`), top customers (`Avatar` + revenue + margin badge; the
144
144
  name `Peek`s the dossier). Also exercises the wider chart family (`LineChart`, `PieChart`,
145
- `RingGauge`, `Sparkline`, `TrendFooter`, `Funnel`) and `DateRangeFilterField` — the date
145
+ `ProgressRing`, `Sparkline`, `TrendFooter`, `Funnel`) and `DateRangeFilterField` — the date
146
146
  filter actually filters the KPI datasets. Start here for any "state of the operation"
147
147
  landing screen.
148
148
 
@@ -15,7 +15,7 @@ import { Badge } from "@lotics/ui/badge";
15
15
  import { BarChart } from "@lotics/ui/bar_chart";
16
16
  import { LineChart } from "@lotics/ui/line_chart";
17
17
  import { PieChart } from "@lotics/ui/pie_chart";
18
- import { RingGauge } from "@lotics/ui/ring_gauge";
18
+ import { ProgressRing } from "@lotics/ui/progress_ring";
19
19
  import { Sparkline } from "@lotics/ui/sparkline";
20
20
  import { TrendFooter } from "@lotics/ui/trend_footer";
21
21
  import { Button } from "@lotics/ui/button";
@@ -405,7 +405,7 @@ export function TplDashboard() {
405
405
  </CardHeaderTitle>
406
406
  </CardHeader>
407
407
  <View style={{ paddingHorizontal: 20, paddingVertical: 16, flex: 1, alignItems: "center", justifyContent: "center" }}>
408
- <RingGauge value={87} label="On time" caption="33 of 38 deliveries" color={solid("emerald")} />
408
+ <ProgressRing value={87} label="On time" caption="33 of 38 deliveries" color={solid("emerald")} />
409
409
  </View>
410
410
  </Card>
411
411
  </View>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "40.2.0",
3
+ "version": "41.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -73,7 +73,7 @@
73
73
  "./format_money": "./src/format_money.ts",
74
74
  "./calendar": "./src/calendar/index.ts",
75
75
  "./gantt": "./src/gantt/index.ts",
76
- "./ring_gauge": "./src/ring_gauge.tsx",
76
+ "./progress_ring": "./src/progress_ring.tsx",
77
77
  "./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
78
78
  "./legend_item": "./src/legend_item.tsx",
79
79
  "./breakdown": "./src/breakdown.tsx",
@@ -26,6 +26,17 @@ export interface ProgressBarProps {
26
26
  * and the percentage too, which is how a 99.6%-full meter starts claiming it is exactly full.
27
27
  */
28
28
  formatValue?: (n: number) => string;
29
+ /**
30
+ * How the PERCENTAGE in the caption renders, given `value / max` as a fraction
31
+ * (which can exceed 1 — see the clamping note below). Defaults to whole points.
32
+ *
33
+ * Separate from `formatValue` because they answer different questions and a
34
+ * caller needs both: `formatValue` reshapes the two amounts, this reshapes the
35
+ * ratio between them. Without it a surface that renders percentages of its own
36
+ * cannot make them agree with this one — a meter reading "(2%)" beside a row
37
+ * reading "1,5%", where both are 1.52%, is the same number twice in one panel.
38
+ */
39
+ formatPercent?: (fraction: number) => string;
29
40
  color?: string;
30
41
  completeColor?: string;
31
42
  /** COMPACT: one row — the track (flex) with a plain sm tabular count beside
@@ -48,6 +59,7 @@ export function ProgressBar(props: ProgressBarProps) {
48
59
  title,
49
60
  format = "percentage",
50
61
  formatValue,
62
+ formatPercent,
51
63
  color = colors.blue["500"],
52
64
  completeColor = colors.green["500"],
53
65
  compact = false,
@@ -67,10 +79,13 @@ export function ProgressBar(props: ProgressBarProps) {
67
79
  const percentage = Math.min(100, ratio);
68
80
  const isComplete = ratio >= 100;
69
81
  const num = formatValue ?? ((n: number) => n.toLocaleString(localeTag));
82
+ const pct = formatPercent
83
+ ? (r: number) => formatPercent(r / 100)
84
+ : (r: number) => `${Math.round(r)}%`;
70
85
 
71
86
  if (compact) {
72
87
  const label =
73
- format === "percentage" ? `${Math.round(ratio)}%` : `${num(value)}/${num(max)}`;
88
+ format === "percentage" ? pct(ratio) : `${num(value)}/${num(max)}`;
74
89
  return (
75
90
  <View style={styles.compactRow}>
76
91
  <View style={[styles.track, styles.compactTrack]}>
@@ -87,9 +102,9 @@ export function ProgressBar(props: ProgressBarProps) {
87
102
 
88
103
  const caption =
89
104
  format === "fraction"
90
- ? `${num(value)} / ${num(max)} (${Math.round(ratio)}%)`
105
+ ? `${num(value)} / ${num(max)} (${pct(ratio)})`
91
106
  : format === "percentage"
92
- ? `${Math.round(ratio)}%`
107
+ ? pct(ratio)
93
108
  : null;
94
109
 
95
110
  return (
@@ -0,0 +1,148 @@
1
+ import { View } from "react-native";
2
+ import { colors } from "./colors";
3
+ import { Text } from "./text";
4
+
5
+ export type ProgressRingFormat = "percentage" | "none";
6
+
7
+ export interface ProgressRingProps {
8
+ value: number;
9
+ /** What `value` is a fraction OF. Default 100, so a value already expressed
10
+ * as a percentage reads directly. Pass the real pair (tokens used / window,
11
+ * slots filled / slots) rather than pre-computing: the arc and the figure
12
+ * then derive from the same untouched numbers, which is how they stay in
13
+ * agreement. */
14
+ max?: number;
15
+ /** Diameter in px. Default 140 — the dashboard figure. Drop to ~16 for a
16
+ * control row or a trigger, where the arc is the whole content. */
17
+ size?: number;
18
+ /** Ring stroke. Derived from `size` when unset — 10% of the diameter, floored
19
+ * at 2px so a small ring still paints. A fixed default cannot serve both
20
+ * scales: 14 is right at 140 and absurd at 16. */
21
+ thickness?: number;
22
+ /** Short label under the ring. */
23
+ label?: string;
24
+ /** One-line context under the label. */
25
+ caption?: string;
26
+ /** What the CENTRE shows. `none` leaves the arc bare — the trigger form,
27
+ * where a figure would be unreadable anyway. */
28
+ format?: ProgressRingFormat;
29
+ /** Arc color. */
30
+ color?: string;
31
+ /** The unfilled remainder. */
32
+ trackColor?: string;
33
+ }
34
+
35
+ /** The centre figure only reads at dashboard scale; below that it must shrink
36
+ * with the ring or it overflows the circle it sits in. */
37
+ function centreTextSize(size: number): "sm" | "lg" | "xxl" | "xxxl" {
38
+ if (size >= 120) return "xxxl";
39
+ if (size >= 88) return "xxl";
40
+ if (size >= 56) return "lg";
41
+ return "sm";
42
+ }
43
+
44
+ /**
45
+ * Circular progress — the ring form of `ProgressBar`, and the ONLY one.
46
+ *
47
+ * Same concept, different geometry, so it takes the same shape of API: a real
48
+ * `value`/`max` rather than a pre-computed percentage, optional `label` and
49
+ * `caption` slots, and a `format` that can turn the figure off entirely. A
50
+ * reader who knows the bar knows this.
51
+ *
52
+ * The `format="none"` end of that axis is what makes one component enough. At
53
+ * trigger scale the arc IS the content — no figure, no label, nothing to read —
54
+ * and at dashboard scale the figure leads with the label beneath it. That was
55
+ * two components (`RingGauge` and a bare ring) until the second one made it
56
+ * obvious they were one component with a suppressed centre.
57
+ *
58
+ * With `format="none"` and no label it renders no text at all, so it announces
59
+ * nothing to a screen reader. That is correct for a decoration inside a labelled
60
+ * control — but it means a bare ring must never be the only thing carrying a
61
+ * meaning: whatever owns it says what it measures.
62
+ *
63
+ * Implementation: native HTML `<svg>` (like `Sparkline`) — Vite can't resolve
64
+ * react-native-svg's native paths, and the `<View>` wrapper preserves the RN
65
+ * layout surface. The arc starts at 12 o'clock (`rotate(-90)`) and grows
66
+ * clockwise via `strokeDasharray`.
67
+ */
68
+ export function ProgressRing(props: ProgressRingProps) {
69
+ const {
70
+ value,
71
+ max = 100,
72
+ size = 140,
73
+ thickness = Math.max(2, size * 0.1),
74
+ label,
75
+ caption,
76
+ format = "percentage",
77
+ color = colors.teal[600],
78
+ trackColor = colors.zinc[100],
79
+ } = props;
80
+
81
+ // The ARC clamps — an arc longer than its own circle is meaningless — while
82
+ // the figure reports the true ratio, the same split `ProgressBar` makes. A
83
+ // ring reading 100% while the number says 130% is the honest rendering of
84
+ // being over.
85
+ const ratio = max > 0 ? (value / max) * 100 : 0;
86
+ const clamped = Math.max(0, Math.min(100, ratio));
87
+ const center = size / 2;
88
+ const radius = (size - thickness) / 2;
89
+ const circumference = 2 * Math.PI * radius;
90
+ const dash = (clamped / 100) * circumference;
91
+
92
+ const ring = (
93
+ <View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
94
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ position: "absolute" }}>
95
+ <circle
96
+ cx={center}
97
+ cy={center}
98
+ r={radius}
99
+ fill="none"
100
+ stroke={trackColor}
101
+ strokeWidth={thickness}
102
+ />
103
+ {/* A zero-length dash still paints a dot under `round` caps, which reads
104
+ as progress where there is none. Omit the arc entirely at 0. */}
105
+ {clamped > 0 ? (
106
+ <circle
107
+ cx={center}
108
+ cy={center}
109
+ r={radius}
110
+ fill="none"
111
+ stroke={color}
112
+ strokeWidth={thickness}
113
+ strokeLinecap="round"
114
+ strokeDasharray={`${dash} ${circumference}`}
115
+ transform={`rotate(-90 ${center} ${center})`}
116
+ />
117
+ ) : null}
118
+ </svg>
119
+ {format === "percentage" ? (
120
+ <Text size={centreTextSize(size)} weight="semibold">
121
+ {`${Math.round(ratio)}%`}
122
+ </Text>
123
+ ) : null}
124
+ </View>
125
+ );
126
+
127
+ // No label and no caption is the trigger form: the ring alone, with none of
128
+ // the column's centring or gap to shift it inside a control row.
129
+ if (!label && !caption) return ring;
130
+
131
+ return (
132
+ <View style={{ alignItems: "center", gap: 12 }}>
133
+ {ring}
134
+ <View style={{ alignItems: "center", gap: 2 }}>
135
+ {label ? (
136
+ <Text size="md" weight="semibold">
137
+ {label}
138
+ </Text>
139
+ ) : null}
140
+ {caption ? (
141
+ <Text size="sm" color="muted">
142
+ {caption}
143
+ </Text>
144
+ ) : null}
145
+ </View>
146
+ </View>
147
+ );
148
+ }
package/src/vite.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  *
23
23
  * Why each family is here:
24
24
  * - `react-native-svg` — @lotics/ui's svg charts (PieChart/LineChart/Sparkline/
25
- * RingGauge) reach a named-export entry the dev optimizer won't resolve
25
+ * ProgressRing) reach a named-export entry the dev optimizer won't resolve
26
26
  * unbundled ("does not provide an export named 'parse'").
27
27
  * - `react-native-web` + `@react-native/normalize-colors` + the `inline-style-
28
28
  * prefixer` / `fbjs` / `styleq` / `postcss-value-parser` / `nullthrows` core —
@@ -1,72 +0,0 @@
1
- import { View } from "react-native";
2
- import { colors } from "./colors";
3
- import { Text } from "./text";
4
-
5
- export interface RingGaugeProps {
6
- /** Progress value, 0–100. Clamped to that range. */
7
- value: number;
8
- /** Short label under the ring. */
9
- label: string;
10
- /** Optional one-line context under the label. */
11
- caption?: string;
12
- /** Diameter in px. Default 128. */
13
- size?: number;
14
- /** Ring stroke width in px. Default 14 — thinner reads weak. */
15
- thickness?: number;
16
- /** Arc color. Default teal accent. */
17
- color?: string;
18
- }
19
-
20
- /**
21
- * Circular progress gauge — an at-a-glance ring for a 0–100% metric
22
- * (on-time rate, SLA compliance, win rate). The percentage leads in the
23
- * center; the track behind it shows the remaining-to-100 context. Pairs
24
- * with `KPICard` (figures) — use a ring when the number IS a ratio to 100.
25
- *
26
- * Implementation: native HTML `<svg>` (like `Sparkline`) — Vite can't
27
- * resolve react-native-svg's native paths, and the `<View>` wrapper
28
- * preserves the RN layout surface. The arc starts at 12 o'clock
29
- * (`rotate(-90)`) and grows clockwise via `strokeDasharray`.
30
- */
31
- export function RingGauge(props: RingGaugeProps) {
32
- const { value, label, caption, size = 140, thickness = 14, color = colors.teal[600] } = props;
33
- const clamped = Math.max(0, Math.min(100, value));
34
- const center = size / 2;
35
- const radius = (size - thickness) / 2;
36
- const circumference = 2 * Math.PI * radius;
37
- const dash = (clamped / 100) * circumference;
38
-
39
- return (
40
- <View style={{ alignItems: "center", gap: 12 }}>
41
- <View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
42
- <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ position: "absolute" }}>
43
- <circle cx={center} cy={center} r={radius} fill="none" stroke={colors.zinc[100]} strokeWidth={thickness} />
44
- <circle
45
- cx={center}
46
- cy={center}
47
- r={radius}
48
- fill="none"
49
- stroke={color}
50
- strokeWidth={thickness}
51
- strokeLinecap="round"
52
- strokeDasharray={`${dash} ${circumference}`}
53
- transform={`rotate(-90 ${center} ${center})`}
54
- />
55
- </svg>
56
- <Text size="xxxl" weight="semibold">
57
- {Math.round(clamped)}%
58
- </Text>
59
- </View>
60
- <View style={{ alignItems: "center", gap: 2 }}>
61
- <Text size="md" weight="semibold">
62
- {label}
63
- </Text>
64
- {caption ? (
65
- <Text size="sm" color="muted">
66
- {caption}
67
- </Text>
68
- ) : null}
69
- </View>
70
- </View>
71
- );
72
- }