panelui-native 0.41.0 → 0.43.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.
Files changed (58) hide show
  1. package/README.md +3 -2
  2. package/lib/module/components/color-picker/index.js +1395 -0
  3. package/lib/module/components/color-picker/index.js.map +1 -0
  4. package/lib/module/components/combobox/index.js +87 -19
  5. package/lib/module/components/combobox/index.js.map +1 -1
  6. package/lib/module/components/kpi-chart/index.js +648 -0
  7. package/lib/module/components/kpi-chart/index.js.map +1 -0
  8. package/lib/module/components/menu/index.js +40 -10
  9. package/lib/module/components/menu/index.js.map +1 -1
  10. package/lib/module/components/radar-chart/index.js +704 -0
  11. package/lib/module/components/radar-chart/index.js.map +1 -0
  12. package/lib/module/components/section-rail/index.js +32 -7
  13. package/lib/module/components/section-rail/index.js.map +1 -1
  14. package/lib/module/components/slider/index.js +240 -39
  15. package/lib/module/components/slider/index.js.map +1 -1
  16. package/lib/module/components/tabs/index.js +194 -10
  17. package/lib/module/components/tabs/index.js.map +1 -1
  18. package/lib/module/index.js +4 -0
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/module/theme/use-theme.js +1 -1
  21. package/lib/module/utils/chart.js +44 -0
  22. package/lib/module/utils/chart.js.map +1 -1
  23. package/lib/module/utils/color.js +268 -0
  24. package/lib/module/utils/color.js.map +1 -0
  25. package/lib/typescript/src/components/color-picker/index.d.ts +258 -0
  26. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -0
  27. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/kpi-chart/index.d.ts +490 -0
  29. package/lib/typescript/src/components/kpi-chart/index.d.ts.map +1 -0
  30. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/radar-chart/index.d.ts +214 -0
  32. package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -0
  33. package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
  34. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  35. package/lib/typescript/src/components/slider/index.d.ts +19 -0
  36. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  37. package/lib/typescript/src/components/tabs/index.d.ts +11 -1
  38. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  39. package/lib/typescript/src/index.d.ts +4 -0
  40. package/lib/typescript/src/index.d.ts.map +1 -1
  41. package/lib/typescript/src/utils/chart.d.ts +21 -0
  42. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  43. package/lib/typescript/src/utils/color.d.ts +74 -0
  44. package/lib/typescript/src/utils/color.d.ts.map +1 -0
  45. package/package.json +2 -1
  46. package/src/components/color-picker/index.tsx +1618 -0
  47. package/src/components/combobox/index.tsx +89 -19
  48. package/src/components/kpi-chart/index.tsx +719 -0
  49. package/src/components/menu/index.tsx +51 -6
  50. package/src/components/radar-chart/index.tsx +910 -0
  51. package/src/components/section-rail/index.tsx +42 -8
  52. package/src/components/slider/index.tsx +283 -41
  53. package/src/components/tabs/index.tsx +263 -8
  54. package/src/index.ts +60 -0
  55. package/src/theme/use-theme.ts +1 -1
  56. package/src/utils/chart.ts +52 -0
  57. package/src/utils/color.ts +246 -0
  58. package/theme.css +125 -112
