@lotics/ui 40.0.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`
@@ -1569,7 +1577,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1569
1577
  nothing pushes back, so a 720p capture measurably widened a 342px chat column to 367px
1570
1578
  the moment it loaded. **Do not add your own fullscreen/expand control**: the native
1571
1579
  transport already carries fullscreen, picture-in-picture and download, and a second
1572
- affordance beside them is both redundant and a claim about where it leads. Native renders
1580
+ affordance beside them is both redundant and a claim about where it leads. A source it
1581
+ cannot load is surfaced, never silent — a media element that fails paints an EMPTY BOX and
1582
+ reports nothing, so an expired serving token is indistinguishable from a feature that never
1583
+ produced a file; on error it renders a retryable notice filling the same box instead
1584
+ (`mediaPlayer` locale slice, worded agnostically because the kit does not know whether it is
1585
+ showing a recording, a clip or an attachment). Failure is remembered per SOURCE, so swapping
1586
+ `src` presents the new one rather than inheriting the old one's error, and the retry remounts
1587
+ the element — a re-render alone does not re-request the source. **`onError` reports the failure
1588
+ with the HTTP STATUS**, not just the `MediaError`: a lapsed token, a missing file and an
1589
+ unplayable codec all arrive as code 4, so the code alone cannot say which happened. The player
1590
+ resolves the status by re-requesting the source, and only when an `onError` is supplied — the
1591
+ kit carries no analytics, so the host decides whether the answer is worth a request. Native renders
1573
1592
  the `notAvailable` placeholder — playback there needs a native media dep the package does
1574
1593
  not carry.
1575
1594
  - **`media_player_types`** — `MediaPlayerProps`; types only.
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.0.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",
package/src/locale.tsx CHANGED
@@ -114,6 +114,12 @@ export interface LoticsLocale {
114
114
  * (open/download/remove + the confirm), the gallery chrome (close, prev/next,
115
115
  * rotate), and the preview captions (not-available / load-failed / password). */
116
116
  gallery: GalleryLabels;
117
+ /** `MediaPlayer`: the caption when a source will not load. Its own slice
118
+ * rather than the `gallery` one, whose wording is "preview" — true of
119
+ * `FilePreview` and wrong everywhere else the player is used, and a single
120
+ * string cannot mean both. Agnostic on purpose: the kit does not know
121
+ * whether it is showing a recording, a clip or an attachment. */
122
+ mediaPlayer: { loadFailed: string };
117
123
  /** `Avatar`: the fallback name (initials + a11y label) shown when no `name`. */
118
124
  avatar: { unknown: string };
119
125
  /** `BackButton` + `PopoverNavHeader`: the back-chevron's a11y name. */
@@ -306,6 +312,7 @@ export const en: LoticsLocale = {
306
312
  selectDateRange: "Select date range", selectDate: "Select date",
307
313
  clear: "Clear", done: "Done", placeholder: "All time",
308
314
  },
315
+ mediaPlayer: { loadFailed: "Failed to load media" },
309
316
  gallery: {
310
317
  close: "Close",
311
318
  previous: "Previous",
@@ -494,6 +501,7 @@ export const vi: LoticsLocale = {
494
501
  download: "Tải xuống",
495
502
  passwordProtected: "Tệp có mật khẩu — không xem trước được",
496
503
  },
504
+ mediaPlayer: { loadFailed: "Không tải được nội dung" },
497
505
  avatar: { unknown: "Không rõ" },
498
506
  nav: { back: "Quay lại" },
499
507
  chart: { noData: "Không có dữ liệu", total: "Tổng" },
@@ -1,4 +1,10 @@
1
+ import { useState } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Button } from "./button";
4
+ import { Icon } from "./icon";
5
+ import { Text } from "./text";
1
6
  import { colors } from "./colors";
7
+ import { useLoticsLocale } from "./locale";
2
8
  import type { MediaPlayerProps } from "./media_player_types";
3
9
 
4
10
  /**
@@ -17,16 +23,68 @@ import type { MediaPlayerProps } from "./media_player_types";
17
23
  * caller composes the box and this draws inside it.
18
24
  */
19
25
  export function MediaPlayer(props: MediaPlayerProps) {
20
- const { src, kind, accessibilityLabel, testID } = props;
26
+ const { src, kind, accessibilityLabel, onError, testID } = props;
27
+ const l = useLoticsLocale();
28
+ // Bumped to REMOUNT the element on retry. Clearing the flag alone leaves the
29
+ // failed element in place, and a media element does not re-request its source
30
+ // just because it re-rendered.
31
+ const [attempt, setAttempt] = useState(0);
32
+ // The failed SOURCE, not a boolean: this component keeps its position when a
33
+ // caller swaps `src` (a gallery stepping to the next clip), and a boolean
34
+ // would carry the previous file's failure onto a file that was never tried.
35
+ const [failedSrc, setFailedSrc] = useState<string | null>(null);
36
+ const failed = failedSrc === src;
37
+
38
+ const handleError = (element: HTMLMediaElement) => {
39
+ setFailedSrc(src);
40
+ // Only probe when someone is listening — the host pays a request for the
41
+ // answer, so it is not spent when nothing reads it.
42
+ if (!onError) return;
43
+ const code = element.error?.code ?? null;
44
+ void resolveStatus(src).then((status) => onError({ status, code }));
45
+ };
46
+
47
+ /**
48
+ * A media element that cannot load its source renders an EMPTY BOX and says
49
+ * nothing — no pixel changes, no message, nothing to act on. An expired
50
+ * file-serving token was indistinguishable from "the feature never recorded
51
+ * anything", for a recording that was intact on disk the whole time. Whatever
52
+ * the cause, a failed load has to be visible and retryable.
53
+ */
54
+ if (failed) {
55
+ const notice = (
56
+ <View testID={testID ? `${testID}-error` : undefined} style={styles.error}>
57
+ <Icon name="circle-alert" size={20} color={colors.zinc["400"]} />
58
+ <Text size="sm" color="muted">
59
+ {l.mediaPlayer.loadFailed}
60
+ </Text>
61
+ <Button
62
+ title={l.errorState.retry}
63
+ color="secondary"
64
+ onPress={() => {
65
+ setFailedSrc(null);
66
+ setAttempt((n) => n + 1);
67
+ }}
68
+ />
69
+ </View>
70
+ );
71
+ // Video fills a box the caller sized, so the notice has to fill it too or
72
+ // the frame half-empties on failure. The wrapper is a flex container for
73
+ // exactly that reason — measured, `flex: 1` inside the default block box
74
+ // left the notice 50px tall in a 206px frame.
75
+ return kind === "video" ? <div style={videoWrapperStyle}>{notice}</div> : notice;
76
+ }
21
77
 
22
78
  if (kind === "audio") {
23
79
  return (
24
80
  <audio
81
+ key={attempt}
25
82
  data-testid={testID}
26
83
  aria-label={accessibilityLabel}
27
84
  controls
28
85
  preload="metadata"
29
86
  src={src}
87
+ onError={(e) => handleError(e.currentTarget)}
30
88
  style={audioStyle}
31
89
  />
32
90
  );
@@ -44,21 +102,52 @@ export function MediaPlayer(props: MediaPlayerProps) {
44
102
  return (
45
103
  <div style={videoWrapperStyle}>
46
104
  <video
105
+ key={attempt}
47
106
  data-testid={testID}
48
107
  aria-label={accessibilityLabel}
49
108
  controls
50
109
  preload="metadata"
51
110
  src={src}
111
+ onError={(e) => handleError(e.currentTarget)}
52
112
  style={videoStyle}
53
113
  />
54
114
  </div>
55
115
  );
56
116
  }
57
117
 
118
+ const styles = StyleSheet.create({
119
+ error: {
120
+ flex: 1,
121
+ alignItems: "center",
122
+ justifyContent: "center",
123
+ gap: 8,
124
+ padding: 16,
125
+ borderRadius: 8,
126
+ backgroundColor: colors.zinc["100"],
127
+ },
128
+ });
129
+
130
+ /**
131
+ * The status behind a failed load. A media element never exposes it — every
132
+ * transport failure arrives as the same opaque `MediaError` — so the only way to
133
+ * tell an expired token from a broken file is to ask again and read the code.
134
+ * Null means even that could not be determined (offline, or a source this
135
+ * origin may not read), which is itself the useful answer.
136
+ */
137
+ async function resolveStatus(src: string): Promise<number | null> {
138
+ try {
139
+ const res = await fetch(src, { method: "HEAD", credentials: "include" });
140
+ return res.status;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
58
146
  // Inline rather than StyleSheet: these land on DOM elements, not RN views.
59
147
  const audioStyle = { width: "100%" } as const;
60
148
  const videoWrapperStyle = {
61
149
  position: "relative",
150
+ display: "flex",
62
151
  width: "100%",
63
152
  height: "100%",
64
153
  overflow: "hidden",
@@ -1,3 +1,18 @@
1
+ /**
2
+ * Why a source would not play.
3
+ *
4
+ * The media element's own `MediaError` is nearly useless on its own: a 401, a
5
+ * 404 and a genuinely unplayable codec all surface as code 4
6
+ * (`SRC_NOT_SUPPORTED`). The STATUS is what separates "the session lapsed" from
7
+ * "this file is broken", so the player resolves it before reporting.
8
+ */
9
+ export interface MediaLoadFailure {
10
+ /** HTTP status of the source, or null when it could not be determined. */
11
+ status: number | null;
12
+ /** `MediaError.code` — 1 aborted, 2 network, 3 decode, 4 unsupported. */
13
+ code: number | null;
14
+ }
15
+
1
16
  export interface MediaPlayerProps {
2
17
  /** Already-served URL for the media — signing/auth is the host's problem. */
3
18
  src: string;
@@ -9,5 +24,12 @@ export interface MediaPlayerProps {
9
24
  kind: "audio" | "video";
10
25
  /** Accessible name for the player. Required: an unlabelled one is a blank control. */
11
26
  accessibilityLabel: string;
27
+ /**
28
+ * Called once when a source fails to load. The player already SHOWS the
29
+ * failure; this is for the host to report it, because the kit carries no
30
+ * analytics of its own. Omit it and no status probe is performed — the work
31
+ * only happens when someone is listening.
32
+ */
33
+ onError?: (failure: MediaLoadFailure) => void;
12
34
  testID?: string;
13
35
  }
@@ -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
- }