@lotics/ui 42.0.0 → 42.3.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/src/colors.ts CHANGED
@@ -1,386 +1,17 @@
1
- const palette = {
2
- black: "rgba(0,0,0,1)",
3
- white: "rgba(255,255,255,1)",
4
-
5
- // We use RGBA so that we can use React Native Animations with colors
6
- red: {
7
- 50: "rgba(254, 242, 242, 1)",
8
- 100: "rgba(254, 226, 226, 1)",
9
- 200: "rgba(254, 202, 202, 1)",
10
- 300: "rgba(252, 165, 165, 1)",
11
- 400: "rgba(248, 113, 113, 1)",
12
- 500: "rgba(239, 68, 68, 1)",
13
- 600: "rgba(220, 38, 38, 1)",
14
- 700: "rgba(185, 28, 28, 1)",
15
- 800: "rgba(153, 27, 27, 1)",
16
- 900: "rgba(127, 29, 29, 1)",
17
- 950: "rgba(69, 10, 10, 1)",
18
- },
19
- orange: {
20
- 50: "rgba(255, 247, 237, 1)",
21
- 100: "rgba(255, 237, 213, 1)",
22
- 200: "rgba(254, 215, 170, 1)",
23
- 300: "rgba(253, 186, 116, 1)",
24
- 400: "rgba(251, 146, 60, 1)",
25
- 500: "rgba(249, 115, 22, 1)",
26
- 600: "rgba(234, 88, 12, 1)",
27
- 700: "rgba(194, 65, 12, 1)",
28
- 800: "rgba(154, 52, 18, 1)",
29
- 900: "rgba(124, 45, 18, 1)",
30
- 950: "rgba(67, 20, 7, 1)",
31
- },
32
- amber: {
33
- 50: "rgba(255, 251, 235, 1)",
34
- 100: "rgba(254, 243, 199, 1)",
35
- 200: "rgba(253, 230, 138, 1)",
36
- 300: "rgba(252, 211, 77, 1)",
37
- 400: "rgba(251, 191, 36, 1)",
38
- 500: "rgba(245, 158, 11, 1)",
39
- 600: "rgba(217, 119, 6, 1)",
40
- 700: "rgba(180, 83, 9, 1)",
41
- 800: "rgba(146, 64, 14, 1)",
42
- 900: "rgba(120, 53, 15, 1)",
43
- 950: "rgba(69, 26, 3, 1)",
44
- },
45
- yellow: {
46
- 50: "rgba(254, 252, 232, 1)",
47
- 100: "rgba(254, 249, 195, 1)",
48
- 200: "rgba(254, 240, 138, 1)",
49
- 300: "rgba(253, 224, 71, 1)",
50
- 400: "rgba(250, 204, 21, 1)",
51
- 500: "rgba(234, 179, 8, 1)",
52
- 600: "rgba(202, 138, 4, 1)",
53
- 700: "rgba(161, 98, 7, 1)",
54
- 800: "rgba(133, 77, 14, 1)",
55
- 900: "rgba(113, 63, 18, 1)",
56
- 950: "rgba(66, 32, 6, 1)",
57
- },
58
- lime: {
59
- 50: "rgba(247, 254, 231, 1)",
60
- 100: "rgba(236, 252, 203, 1)",
61
- 200: "rgba(217, 249, 157, 1)",
62
- 300: "rgba(190, 242, 100, 1)",
63
- 400: "rgba(163, 230, 53, 1)",
64
- 500: "rgba(132, 204, 22, 1)",
65
- 600: "rgba(101, 163, 13, 1)",
66
- 700: "rgba(77, 124, 15, 1)",
67
- 800: "rgba(63, 98, 18, 1)",
68
- 900: "rgba(54, 83, 20, 1)",
69
- 950: "rgba(26, 46, 5, 1)",
70
- },
71
- green: {
72
- 50: "rgba(240, 253, 244, 1)",
73
- 100: "rgba(220, 252, 231, 1)",
74
- 200: "rgba(187, 247, 208, 1)",
75
- 300: "rgba(134, 239, 172, 1)",
76
- 400: "rgba(74, 222, 128, 1)",
77
- 500: "rgba(34, 197, 94, 1)",
78
- 600: "rgba(22, 163, 74, 1)",
79
- 700: "rgba(21, 128, 61, 1)",
80
- 800: "rgba(22, 101, 52, 1)",
81
- 900: "rgba(20, 83, 45, 1)",
82
- 950: "rgba(5, 46, 22, 1)",
83
- },
84
- emerald: {
85
- 50: "rgba(236, 253, 245, 1)",
86
- 100: "rgba(209, 250, 229, 1)",
87
- 200: "rgba(167, 243, 208, 1)",
88
- 300: "rgba(110, 231, 183, 1)",
89
- 400: "rgba(52, 211, 153, 1)",
90
- 500: "rgba(16, 185, 129, 1)",
91
- 600: "rgba(5, 150, 105, 1)",
92
- 700: "rgba(4, 120, 87, 1)",
93
- 800: "rgba(6, 95, 70, 1)",
94
- 900: "rgba(6, 78, 59, 1)",
95
- 950: "rgba(2, 44, 34, 1)",
96
- },
97
- teal: {
98
- 50: "rgba(240, 253, 250, 1)",
99
- 100: "rgba(204, 251, 241, 1)",
100
- 200: "rgba(153, 246, 228, 1)",
101
- 300: "rgba(94, 234, 212, 1)",
102
- 400: "rgba(45, 212, 191, 1)",
103
- 500: "rgba(20, 184, 166, 1)",
104
- 600: "rgba(13, 148, 136, 1)",
105
- 700: "rgba(15, 118, 110, 1)",
106
- 800: "rgba(17, 94, 89, 1)",
107
- 900: "rgba(19, 78, 74, 1)",
108
- 950: "rgba(4, 47, 46, 1)",
109
- },
110
- cyan: {
111
- 50: "rgba(236, 254, 255, 1)",
112
- 100: "rgba(207, 250, 254, 1)",
113
- 200: "rgba(165, 243, 252, 1)",
114
- 300: "rgba(103, 232, 249, 1)",
115
- 400: "rgba(34, 211, 238, 1)",
116
- 500: "rgba(6, 182, 212, 1)",
117
- 600: "rgba(8, 145, 178, 1)",
118
- 700: "rgba(14, 116, 144, 1)",
119
- 800: "rgba(21, 94, 117, 1)",
120
- 900: "rgba(22, 78, 99, 1)",
121
- 950: "rgba(8, 51, 68, 1)",
122
- },
123
- sky: {
124
- 50: "rgba(240, 249, 255, 1)",
125
- 100: "rgba(224, 242, 254, 1)",
126
- 200: "rgba(186, 230, 253, 1)",
127
- 300: "rgba(125, 211, 252, 1)",
128
- 400: "rgba(56, 189, 248, 1)",
129
- 500: "rgba(14, 165, 233, 1)",
130
- 600: "rgba(2, 132, 199, 1)",
131
- 700: "rgba(3, 105, 161, 1)",
132
- 800: "rgba(7, 89, 133, 1)",
133
- 900: "rgba(12, 74, 110, 1)",
134
- 950: "rgba(8, 47, 73, 1)",
135
- },
136
- blue: {
137
- 50: "rgba(239, 246, 255, 1)",
138
- 100: "rgba(219, 234, 254, 1)",
139
- 200: "rgba(191, 219, 254, 1)",
140
- 300: "rgba(147, 197, 253, 1)",
141
- 400: "rgba(96, 165, 250, 1)",
142
- 500: "rgba(59, 130, 246, 1)",
143
- 600: "rgba(37, 99, 235, 1)",
144
- 700: "rgba(29, 78, 216, 1)",
145
- 800: "rgba(30, 64, 175, 1)",
146
- 900: "rgba(30, 58, 138, 1)",
147
- 950: "rgba(23, 37, 84, 1)",
148
- },
149
- indigo: {
150
- 50: "rgba(238, 242, 255, 1)",
151
- 100: "rgba(224, 231, 255, 1)",
152
- 200: "rgba(199, 210, 254, 1)",
153
- 300: "rgba(165, 180, 252, 1)",
154
- 400: "rgba(129, 140, 248, 1)",
155
- 500: "rgba(99, 102, 241, 1)",
156
- 600: "rgba(79, 70, 229, 1)",
157
- 700: "rgba(67, 56, 202, 1)",
158
- 800: "rgba(55, 48, 163, 1)",
159
- 900: "rgba(49, 46, 129, 1)",
160
- 950: "rgba(30, 27, 75, 1)",
161
- },
162
- violet: {
163
- 50: "rgba(245, 243, 255, 1)",
164
- 100: "rgba(237, 233, 254, 1)",
165
- 200: "rgba(221, 214, 254, 1)",
166
- 300: "rgba(196, 181, 253, 1)",
167
- 400: "rgba(167, 139, 250, 1)",
168
- 500: "rgba(139, 92, 246, 1)",
169
- 600: "rgba(124, 58, 237, 1)",
170
- 700: "rgba(109, 40, 217, 1)",
171
- 800: "rgba(91, 33, 182, 1)",
172
- 900: "rgba(76, 29, 149, 1)",
173
- 950: "rgba(46, 16, 101, 1)",
174
- },
175
- purple: {
176
- 50: "rgba(250, 245, 255, 1)",
177
- 100: "rgba(243, 232, 255, 1)",
178
- 200: "rgba(233, 213, 255, 1)",
179
- 300: "rgba(216, 180, 254, 1)",
180
- 400: "rgba(192, 132, 252, 1)",
181
- 500: "rgba(168, 85, 247, 1)",
182
- 600: "rgba(147, 51, 234, 1)",
183
- 700: "rgba(126, 34, 206, 1)",
184
- 800: "rgba(107, 33, 168, 1)",
185
- 900: "rgba(88, 28, 135, 1)",
186
- 950: "rgba(59, 7, 100, 1)",
187
- },
188
- fuchsia: {
189
- 50: "rgba(253, 244, 255, 1)",
190
- 100: "rgba(250, 232, 255, 1)",
191
- 200: "rgba(245, 208, 254, 1)",
192
- 300: "rgba(240, 171, 252, 1)",
193
- 400: "rgba(232, 121, 249, 1)",
194
- 500: "rgba(217, 70, 239, 1)",
195
- 600: "rgba(192, 38, 211, 1)",
196
- 700: "rgba(162, 28, 175, 1)",
197
- 800: "rgba(134, 25, 143, 1)",
198
- 900: "rgba(112, 26, 117, 1)",
199
- 950: "rgba(74, 4, 78, 1)",
200
- },
201
- pink: {
202
- 50: "rgba(253, 242, 248, 1)",
203
- 100: "rgba(252, 231, 243, 1)",
204
- 200: "rgba(251, 207, 232, 1)",
205
- 300: "rgba(249, 168, 212, 1)",
206
- 400: "rgba(244, 114, 182, 1)",
207
- 500: "rgba(236, 72, 153, 1)",
208
- 600: "rgba(219, 39, 119, 1)",
209
- 700: "rgba(190, 24, 93, 1)",
210
- 800: "rgba(157, 23, 77, 1)",
211
- 900: "rgba(131, 24, 67, 1)",
212
- 950: "rgba(80, 7, 36, 1)",
213
- },
214
- rose: {
215
- 50: "rgba(255, 241, 242, 1)",
216
- 100: "rgba(255, 228, 230, 1)",
217
- 200: "rgba(254, 205, 211, 1)",
218
- 300: "rgba(253, 164, 175, 1)",
219
- 400: "rgba(251, 113, 133, 1)",
220
- 500: "rgba(244, 63, 94, 1)",
221
- 600: "rgba(225, 29, 72, 1)",
222
- 700: "rgba(190, 18, 60, 1)",
223
- 800: "rgba(159, 18, 57, 1)",
224
- 900: "rgba(136, 19, 55, 1)",
225
- 950: "rgba(76, 5, 25, 1)",
226
- },
227
- slate: {
228
- 50: "rgba(248, 250, 252, 1)",
229
- 100: "rgba(241, 245, 249, 1)",
230
- 200: "rgba(226, 232, 240, 1)",
231
- 300: "rgba(203, 213, 225, 1)",
232
- 400: "rgba(148, 163, 184, 1)",
233
- 500: "rgba(100, 116, 139, 1)",
234
- 600: "rgba(71, 85, 105, 1)",
235
- 700: "rgba(51, 65, 85, 1)",
236
- 800: "rgba(30, 41, 59, 1)",
237
- 900: "rgba(15, 23, 42, 1)",
238
- 950: "rgba(2, 6, 23, 1)",
239
- },
240
- gray: {
241
- 50: "rgba(249, 250, 251, 1)",
242
- 100: "rgba(243, 244, 246, 1)",
243
- 200: "rgba(229, 231, 235, 1)",
244
- 300: "rgba(209, 213, 219, 1)",
245
- 400: "rgba(156, 163, 175, 1)",
246
- 500: "rgba(107, 114, 128, 1)",
247
- 600: "rgba(75, 85, 99, 1)",
248
- 700: "rgba(55, 65, 81, 1)",
249
- 800: "rgba(31, 41, 55, 1)",
250
- 900: "rgba(17, 24, 39, 1)",
251
- 950: "rgba(3, 7, 18, 1)",
252
- },
253
- zinc: {
254
- 50: "rgba(250, 250, 250, 1)",
255
- 100: "rgba(244, 244, 245, 1)",
256
- 200: "rgba(228, 228, 231, 1)",
257
- 300: "rgba(212, 212, 216, 1)",
258
- 400: "rgba(161, 161, 170, 1)",
259
- 500: "rgba(113, 113, 122, 1)",
260
- 600: "rgba(82, 82, 91, 1)",
261
- 700: "rgba(63, 63, 70, 1)",
262
- 800: "rgba(39, 39, 42, 1)",
263
- 900: "rgba(24, 24, 27, 1)",
264
- 950: "rgba(9, 9, 11, 1)",
265
- },
266
- neutral: {
267
- 50: "rgba(250, 250, 250, 1)",
268
- 100: "rgba(245, 245, 245, 1)",
269
- 200: "rgba(229, 229, 229, 1)",
270
- 300: "rgba(212, 212, 212, 1)",
271
- 400: "rgba(163, 163, 163, 1)",
272
- 500: "rgba(115, 115, 115, 1)",
273
- 600: "rgba(82, 82, 82, 1)",
274
- 700: "rgba(64, 64, 64, 1)",
275
- 800: "rgba(38, 38, 38, 1)",
276
- 900: "rgba(23, 23, 23, 1)",
277
- 950: "rgba(10, 10, 10, 1)",
278
- },
279
- stone: {
280
- 50: "rgba(250, 250, 249, 1)",
281
- 100: "rgba(245, 245, 244, 1)",
282
- 200: "rgba(231, 229, 228, 1)",
283
- 300: "rgba(214, 211, 209, 1)",
284
- 400: "rgba(168, 162, 158, 1)",
285
- 500: "rgba(120, 113, 108, 1)",
286
- 600: "rgba(87, 83, 78, 1)",
287
- 700: "rgba(68, 64, 60, 1)",
288
- 800: "rgba(41, 37, 36, 1)",
289
- 900: "rgba(28, 25, 23, 1)",
290
- 950: "rgba(12, 10, 9, 1)",
291
- },
292
- };
293
-
294
- export const colors = {
295
- ...palette,
296
- border: palette.zinc["200"],
297
- border_shadow: `0px 1px 2px 0px ${palette.zinc["200"]}`,
298
- background: palette.white,
299
- shadow: `0px 0px 6px 1px ${palette.zinc["300"]}`,
300
- };
301
-
302
- /**
303
- * A low-alpha wash of a palette color (hover tints, dimmed chart segments,
304
- * icon discs). Handles the palette's rgba() strings plus rgb()/#RRGGBB.
305
- */
306
- export function withAlpha(color: string, alpha: number): string {
307
- if (color.startsWith("rgba(")) return color.replace(/,\s*[\d.]+\s*\)\s*$/, `, ${alpha})`);
308
- if (color.startsWith("rgb(")) return color.replace("rgb(", "rgba(").replace(/\)\s*$/, `, ${alpha})`);
309
- if (color.startsWith("#") && color.length === 7) {
310
- return `${color}${Math.round(alpha * 255)
311
- .toString(16)
312
- .padStart(2, "0")}`;
313
- }
314
- return color;
315
- }
316
-
317
- /**
318
- * A palette FAMILY name — the single, semantic way to reference a color across
319
- * the system (Badge, status indicators, chart series, breakdown segments).
320
- * Reference colors by NAME and let the component resolve the shade it needs;
321
- * never thread a raw hex through props — it drifts (one call site picks 500,
322
- * another 600, and the same "status" renders two greens). Excludes the
323
- * non-scale role keys (border/background/shadow/black/white).
324
- */
325
- export type ColorName = Exclude<keyof typeof colors, "border" | "border_shadow" | "background" | "shadow" | "black" | "white">;
326
-
327
- /**
328
- * The canonical SOLID shade of a family (500) — status dots, status-grid
329
- * cells, breakdown segments, chart series. Defined ONCE so every indicator of
330
- * the same color agrees on the shade.
331
- */
332
- export function solid(name: ColorName): string {
333
- return colors[name][500];
334
- }
335
-
336
- /** A low-alpha wash of a family's solid shade — cell/segment tints, dimmed
337
- * states. `tint("emerald", 0.2)` === `withAlpha(solid("emerald"), 0.2)`. */
338
- export function tint(name: ColorName, alpha: number): string {
339
- return withAlpha(solid(name), alpha);
340
- }
341
-
342
- /** Dark → light stops a `ramp` spans (the usable mid range of a scale). */
343
- const RAMP_STOPS = [700, 600, 500, 400, 300, 200] as const;
344
-
345
- /**
346
- * `count` distinct shades of ONE family, strong → light — the monochrome
347
- * "one hue family per dimension" data ramp (a Breakdown's segments, an ordered
348
- * funnel, any multi-category breakdown of a single dimension). Use this for a
349
- * COHERENT dimension instead of hand-picking shades or scattering hues; the
350
- * segment LABEL carries identity, the shade only orders. (For semantic
351
- * categories whose color carries MEANING — status — give each its own
352
- * `ColorName` and `solid()` it, don't ramp.)
353
- */
354
- export function ramp(name: ColorName, count: number): string[] {
355
- const scale = colors[name];
356
- if (count <= 1) return [scale[600]];
357
- return Array.from({ length: count }, (_, i) => {
358
- const idx = Math.round((i / (count - 1)) * (RAMP_STOPS.length - 1));
359
- return scale[RAMP_STOPS[idx]];
360
- });
361
- }
362
-
363
- /**
364
- * Is `value` a usable {@link ColorName} — a palette FAMILY, not a role key
365
- * (`border`/`background`/…) and not `black`/`white`? A family resolves to a
366
- * shade object; the role/scalar keys resolve to a string, so "value is an
367
- * object" is the test (no name list to keep in sync).
368
- */
369
- export function isColorName(value: unknown): value is ColorName {
370
- return (
371
- typeof value === "string" &&
372
- value in colors &&
373
- typeof (colors as Record<string, unknown>)[value] === "object"
374
- );
375
- }
1
+ export * from "./color_tokens";
376
2
 