@@ -0,0 +1,719 @@
1
+ /**
2
+ * KpiChart — one number, and what it is doing.
3
+ *
4
+ * A metric card is not a chart with a caption. The number is the message and
5
+ * the chart is the footnote, so the parts here are sized and ordered around
6
+ * that: a title that stays quiet, a value that does not, a trend that says
7
+ * which way and by how much, and a sparkline that is allowed to be small
8
+ * because nobody is reading values off it.
9
+ *
10
+ * ```tsx
11
+ * <KpiChart>
12
+ * <KpiChart.Header>
13
+ * <KpiChart.Title>Active users</KpiChart.Title>
14
+ * </KpiChart.Header>
15
+ * <KpiChart.Content>
16
+ * <KpiChart.Value>12,480</KpiChart.Value>
17
+ * <KpiChart.Trend value={12.4} />
18
+ * </KpiChart.Content>
19
+ * <KpiChart.Chart data={week} dataKey="v" />
20
+ * </KpiChart>
21
+ * ```
22
+ *
23
+ * The trend is given a number rather than a written string, so the component
24
+ * decides the direction and the colour rather than the call site remembering
25
+ * to keep a minus sign and a red in step. Where a rise is the bad news —
26
+ * churn, latency, cost — say so with `goodDirection="down"` and the colour
27
+ * follows the meaning instead of the sign.
28
+ */
29
+ import { createContext, forwardRef, useContext, useMemo, type ReactNode } from 'react';
30
+ import { View, type ViewProps } from 'react-native';
31
+ import { ArrowDown, ArrowUp, Minus } from 'lucide-react-native';
32
+ import { tv, type VariantProps } from 'tailwind-variants';
33
+ import { useCSSVariable } from 'uniwind';
34
+ import { Text } from '../../primitives/text';
35
+ import { cn } from '../../utils/cn';
36
+ import { type SeriesColorIndex } from '../../utils/chart';
37
+ import { LineChart, type LineChartDatum } from '../line-chart';
38
+ import { Progress } from '../progress';
39
+ import { Surface } from '../surface';
40
+
41
+ /** Height of the sparkline when nothing else is said. */
42
+ const CHART_HEIGHT = 56;
43
+ /**
44
+ * …and its box beside the number, which is fixed rather than shared out.
45
+ *
46
+ * A column of stat cards has labels of every length — "Revenue" over "New
47
+ * customers" — and a chart taking whatever the text leaves would be a
48
+ * different width on every card in the stack. Fixed, the shapes line up down
49
+ * the right-hand edge and the eye can compare them, which is the only reason
50
+ * they are there.
51
+ */
52
+ const INLINE_CHART_WIDTH = 128;
53
+ const INLINE_CHART_HEIGHT = 56;
54
+ /** Width ÷ height of the sparkline in that box. */
55
+ const INLINE_ASPECT = 2.3;
56
+
57
+ /** The arrow in a trend badge. Small — it is a direction, not an icon. */
58
+ const TREND_ICON = 12;
59
+ const TREND_STROKE = 2.5;
60
+
61
+ const kpiVariants = tv({
62
+ slots: {
63
+ root: 'w-full gap-2',
64
+ header: 'flex-row items-center gap-2',
65
+ icon: 'h-8 w-8 items-center justify-center rounded-xl',
66
+ title: 'text-sm font-medium text-muted-foreground',
67
+ content: 'flex-row items-end justify-between gap-3',
68
+ value: 'text-2xl font-bold text-foreground',
69
+ /** The stacked title/value/trend block, tight enough to read as one thing. */
70
+ stat: 'flex-1 gap-1',
71
+ trend: 'flex-row items-center gap-1',
72
+ trendLabel: 'font-medium',
73
+ footer: 'flex-row items-center gap-2',
74
+ separator: 'h-px w-full bg-border',
75
+ group: 'w-full',
76
+ groupSeparator: 'bg-border',
77
+ },
78
+ variants: {
79
+ /**
80
+ * Which way the number moved, *after* the metric's own polarity has been
81
+ * applied — so `good` is green whether it went up or down.
82
+ */
83
+ tone: {
84
+ good: { trendLabel: 'text-success', icon: 'bg-success-subtle' },
85
+ bad: { trendLabel: 'text-destructive', icon: 'bg-destructive-subtle' },
86
+ flat: { trendLabel: 'text-muted-foreground', icon: 'bg-muted' },
87
+ neutral: { trendLabel: 'text-muted-foreground', icon: 'bg-secondary' },
88
+ },
89
+ /**
90
+ * How the change is drawn.
91
+ *
92
+ * `text` is the quieter of the two and the default: a line of colour under
93
+ * the number, which is where the eye already is. `badge` puts a pill round
94
+ * it for a card dense enough that a bare line of colour gets lost.
95
+ */
96
+ trendVariant: {
97
+ text: { trend: 'gap-0.5', trendLabel: 'text-sm' },
98
+ badge: { trend: 'rounded-full px-2 py-0.5', trendLabel: 'text-xs' },
99
+ },
100
+ /** Where the chart sits relative to the number. */
101
+ layout: {
102
+ /** Under everything, full width. The chart is a footnote. */
103
+ below: {},
104
+ /**
105
+ * Beside the number: the text takes the width and the chart takes a
106
+ * fixed column on the end. The number is read first and the shape
107
+ * second, which is the order they sit in — so this is the shape a stat
108
+ * card wants, and `below` is for a chart big enough to be looked at.
109
+ */
110
+ inline: { content: 'items-center gap-4' },
111
+ },
112
+ },
113
+ compoundVariants: [
114
+ { trendVariant: 'badge', tone: 'good', class: { trend: 'bg-success-subtle' } },
115
+ { trendVariant: 'badge', tone: 'bad', class: { trend: 'bg-destructive-subtle' } },
116
+ { trendVariant: 'badge', tone: 'flat', class: { trend: 'bg-muted' } },
117
+ { trendVariant: 'badge', tone: 'neutral', class: { trend: 'bg-muted' } },
118
+ ],
119
+ defaultVariants: {
120
+ tone: 'neutral',
121
+ trendVariant: 'text',
122
+ layout: 'below',
123
+ },
124
+ });
125
+
126
+ type KpiVariantProps = VariantProps<typeof kpiVariants>;
127
+
128
+ /** How a trend is coloured once the metric's polarity has been applied. */
129
+ export type KpiTone = NonNullable<KpiVariantProps['tone']>;
130
+
131
+ /** Which direction of movement is the good news for this metric. */
132
+ export type KpiGoodDirection = 'up' | 'down' | 'none';
133
+
134
+ interface KpiChartContextValue {
135
+ colorIndex: SeriesColorIndex;
136
+ goodDirection: KpiGoodDirection;
137
+ }
138
+
139
+ const KpiChartContext = createContext<KpiChartContextValue | null>(null);
140
+
141
+ /**
142
+ * Whether the part rendering is inside `KpiChart.Header`.
143
+ *
144
+ * `Title` is the only part that cares, and it cares a great deal. In a header
145
+ * it is one of several things on a row and has to take the space between the
146
+ * icon and the actions, so it grows. Written straight into the card it is one
147
+ * of several things in a *column* — and a growing child of a column absorbs
148
+ * the leftover height, which pushes the number under it down by however much
149
+ * that card had spare. Three of those side by side is three numbers at three
150
+ * different heights, which is exactly what a row of metrics must not be.
151
+ */
152
+ const KpiHeaderContext = createContext(false);
153
+
154
+ /**
155
+ * Whether the part rendering is inside `KpiChart.Stat`.
156
+ *
157
+ * `Trend` is the one that cares. `self-center` on a flex child means "centre
158
+ * on the cross axis", and the cross axis is not the same axis in the two
159
+ * places a trend is put: in a row it is vertical, which is what a badge on the
160
+ * end of a header wants, and in the stat column it is *horizontal* — which
161
+ * indents the change by half whatever width it did not use, so two rows with
162
+ * captions of different lengths start at two different places.
163
+ */
164
+ const KpiStatContext = createContext(false);
165
+
166
+ function useKpiChart(part: string): KpiChartContextValue {
167
+ const context = useContext(KpiChartContext);
168
+ if (!context) throw new Error(`${part} must be used inside <KpiChart>.`);
169
+ return context;
170
+ }
171
+
172
+ /* ------------------------------------------------------------------ *
173
+ * Root.
174
+ * ------------------------------------------------------------------ */
175
+
176
+ export interface KpiChartProps extends Omit<ViewProps, 'children'> {
177
+ className?: string;
178
+ /**
179
+ * Which `--color-chart-*` token the sparkline and the icon take. Set on the
180
+ * card rather than on the chart so a row of cards can be given five
181
+ * different series colours without repeating the choice on every part.
182
+ */
183
+ colorIndex?: SeriesColorIndex;
184
+ /**
185
+ * Which way is the good news. `up` for revenue and signups, `down` for churn
186
+ * and latency, `none` for a number that is neither — a headcount, a version.
187
+ * Defaults to `up`.
188
+ */
189
+ goodDirection?: KpiGoodDirection;
190
+ /** Draw the card on a surface. Turn off to place it in a shell of your own. */
191
+ surface?: boolean;
192
+ children: ReactNode;
193
+ }
194
+
195
+ const KpiChartRoot = forwardRef<View, KpiChartProps>(function KpiChartRoot(
196
+ { className, colorIndex = 1, goodDirection = 'up', surface = true, children, ...props },
197
+ ref
198
+ ) {
199
+ const { root } = kpiVariants();
200
+ const context = useMemo(() => ({ colorIndex, goodDirection }), [colorIndex, goodDirection]);
201
+
202
+ const body = (
203
+ <View ref={surface ? undefined : ref} className={root({ className })} {...props}>
204
+ {children}
205
+ </View>
206
+ );
207
+
208
+ return (
209
+ <KpiChartContext.Provider value={context}>
210
+ {surface ? (
211
+ <Surface ref={ref} variant="secondary" padding="lg" className="w-full">
212
+ {body}
213
+ </Surface>
214
+ ) : (
215
+ body
216
+ )}
217
+ </KpiChartContext.Provider>
218
+ );
219
+ });
220
+
221
+ /* ------------------------------------------------------------------ *
222
+ * Header, and the things that live in it.
223
+ * ------------------------------------------------------------------ */
224
+
225
+ export interface KpiChartHeaderProps extends ViewProps {
226
+ className?: string;
227
+ children: ReactNode;
228
+ }
229
+
230
+ /** The top row: an icon, the metric's name, and anything acting on it. */
231
+ function KpiChartHeader({ className, children, ...props }: KpiChartHeaderProps) {
232
+ const { header } = kpiVariants();
233
+ return (
234
+ <KpiHeaderContext.Provider value={true}>
235
+ <View className={header({ className })} {...props}>
236
+ {children}
237
+ </View>
238
+ </KpiHeaderContext.Provider>
239
+ );
240
+ }
241
+
242
+ export interface KpiChartIconProps extends ViewProps {
243
+ className?: string;
244
+ /** Overrides the tint the card's `colorIndex` would give it. */
245
+ tone?: KpiTone;
246
+ children: ReactNode;
247
+ }
248
+
249
+ /**
250
+ * A tinted square for a glyph.
251
+ *
252
+ * It takes the element rather than drawing one, because a metric's icon comes
253
+ * from whatever set the app already uses — and an icon from outside this
254
+ * library will not read an ambient colour, so pass it one.
255
+ */
256
+ function KpiChartIcon({ className, tone = 'neutral', children, ...props }: KpiChartIconProps) {
257
+ const { icon } = kpiVariants({ tone });
258
+ return (
259
+ <View className={icon({ className })} {...props}>
260
+ {children}
261
+ </View>
262
+ );
263
+ }
264
+
265
+ export interface KpiChartTitleProps {
266
+ className?: string;
267
+ children: ReactNode;
268
+ }
269
+
270
+ /** The metric's name. Quiet on purpose — the value is the thing being read. */
271
+ function KpiChartTitle({ className, children }: KpiChartTitleProps) {
272
+ const { title } = kpiVariants();
273
+ // Grows across a header row; never down a column — see `KpiHeaderContext`.
274
+ const inHeader = useContext(KpiHeaderContext);
275
+ return (
276
+ <Text className={title({ className: cn(inHeader && 'flex-1', className) })} numberOfLines={1}>
277
+ {children}
278
+ </Text>
279
+ );
280
+ }
281
+
282
+ export interface KpiChartStatProps extends ViewProps {
283
+ className?: string;
284
+ children: ReactNode;
285
+ }
286
+
287
+ /**
288
+ * The stacked title / value / change block.
289
+ *
290
+ * Its own container rather than three loose children of the card, because the
291
+ * three belong together more tightly than they belong to whatever is above or
292
+ * below them — 4pt between the lines of one fact, and the card's own spacing
293
+ * between facts. It also takes the width in an `inline` row, leaving the chart
294
+ * its column on the end.
295
+ */
296
+ function KpiChartStat({ className, children, ...props }: KpiChartStatProps) {
297
+ const { stat } = kpiVariants();
298
+ return (
299
+ <KpiStatContext.Provider value={true}>
300
+ <View className={stat({ className })} {...props}>
301
+ {children}
302
+ </View>
303
+ </KpiStatContext.Provider>
304
+ );
305
+ }
306
+
307
+ export interface KpiChartActionsProps extends ViewProps {
308
+ className?: string;
309
+ children: ReactNode;
310
+ }
311
+
312
+ /** The trailing end of the header — a menu trigger, a filter, a link. */
313
+ function KpiChartActions({ className, children, ...props }: KpiChartActionsProps) {
314
+ return (
315
+ <View className={cn('flex-row items-center gap-1', className)} {...props}>
316
+ {children}
317
+ </View>
318
+ );
319
+ }
320
+
321
+ /* ------------------------------------------------------------------ *
322
+ * The number, and what it is doing.
323
+ * ------------------------------------------------------------------ */
324
+
325
+ export interface KpiChartContentProps extends ViewProps {
326
+ className?: string;
327
+ /** `inline` puts the chart beside the value instead of under everything. */
328
+ layout?: NonNullable<KpiVariantProps['layout']>;
329
+ children: ReactNode;
330
+ }
331
+
332
+ /** The row the value and the trend share. */
333
+ function KpiChartContent({
334
+ className,
335
+ layout = 'below',
336
+ children,
337
+ ...props
338
+ }: KpiChartContentProps) {
339
+ const { content } = kpiVariants({ layout });
340
+ return (
341
+ <View className={content({ className })} {...props}>
342
+ {children}
343
+ </View>
344
+ );
345
+ }
346
+
347
+ export interface KpiChartValueProps {
348
+ className?: string;
349
+ children: ReactNode;
350
+ }
351
+
352
+ /**
353
+ * The number.
354
+ *
355
+ * Formatted by the caller, not here: thousands separators, currency symbols
356
+ * and units are locale decisions, and a component that guessed them would be
357
+ * wrong in a way that is hard to notice and impossible to override.
358
+ */
359
+ function KpiChartValue({ className, children }: KpiChartValueProps) {
360
+ const { value } = kpiVariants();
361
+ return (
362
+ <Text className={value({ className })} numberOfLines={1}>
363
+ {children}
364
+ </Text>
365
+ );
366
+ }
367
+
368
+ export interface KpiChartTrendProps extends Omit<ViewProps, 'children'> {
369
+ className?: string;
370
+ /**
371
+ * How much it moved, as a percentage. The sign carries the direction, so
372
+ * `-4.2` is a fall of 4.2%; there is no separate direction prop to keep in
373
+ * step with it.
374
+ */
375
+ value: number;
376
+ /**
377
+ * Writes the number yourself. Receives the raw value, sign and all. The
378
+ * default prints one decimal place with an explicit `+` or `−`.
379
+ */
380
+ format?: (value: number) => string;
381
+ /** Overrides the card's own `goodDirection` for this one figure. */
382
+ goodDirection?: KpiGoodDirection;
383
+ /**
384
+ * `text` is a line of colour under the number — the default, and what a
385
+ * stat card usually wants. `badge` puts a pill round it, with an arrow, for
386
+ * a card busy enough that a bare line of colour is lost in it.
387
+ */
388
+ variant?: NonNullable<KpiVariantProps['trendVariant']>;
389
+ /** What it is being compared against — "last 30d", "vs last week". */
390
+ caption?: string;
391
+ /** Anything after the number, when a caption is not enough. */
392
+ children?: ReactNode;
393
+ /** Below which a movement counts as no movement. Defaults to `0`. */
394
+ threshold?: number;
395
+ }
396
+
397
+ /**
398
+ * The change.
399
+ *
400
+ * Colour comes from what the movement *means*, not from its sign: a fall in
401
+ * churn is good news and is drawn as good news. That is the whole reason this
402
+ * takes a number rather than a string — a caller writing "−4.2%" into a green
403
+ * label has to remember to change the colour when the metric changes, and
404
+ * nobody does.
405
+ */
406
+ function KpiChartTrend({
407
+ className,
408
+ value,
409
+ format,
410
+ goodDirection,
411
+ variant = 'text',
412
+ caption,
413
+ threshold = 0,
414
+ children,
415
+ ...props
416
+ }: KpiChartTrendProps) {
417
+ const context = useKpiChart('KpiChart.Trend');
418
+ const polarity = goodDirection ?? context.goodDirection;
419
+
420
+ const flat = Math.abs(value) <= threshold;
421
+ const rising = value > 0;
422
+ const tone: KpiTone = flat
423
+ ? 'flat'
424
+ : polarity === 'none'
425
+ ? 'neutral'
426
+ : (rising && polarity === 'up') || (!rising && polarity === 'down')
427
+ ? 'good'
428
+ : 'bad';
429
+
430
+ // Down a column it starts at the leading edge, level with the title and the
431
+ // number above it; across a row it centres against them — see `KpiStatContext`.
432
+ const inStat = useContext(KpiStatContext);
433
+ const { trend, trendLabel } = kpiVariants({ tone, trendVariant: variant });
434
+
435
+ // The arrow's colour, resolved so it matches the label beside it. An icon
436
+ // from outside this library does not inherit a text colour.
437
+ const goodTint = useCSSVariable('--color-success');
438
+ const badTint = useCSSVariable('--color-destructive');
439
+ const mutedTint = useCSSVariable('--color-muted-foreground');
440
+ const raw = tone === 'good' ? goodTint : tone === 'bad' ? badTint : mutedTint;
441
+ const tint = typeof raw === 'string' ? raw : '#737373';
442
+
443
+ const Arrow = flat ? Minus : rising ? ArrowUp : ArrowDown;
444
+
445
+ const label = format
446
+ ? format(value)
447
+ : // A true minus sign rather than a hyphen: at this size a hyphen reads as
448
+ // a dash between two words, and the sign is half the meaning.
449
+ `${rising ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(1)}%`;
450
+
451
+ return (
452
+ <View
453
+ className={trend({ className: cn(inStat ? 'self-start' : 'self-center', className) })}
454
+ accessible
455
+ accessibilityLabel={`${flat ? 'No change' : rising ? 'Up' : 'Down'} ${Math.abs(
456
+ value
457
+ ).toFixed(1)} percent${caption ? `, ${caption}` : ''}`}
458
+ {...props}
459
+ >
460
+ {/* No arrow in `text`: the sign is already in front of the number, and
461
+ drawing both says the same thing twice in the same three points. */}
462
+ {variant === 'badge' ? (
463
+ <Arrow size={TREND_ICON} strokeWidth={TREND_STROKE} color={tint} />
464
+ ) : null}
465
+ <Text className={trendLabel()}>
466
+ {label}
467
+ {/* One Text, not two: a caption in its own element wraps onto its own
468
+ line the moment the card gets narrow, which reads as a second fact
469
+ rather than the rest of this one. */}
470
+ {caption ? <Text className="text-muted-foreground"> {caption}</Text> : null}
471
+ </Text>
472
+ {children}
473
+ </View>
474
+ );
475
+ }
476
+
477
+ /* ------------------------------------------------------------------ *
478
+ * The chart, and the bar.
479
+ * ------------------------------------------------------------------ */
480
+
481
+ export interface KpiChartSparklineProps {
482
+ className?: string;
483
+ /** The rows. One point each, in order. */
484
+ data: LineChartDatum[];
485
+ /** Key holding the y values. */
486
+ dataKey: string;
487
+ /** Overrides the card's `colorIndex`. */
488
+ colorIndex?: SeriesColorIndex;
489
+ /**
490
+ * Fill under the line. Off beside the number, where the chart is a gesture
491
+ * and a fill would make it a second block competing with the value; on when
492
+ * it has the full width under everything and is being looked at properly.
493
+ */
494
+ filled?: boolean;
495
+ /** Height in points. */
496
+ height?: number;
497
+ /**
498
+ * Put it beside the number, taking whatever width the text leaves rather
499
+ * than a column of its own. Pair with `layout="inline"` on the content row.
500
+ */
501
+ inline?: boolean;
502
+ strokeWidth?: number;
503
+ }
504
+
505
+ /**
506
+ * The sparkline.
507
+ *
508
+ * A line chart with the axis padding dropped, which is what `compact` on the
509
+ * chart itself means — there is no grid, no axis and no crosshair here, so
510
+ * every point of padding is a point the shape is not using. Nobody reads a
511
+ * value off one of these; they read whether it is going up.
512
+ */
513
+ function KpiChartSparkline({
514
+ className,
515
+ data,
516
+ dataKey,
517
+ colorIndex,
518
+ filled,
519
+ height,
520
+ inline = false,
521
+ strokeWidth = 2,
522
+ }: KpiChartSparklineProps) {
523
+ const context = useKpiChart('KpiChart.Chart');
524
+ const index = colorIndex ?? context.colorIndex;
525
+ const fill = filled ?? !inline;
526
+
527
+ return (
528
+ <View
529
+ className={cn(inline ? '' : 'w-full', className)}
530
+ style={
531
+ inline
532
+ ? { width: INLINE_CHART_WIDTH, height: height ?? INLINE_CHART_HEIGHT }
533
+ : { height: height ?? CHART_HEIGHT }
534
+ }
535
+ >
536
+ <LineChart data={data} compact aspectRatio={inline ? INLINE_ASPECT : 4}>
537
+ {fill ? <LineChart.Area dataKey={dataKey} colorIndex={index} /> : null}
538
+ <LineChart.Line dataKey={dataKey} colorIndex={index} strokeWidth={strokeWidth} />
539
+ </LineChart>
540
+ </View>
541
+ );
542
+ }
543
+
544
+ export interface KpiChartProgressProps {
545
+ className?: string;
546
+ /** Where it has got to. */
547
+ value: number;
548
+ /** The value at which the bar reads as full. Defaults to `100`. */
549
+ maxValue?: number;
550
+ /** A caption above the bar. */
551
+ label?: string;
552
+ /** Print the percentage on the right of the caption row. */
553
+ showValueLabel?: boolean;
554
+ }
555
+
556
+ /** Progress towards a target, for a metric that has one. */
557
+ function KpiChartProgressBar({
558
+ className,
559
+ value,
560
+ maxValue = 100,
561
+ label,
562
+ showValueLabel,
563
+ }: KpiChartProgressProps) {
564
+ return (
565
+ <Progress
566
+ className={className}
567
+ value={value}
568
+ maxValue={maxValue}
569
+ label={label}
570
+ showValueLabel={showValueLabel}
571
+ size="sm"
572
+ />
573
+ );
574
+ }
575
+
576
+ /* ------------------------------------------------------------------ *
577
+ * Footer and separator.
578
+ * ------------------------------------------------------------------ */
579
+
580
+ export interface KpiChartFooterProps extends ViewProps {
581
+ className?: string;
582
+ children: ReactNode;
583
+ }
584
+
585
+ /** The bottom strip — a comparison period, a caveat, a link. */
586
+ function KpiChartFooter({ className, children, ...props }: KpiChartFooterProps) {
587
+ const { footer } = kpiVariants();
588
+ return (
589
+ <View className={footer({ className })} {...props}>
590
+ {children}
591
+ </View>
592
+ );
593
+ }
594
+
595
+ export interface KpiChartSeparatorProps extends ViewProps {
596
+ className?: string;
597
+ }
598
+
599
+ /** A hairline across the card. */
600
+ function KpiChartSeparator({ className, ...props }: KpiChartSeparatorProps) {
601
+ const { separator } = kpiVariants();
602
+ return <View className={separator({ className })} {...props} />;
603
+ }
604
+
605
+ /* ------------------------------------------------------------------ *
606
+ * Group.
607
+ * ------------------------------------------------------------------ */
608
+
609
+ export type KpiChartGroupOrientation = 'horizontal' | 'vertical';
610
+
611
+ export interface KpiChartGroupProps extends ViewProps {
612
+ className?: string;
613
+ /** `horizontal` splits the row between the cards; `vertical` stacks them. */
614
+ orientation?: KpiChartGroupOrientation;
615
+ /**
616
+ * Draw a hairline between the cards rather than spacing them apart. Several
617
+ * metrics separated by a rule read as one panel; several spaced apart read
618
+ * as several panels that happen to be adjacent.
619
+ */
620
+ separated?: boolean;
621
+ children: ReactNode;
622
+ }
623
+
624
+ /**
625
+ * Several metrics, laid out as one panel.
626
+ *
627
+ * The separators are inserted between the children rather than written by the
628
+ * caller, because "between" is the one thing a list of siblings cannot express
629
+ * — a trailing rule after the last card is the mistake this exists to prevent.
630
+ */
631
+ const KpiChartGroup = forwardRef<View, KpiChartGroupProps>(function KpiChartGroup(
632
+ { className, orientation = 'horizontal', separated = true, children, ...props },
633
+ ref
634
+ ) {
635
+ const { group, groupSeparator } = kpiVariants();
636
+ const horizontal = orientation === 'horizontal';
637
+
638
+ const items = Array.isArray(children) ? children.flat() : [children];
639
+ const visible = items.filter(Boolean);
640
+
641
+ return (
642
+ <View
643
+ ref={ref}
644
+ className={cn(
645
+ group(),
646
+ horizontal ? 'flex-row items-stretch' : 'flex-col',
647
+ separated ? '' : 'gap-3',
648
+ className
649
+ )}
650
+ {...props}
651
+ >
652
+ {visible.map((child, index) => (
653
+ <View
654
+ key={index}
655
+ className={cn(
656
+ horizontal ? 'flex-1' : 'w-full',
657
+ // The padding is on the children rather than the rule, so the rule
658
+ // reaches the full height or width of the panel.
659
+ separated && horizontal && index > 0 && 'ps-4',
660
+ separated && horizontal && index < visible.length - 1 && 'pe-4',
661
+ separated && !horizontal && index > 0 && 'pt-4',
662
+ separated && !horizontal && index < visible.length - 1 && 'pb-4'
663
+ )}
664
+ >
665
+ {separated && index > 0 ? (
666
+ <View
667
+ className={cn(
668
+ groupSeparator(),
669
+ // Absolute on both axes, and on the *edge* of the padded box.
670
+ // In normal flow the rule lands after this cell's leading
671
+ // padding rather than on the boundary, which reads as a gap
672
+ // above the rule and none below it.
673
+ horizontal
674
+ ? 'absolute bottom-0 start-0 top-0 w-px'
675
+ : 'absolute end-0 start-0 top-0 h-px'
676
+ )}
677
+ // A rule between panels is decoration; announcing it puts an
678
+ // unlabelled stop between two metrics.
679
+ accessibilityElementsHidden
680
+ importantForAccessibility="no-hide-descendants"
681
+ />
682
+ ) : null}
683
+ {child}
684
+ </View>
685
+ ))}
686
+ </View>
687
+ );
688
+ });
689
+
690
+ KpiChartRoot.displayName = 'KpiChart';
691
+ KpiChartHeader.displayName = 'KpiChart.Header';
692
+ KpiChartIcon.displayName = 'KpiChart.Icon';
693
+ KpiChartTitle.displayName = 'KpiChart.Title';
694
+ KpiChartStat.displayName = 'KpiChart.Stat';
695
+ KpiChartActions.displayName = 'KpiChart.Actions';
696
+ KpiChartContent.displayName = 'KpiChart.Content';
697
+ KpiChartValue.displayName = 'KpiChart.Value';
698
+ KpiChartTrend.displayName = 'KpiChart.Trend';
699
+ KpiChartSparkline.displayName = 'KpiChart.Chart';
700
+ KpiChartProgressBar.displayName = 'KpiChart.Progress';
701
+ KpiChartFooter.displayName = 'KpiChart.Footer';
702
+ KpiChartSeparator.displayName = 'KpiChart.Separator';
703
+ KpiChartGroup.displayName = 'KpiChart.Group';
704
+
705
+ export const KpiChart = Object.assign(KpiChartRoot, {
706
+ Header: KpiChartHeader,
707
+ Icon: KpiChartIcon,
708
+ Title: KpiChartTitle,
709
+ Stat: KpiChartStat,
710
+ Actions: KpiChartActions,
711
+ Content: KpiChartContent,
712
+ Value: KpiChartValue,
713
+ Trend: KpiChartTrend,
714
+ Chart: KpiChartSparkline,
715
+ Progress: KpiChartProgressBar,
716
+ Footer: KpiChartFooter,
717
+ Separator: KpiChartSeparator,
718
+ Group: KpiChartGroup,
719
+ });