@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 +42 -0
- package/docs/catalog.md +22 -3
- package/docs/templates.md +1 -1
- package/examples/tpl_dashboard.tsx +2 -2
- package/package.json +2 -2
- package/src/locale.tsx +8 -0
- package/src/media_player.web.tsx +90 -1
- package/src/media_player_types.ts +22 -0
- package/src/progress_bar.tsx +18 -3
- package/src/progress_ring.tsx +148 -0
- package/src/vite.mjs +1 -1
- package/src/ring_gauge.tsx +0 -72
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), `
|
|
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.
|
|
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
|
-
`
|
|
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 {
|
|
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
|
-
<
|
|
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": "
|
|
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
|
-
"./
|
|
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" },
|
package/src/media_player.web.tsx
CHANGED
|
@@ -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
|
}
|
package/src/progress_bar.tsx
CHANGED
|
@@ -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" ?
|
|
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)} (${
|
|
105
|
+
? `${num(value)} / ${num(max)} (${pct(ratio)})`
|
|
91
106
|
: format === "percentage"
|
|
92
|
-
?
|
|
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
|
-
*
|
|
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 —
|
package/src/ring_gauge.tsx
DELETED
|
@@ -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
|
-
}
|