377
3
  /**
378
- * Coerce an arbitrary color token to a {@link ColorName}, falling back to a
379
- * neutral. The single graceful-degradation point for stored option/status
380
- * colors: a select option's `color` may be a token a newer table config
381
- * introduced that this UI build predates, or absent entirely — either way a
382
- * component renders a neutral badge instead of breaking. Used by `OptionBadge`.
4
+ * The NATIVE token surface the literal table, unchanged.
5
+ *
6
+ * The values themselves live in `./color_tokens`, which both this file and
7
+ * `./colors.web.ts` re-export. That indirection exists for one concrete reason:
8
+ * a relative `./colors` resolves `.web.ts` FIRST on web, so a web variant that
9
+ * imported `./colors` would import itself, and esbuild reports it as
10
+ * "Detected cycle while resolving import". Neither variant may name the other;
11
+ * both name a third module instead.
12
+ *
13
+ * Importers never see this split — `@lotics/ui/colors` and the kit's own
14
+ * `./colors` both keep working, and which of the two files answers is decided by
15
+ * platform (the `react-native` export condition, and `.web.ts` extension order
16
+ * inside the kit).
383
17
  */
384
- export function asColorName(value: unknown, fallback: ColorName = "zinc"): ColorName {
385
- return isColorName(value) ? value : fallback;
386
- }
@@ -0,0 +1,43 @@
1
+ import { colors as contract } from "./color_tokens";
2
+
3
+ export { withAlpha, solid, tint, ramp, isColorName, asColorName } from "./color_tokens";
4
+ export type { ColorName } from "./color_tokens";
5
+
6
+ /**
7
+ * The token contract, with its FOUR themeable roles indirected through CSS
8
+ * variables so an app can carry its own visual identity.
9
+ *
10
+ * Why this file exists at all, rather than a React context: 136 of the kit's 320
11
+ * modules import `colors` directly, at module scope, into `StyleSheet.create`.
12
+ * A context would have to reach every one of them, turning static stylesheets
13
+ * into per-render inline styles — the kit's whole performance model — for a
14
+ * feature that changes four values. A variable costs nothing at runtime and
15
+ * touches no component: `colors.background` still reads `colors.background`.
16
+ *
17
+ * It is safe because react-native-web's `isWebColor` allowlists `var(` ahead of
18
+ * its own colour normalization, so a variable reaches CSS untouched on every
19
+ * colour property. That is a web-only guarantee, which is exactly why this is a
20
+ * `.web.ts` sibling: the `react-native` export condition and `.web.ts` extension
21
+ * order pick it for the browser; native keeps the literals it can parse.
22
+ *
23
+ * It reads its literals from `./color_tokens`, NEVER from `./colors` — on web a
24
+ * relative `./colors` resolves back to this very file, which esbuild reports as
25
+ * "Detected cycle while resolving import".
26
+ *
27
+ * Each variable carries its literal DEFAULT inline, so the theme is opt-in in
28
+ * the strictest sense — an app that sets nothing has no `--lotics-*` defined,
29
+ * every `var()` falls back, and the pixels are identical to before this file.
30
+ *
31
+ * ONLY these three. The palette families stay literal, and must: `withAlpha`,
32
+ * `tint` and `ramp` do string surgery on an `rgba()` to derive a wash, and a
33
+ * `var()` handed to them would produce garbage no type would catch. Keeping the
34
+ * themeable set to chrome keeps those functions total — the maintainability
35
+ * argument and the "colour must keep meaning status" argument turn out to be the
36
+ * same argument.
37
+ */
38
+ export const colors = {
39
+ ...contract,
40
+ background: `var(--lotics-background, ${contract.background})`,
41
+ border: `var(--lotics-border, ${contract.border})`,
42
+ accent: `var(--lotics-accent, ${contract.accent})`,
43
+ };
@@ -0,0 +1,217 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { Metric, type MetricFormat, type MetricTone } from "./metric";
4
+ import { StackedProgressBar } from "./stacked_progress_bar";
5
+ import { SummaryLine, type SummaryLineItem } from "./summary_line";
6
+ import { useLocaleTag } from "./locale";
7
+ import { Text } from "./text";
8
+
9
+ /**
10
+ * One bucket of the set being summarized — a slice of the whole, with the colour
11
+ * it wears wherever it appears.
12
+ *
13
+ * The buckets PARTITION the set: their values sum to the total, which is why the
14
+ * total is derived rather than passed. A count that belongs to the same rows but
15
+ * does not partition them — a condition rather than a position, "overdue" beside
16
+ * "draft / sent / paid" — is not a bucket and must not be one, or the bar starts
17
+ * claiming a share for something that is already inside another segment. Pass
18
+ * those to `Summary.Facts` as `extra`.
19
+ */
20
+ export interface SummaryBucket {
21
+ key: string;
22
+ /** Reads AFTER the value, like every other label in this family. */
23
+ label: string;
24
+ value: number;
25
+ /** The segment's colour, and the swatch's. Use the same value this bucket
26
+ * wears in the rest of the product — a bar that disagrees with the badge
27
+ * beside it is worse than no bar. */
28
+ color: string;
29
+ /** What this bucket means — the ⓘ beside its count. */
30
+ info?: string;
31
+ }
32
+
33
+ interface SummaryContext {
34
+ buckets: readonly SummaryBucket[];
35
+ total: number;
36
+ }
37
+
38
+ const Ctx = createContext<SummaryContext | null>(null);
39
+
40
+ function useSummary(part: string): SummaryContext {
41
+ const ctx = useContext(Ctx);
42
+ if (!ctx) throw new Error(`<Summary.${part}> must be rendered inside <Summary>.`);
43
+ return ctx;
44
+ }
45
+
46
+ interface SummaryProps {
47
+ buckets: readonly SummaryBucket[];
48
+ children: ReactNode;
49
+ }
50
+
51
+ /**
52
+ * The register's summary band — a headline figure, the set's DISTRIBUTION, and
53
+ * the buckets named — composed from parts rather than configured by props.
54
+ *
55
+ * **Why a compound and not one component with more props.** The parts encode a
56
+ * correspondence that is easy to break and impossible to see broken: the bar,
57
+ * the legend and the headline all describe ONE set. Passing a `segments` array
58
+ * beside an `items` array beside a `total` invites exactly three drifts —
59
+ * segments reordered against their labels, a legend swatch picking a different
60
+ * shade, and a hardcoded total that stops summing after a bucket is added. So
61
+ * the buckets are declared ONCE on the root and every part derives from them.
62
+ * The total is a sum. The swatches are the segments' own colours. Nothing is
63
+ * restated, so nothing can disagree.
64
+ *
65
+ * That correspondence is the reason this is a primitive at all — this kit does
66
+ * not extract a composition for saving lines of layout.
67
+ *
68
+ * Reach for it when the set has a SHAPE worth showing. A summary that is only a
69
+ * few aggregates stays `SummaryLine`, which is this band's `Facts` row on its
70
+ * own; a boxed dashboard stat band is `KPIStrip`; a drill-down facet browser
71
+ * over a large population is `Breakdown`.
72
+ *
73
+ * <Summary buckets={STAGES}>
74
+ * <Summary.Header>
75
+ * <Summary.Total label="khách hàng" />
76
+ * <Summary.Metric value={660_000_000} label="hoa hồng" format="currency" compact />
77
+ * </Summary.Header>
78
+ * <Summary.Distribution />
79
+ * <Summary.Facts extra={[{ label: "đang tắc", value: 3, tone: "warning" }]} />
80
+ * </Summary>
81
+ */
82
+ export function Summary(props: SummaryProps) {
83
+ const { buckets, children } = props;
84
+ const total = buckets.reduce((sum, b) => sum + b.value, 0);
85
+ return (
86
+ <Ctx.Provider value={{ buckets, total }}>
87
+ <View style={styles.root}>{children}</View>
88
+ </Ctx.Provider>
89
+ );
90
+ }
91
+
92
+ /** The band's top line: a `Total` at the start, anything else pushed to the end.
93
+ * Baseline-aligned, because figures of different sizes sitting on one line is
94
+ * the whole reason this row exists. */
95
+ function Header(props: { children: ReactNode }) {
96
+ return <View style={styles.header}>{props.children}</View>;
97
+ }
98
+
99
+ /**
100
+ * The set's SIZE, at display scale — the band's headline.
101
+ *
102
+ * The number is the sum of the buckets and is never passed in. A page that
103
+ * restates its own total eventually restates it wrongly: a bucket gets added,
104
+ * the constant does not move, and the headline quietly disagrees with the bar
105
+ * directly beneath it.
106
+ */
107
+ function Total(props: { label: string; formatValue?: (n: number) => string }) {
108
+ const { total } = useSummary("Total");
109
+ const tag = useLocaleTag();
110
+ // Grouped by the reader's locale by default — a set of 12,345 was rendering
111
+ // "12345" in the one place on the screen sized to be read first, because the
112
+ // first app to use this counted in the hundreds. `formatValue` is the escape
113
+ // hatch for a total whose unit is not a plain count.
114
+ const shown = props.formatValue ? props.formatValue(total) : total.toLocaleString(tag);
115
+ return (
116
+ <View style={styles.pair}>
117
+ <Text size="xxxl" weight="semibold" tabular>
118
+ {shown}
119
+ </Text>
120
+ <Text size="md" color="muted">
121
+ {props.label}
122
+ </Text>
123
+ </View>
124
+ );
125
+ }
126
+
127
+ /**
128
+ * A second headline figure that is NOT part of the distribution — a sum of money
129
+ * over the same rows, an average, a rate.
130
+ *
131
+ * Deliberately not a bucket: it does not partition the set, so the bar must not
132
+ * carry it. It sits on the header row because a reader opening a register asks
133
+ * two questions ("how much of this is there" and "what is it worth"), and the
134
+ * answers belong side by side.
135
+ */
136
+ function SummaryMetric(props: {
137
+ value: number | string | null | undefined;
138
+ label: string;
139
+ format?: MetricFormat;
140
+ currency?: string;
141
+ compact?: boolean;
142
+ emptyLabel?: string;
143
+ tone?: MetricTone;
144
+ }) {
145
+ const { value, label, ...rest } = props;
146
+ return (
147
+ <View style={styles.pair}>
148
+ <Metric value={value} size="lg" {...rest} />
149
+ <Text size="sm" color="muted">
150
+ {label}
151
+ </Text>
152
+ </View>
153
+ );
154
+ }
155
+
156
+ /**
157
+ * The set's shape — one segment per bucket, in declaration order.
158
+ *
159
+ * Order is the CALLER's, untouched, because for an ordered set (a pipeline, a
160
+ * ladder, an age bracket) the sequence carries meaning that sorting by size
161
+ * would destroy. Re-rank the array before passing it if size is what matters.
162
+ */
163
+ function Distribution(props: { height?: number }) {
164
+ const { buckets, total } = useSummary("Distribution");
165
+ return (
166
+ <StackedProgressBar
167
+ height={props.height ?? 20}
168
+ total={total}
169
+ segments={buckets.map((b) => ({ key: b.key, value: b.value, color: b.color }))}
170
+ />
171
+ );
172
+ }
173
+
174
+ /**
175
+ * The buckets named, with their counts — the band's legend, and on its own the
176
+ * whole of `SummaryLine`.
177
+ *
178
+ * Each item takes its swatch from its own bucket, so the legend cannot drift
179
+ * from the bar above it. `extra` appends aggregates that are NOT segments and
180
+ * therefore get no swatch — the visual difference is the point: a reader can see
181
+ * that the unswatched figure is not a slice of the bar.
182
+ */
183
+ function Facts(props: { extra?: readonly SummaryLineItem[] }) {
184
+ const { buckets } = useSummary("Facts");
185
+ const items: SummaryLineItem[] = buckets.map((b) => ({
186
+ label: b.label,
187
+ value: b.value,
188
+ color: b.color,
189
+ info: b.info,
190
+ }));
191
+ return <SummaryLine items={[...items, ...(props.extra ?? [])]} />;
192
+ }
193
+
194
+ Summary.Header = Header;
195
+ Summary.Total = Total;
196
+ Summary.Metric = SummaryMetric;
197
+ Summary.Distribution = Distribution;
198
+ Summary.Facts = Facts;
199
+
200
+ const styles = StyleSheet.create({
201
+ // 12, and it is half of what a page must put BETWEEN this band and its
202
+ // neighbours. The band's parts are one group — a figure, the shape of what it
203
+ // counts, and that shape's key — so they have to sit closer to each other than
204
+ // the band sits to the register below it. Measured at 14 against a 16px page
205
+ // gap, the ratio was 1.14: every block on the screen an equal peer, nothing
206
+ // grouped, which is what "no rhythm" is when you measure it.
207
+ root: { gap: 12 },
208
+ header: {
209
+ flexDirection: "row",
210
+ alignItems: "baseline",
211
+ flexWrap: "wrap",
212
+ justifyContent: "space-between",
213
+ columnGap: 20,
214
+ rowGap: 8,
215
+ },
216
+ pair: { flexDirection: "row", alignItems: "baseline", gap: 8 },
217
+ });