@delacour/react-native-ui 0.1.0-alpha.20260925064429 → 0.1.0-alpha.20260925064741

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20260925064429",
3
+ "version": "0.1.0-alpha.20260925064741",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,6 +48,7 @@
48
48
  "./field": "./src/components/field/index.ts",
49
49
  "./icon": "./src/components/icon/index.ts",
50
50
  "./input": "./src/components/input/index.ts",
51
+ "./kpi": "./src/components/kpi/index.ts",
51
52
  "./label": "./src/components/label/index.ts",
52
53
  "./list-group": "./src/components/list-group/index.ts",
53
54
  "./meter": "./src/components/meter/index.ts",
@@ -96,7 +97,7 @@
96
97
  },
97
98
  "peerDependencies": {
98
99
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
99
- "@delacour/react-native-charts": "0.1.0-alpha.20260925064429",
100
+ "@delacour/react-native-charts": "0.1.0-alpha.20260925064741",
100
101
  "@gorhom/bottom-sheet": "^5.2.8",
101
102
  "@legendapp/list": ">=3.3",
102
103
  "expo-linear-gradient": ">=15",
@@ -87,6 +87,14 @@ knows nothing about tokens.
87
87
  tailwind-merge is what lets a caller's `className="aspect-video h-auto"`
88
88
  cleanly win when they want that instead.
89
89
 
90
+ - **`frameClassName` resizes the frame, not the root.** The canvas fills the
91
+ frame, and the frame carries the size's height, so a `className` on the root
92
+ cannot shorten the plot — the frame would overflow it. `frameClassName` is
93
+ merged after the size's `h-chart-*`, and the tokens are registered with
94
+ tailwind-merge, so `h-16` cleanly replaces it. `Kpi.Sparkline` is the reason
95
+ it exists: a sparkline is a chart with no axes a third the height of the
96
+ smallest one.
97
+
90
98
  - **The tooltip is a React Native view even though it floats over the canvas.**
91
99
  It wants `popover`, `border`, the radius scale and the type scale, none of
92
100
  which exist in Skia, and it is the part a caller most wants to restyle —
@@ -72,6 +72,12 @@ export type ChartProps = {
72
72
  /** How the scrub coexists with a scrolling parent. Defaults to holding. */
73
73
  scrubConfig?: ScrubConfig;
74
74
  className?: string;
75
+ /**
76
+ * Merged onto the frame — the view the canvas fills — after the size's
77
+ * height. `frameClassName="h-16"` is a sparkline; the three `size` heights
78
+ * are for a chart someone reads with axes.
79
+ */
80
+ frameClassName?: string;
75
81
  /** Named on the frame, so a capture flow or a test can find the plot. */
76
82
  testID?: string;
77
83
  children?: ReactNode;
@@ -106,6 +112,7 @@ function ChartRoot({
106
112
  orientation = "vertical",
107
113
  scrubConfig,
108
114
  className,
115
+ frameClassName,
109
116
  testID,
110
117
  children,
111
118
  }: ChartProps): ReactElement {
@@ -231,7 +238,7 @@ function ChartRoot({
231
238
  return (
232
239
  <ChartProvider value={value}>
233
240
  <View className={slots.root({ className })}>
234
- <View className={slots.frame()} onLayout={onLayout} testID={testID}>
241
+ <View className={slots.frame({ className: frameClassName })} onLayout={onLayout} testID={testID}>
235
242
  <CartesianChart
236
243
  curve={curve}
237
244
  data={data}
@@ -0,0 +1,125 @@
1
+ # Kpi
2
+
3
+ One number, what it is doing, and the shape it made getting there — a
4
+ [`Card`](../card/AGENTS.md) with a vocabulary for a metric, whose sparkline is a
5
+ [`Chart`](../chart/AGENTS.md) line. Compound root plus eleven parts: `Header`,
6
+ `Icon`, `Title`, `Action`, `Content`, `Stat`, `Value`, `Trend`, `Sparkline`,
7
+ `Footer`, `Group`.
8
+
9
+ `import { Kpi } from "@delacour/react-native-ui/kpi";`
10
+
11
+ ## Files
12
+
13
+ | File | What it holds |
14
+ | --- | --- |
15
+ | `index.ts` | → `@delacour/react-native-ui/kpi` |
16
+ | `kpi.tsx` | Root + the `Object.assign` compound surface |
17
+ | `kpi-header.tsx` | `Kpi.Header`, on the card header's inset |
18
+ | `kpi-icon.tsx` | `Kpi.Icon`, and the icon defaults it publishes |
19
+ | `kpi-title.tsx` | `Kpi.Title` |
20
+ | `kpi-action.tsx` | `Kpi.Action` |
21
+ | `kpi-content.tsx` | `Kpi.Content`, and the layout it publishes |
22
+ | `kpi-stat.tsx` | `Kpi.Stat` |
23
+ | `kpi-value.tsx` | `Kpi.Value`, and its loading placeholder |
24
+ | `kpi-trend.tsx` | `Kpi.Trend` — text or badge |
25
+ | `kpi-sparkline.tsx` | `Kpi.Sparkline`, and the internal scrub reader |
26
+ | `kpi-footer.tsx` | `Kpi.Footer`, over `Card.Footer` |
27
+ | `kpi-group.tsx` | `Kpi.Group` |
28
+ | `kpi.context.tsx` | `KpiProvider`, `useKpi()`, `useKpiContext()`, `useKpiPart()`, the layout and group contexts |
29
+ | `kpi.types.ts` | Prop types shared by two or more parts |
30
+ | `kpi.variants.ts` | The slotted `tv()`, the axes, and every pure resolver — no RN imports |
31
+ | `kpi.variants.test.ts` | |
32
+
33
+ ## Design
34
+
35
+ - **A KPI is a card, not a second definition of one.** The root renders `Card`
36
+ with the caller's `variant` and `size`, so it takes the four fills, steps
37
+ when nested, and has no scale of its own — `KPI_SIZES` *is* `CARD_SIZES`.
38
+ `Kpi.Header` and `Kpi.Content` compose their classes onto `cardVariants`'
39
+ own `header` and `content` slots, and the test asserts the card's inset
40
+ survives the merge, so the number, the title and the footer sit on one line
41
+ and a retune of the card moves all three.
42
+ - **The trend is coloured by what the movement means, never by its sign.**
43
+ `resolveKpiTrend` turns a signed percentage and a `goodDirection` into a
44
+ `direction` and a `tone`. A fall in churn is `down` and `good`, drawn in
45
+ `success-soft-foreground`. `goodDirection` is said once on the card and every
46
+ trend follows it unless it overrides it; `none` keeps the direction and drops
47
+ the judgement. A value that is not finite — a change against a zero baseline
48
+ — is flat and neutral rather than news of either kind.
49
+ - **There is no direction prop.** The sign is the direction. A separate prop
50
+ would be a second statement of one fact that the call site has to keep in
51
+ step, and the first time it did not the card would paint a fall green.
52
+ - **The trend is printed with a typographic minus.** `−4.2%` rather than
53
+ `-4.2%`: a hyphen is narrower than a plus, so a column of changes would not
54
+ line up on the sign. A value that rounds to zero is unsigned — `−0.0%` would
55
+ claim a fall nothing shows.
56
+ - **Colour is never the only signal.** The sign carries the direction in text,
57
+ the badge adds an arrow, and the trend is one accessible string — "Up 7.8
58
+ percent, vs last month" — rather than a number, an arrow and a caption read
59
+ as three stops.
60
+ - **The badge is `Badge`, soft.** `KPI_TONE_BADGE_COLOR` maps the tone onto
61
+ `success`, `destructive` and `default`, so a trend badge is the same object as
62
+ any other status badge on the screen.
63
+ - **The value is formatted by the caller.** Separators, currency and units are
64
+ locale decisions a component would get wrong in a way that is hard to notice
65
+ and impossible to override. It is one line, shrinking to fit — a number that
66
+ wraps is no longer read as one — and it takes the foreground token of the
67
+ plane the card landed on, the way `Card.Title` does.
68
+ - **The title never grows.** A growing child of a column absorbs the column's
69
+ spare height, which in a row of cards lands every number at a different
70
+ height. The header pushes `Kpi.Action` to the end with `ml-auto` instead, and
71
+ a test forbids `flex-1` on the title.
72
+ - **`Kpi.Stat` exists because the value and its change are one fact.** The
73
+ card's gap is for the space between facts; written straight into the content
74
+ the two drift apart and stop reading as a unit. Beside an `inline` sparkline
75
+ the stat takes the row's width.
76
+ - **The sparkline is `Chart`, with nothing but the line.** No grid, no axes, no
77
+ tooltip readout — `Chart` reserves an axis gutter only for an axis that is
78
+ placed, so the plot is the whole frame. The frame is sized through `Chart`'s
79
+ `frameClassName`, which exists for this: the chart's three `size` heights are
80
+ for a chart someone reads with axes. Rows or a bare list of numbers both work;
81
+ `resolveSparklineSeries` indexes a list.
82
+ - **The y bounds are the data's own, padded a tenth.** With no padding a line
83
+ drawn to its exact extent puts the peak and trough on the frame's edge, half
84
+ the stroke off the canvas. `resolveSparklineDomain` pads the span and gives a
85
+ flat series a unit either side so it sits centred.
86
+ - **Inline is a fixed 128pt column; below is the full width.** A stack of cards
87
+ has labels of every length, and a chart taking whatever the text left would
88
+ be a different width on every card. Fixed, the shapes line up down the
89
+ right-hand edge, which is the reason to put them there. Below, the chart is
90
+ filled; inline it is not, where a fill would make it a second block competing
91
+ with the number — `resolveSparklineFilled`, overridable.
92
+ - **`colorIndex` is on the card, not the chart.** The icon and the sparkline
93
+ share it, and a row of cards gets five series colours by setting one prop on
94
+ each. The trend ignores it: a series colour has nothing to say about whether
95
+ the number went the right way. The icon square is the series colour at 15%,
96
+ written out per index because Tailwind's scanner cannot see a class built at
97
+ runtime.
98
+ - **Holding the sparkline scrubs it, and the scrub is state.** A
99
+ `Chart.Tooltip.Dot` rides the line, and an internal `KpiSparklineScrub` —
100
+ a child of `<Chart>` that is not a mark, so the chart mounts it beside the
101
+ canvas with the scrub's shared values in reach — reports the index to the
102
+ root through `scheduleOnRN` only when it changes, and `null` when the finger
103
+ lifts. The root holds it with `useControllableState`: `activeIndex` and
104
+ `onActiveIndexChange` control it, `defaultActiveIndex` seeds it, and
105
+ `useKpi()` reads it, so a part of the caller's own prints the scrubbed
106
+ point's value in place of the latest. A controlled index moves what the
107
+ caller prints; it draws no dot, because the dot is the finger's.
108
+ - **Loading is a placeholder the size of what it stands for.** `isLoading`
109
+ swaps the value, the trend and the sparkline for muted blocks — the value's
110
+ is the height of its line at each size, which a test pins — and marks the
111
+ card busy, so nothing jumps when the data lands. They are
112
+ `muted-foreground` at 15%, not `bg-muted`: `muted` is the secondary fill, and
113
+ the first build's placeholders vanished on a secondary card. A translucent
114
+ foreground reads on every plane, and a test forbids a fill token there.
115
+ - **No interaction on the card.** Like `Card`, a KPI that selects or navigates
116
+ is a `Pressable` around it, which keeps the role, the haptic and the pressed
117
+ state at the call site that knows what the press means. The playground's
118
+ metric picker is the example.
119
+ - **`Kpi.Group` arranges; it does not restyle.** A row gives each metric an
120
+ equal share of the width. `separated` sets them on one `Surface` with a
121
+ `Separator` between each — hidden from assistive technology, so a rule is not
122
+ an unlabelled stop between two numbers — and each metric drops its own fill
123
+ to `transparent` unless it names one.
124
+ - **No text treatment on a view slot** (rule 1). The tests assert it across
125
+ every combination.
@@ -0,0 +1,45 @@
1
+ export { Kpi, type KpiProps } from "./kpi";
2
+ export {
3
+ type KpiContextValue,
4
+ type KpiGroupContextValue,
5
+ KpiProvider,
6
+ useKpi,
7
+ useKpiContext,
8
+ useKpiLayout,
9
+ } from "./kpi.context";
10
+ export type { KpiSlotProps, KpiTextProps } from "./kpi.types";
11
+ export {
12
+ formatKpiTrend,
13
+ KPI_COLOR_INDEXES,
14
+ KPI_DIRECTIONS,
15
+ KPI_GOOD_DIRECTIONS,
16
+ KPI_GROUP_ORIENTATIONS,
17
+ KPI_LAYOUTS,
18
+ KPI_SIZES,
19
+ KPI_TONE_BADGE_COLOR,
20
+ KPI_TONES,
21
+ KPI_TREND_VARIANTS,
22
+ type KpiColorIndex,
23
+ type KpiDirection,
24
+ type KpiGoodDirection,
25
+ type KpiGroupOrientation,
26
+ type KpiLayout,
27
+ type KpiSize,
28
+ type KpiSparklineData,
29
+ type KpiSparklineSeries,
30
+ type KpiTone,
31
+ type KpiTrendVariant,
32
+ type KpiVariantProps,
33
+ kpiSparklineAccessibilityLabel,
34
+ kpiTrendAccessibilityLabel,
35
+ kpiVariants,
36
+ resolveKpiTrend,
37
+ resolveSparklineDomain,
38
+ resolveSparklineFilled,
39
+ resolveSparklineSeries,
40
+ } from "./kpi.variants";
41
+ export type { KpiContentProps } from "./kpi-content";
42
+ export type { KpiFooterProps } from "./kpi-footer";
43
+ export type { KpiGroupProps } from "./kpi-group";
44
+ export type { KpiSparklineProps } from "./kpi-sparkline";
45
+ export type { KpiTrendProps } from "./kpi-trend";
@@ -0,0 +1,12 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import { useKpiPart } from "./kpi.context";
4
+ import type { KpiSlotProps } from "./kpi.types";
5
+ import { kpiVariants } from "./kpi.variants";
6
+
7
+ /** The header's trailing end — a menu button, a period filter, a link — pushed to the right edge. */
8
+ export function KpiAction({ className, ...props }: KpiSlotProps): ReactElement {
9
+ const { size } = useKpiPart("Kpi.Action");
10
+ return <View className={kpiVariants({ size }).action({ className })} {...props} />;
11
+ }
12
+ KpiAction.displayName = "DelacourUI.Kpi.Action";
@@ -0,0 +1,35 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import { cn } from "../../lib/cn";
4
+ import { cardVariants } from "../card/card.variants";
5
+ import { KpiLayoutProvider, useKpiPart } from "./kpi.context";
6
+ import type { KpiSlotProps } from "./kpi.types";
7
+ import { type KpiLayout, kpiVariants } from "./kpi.variants";
8
+
9
+ export type KpiContentProps = KpiSlotProps & {
10
+ /**
11
+ * `below` stacks the stat over a full-width sparkline. `inline` sets the
12
+ * sparkline in a fixed column beside the number, for a stack of cards whose
13
+ * shapes should line up down the right-hand edge.
14
+ */
15
+ layout?: KpiLayout;
16
+ };
17
+
18
+ /**
19
+ * The body: the stat and the sparkline. Sits on the card content's own inset,
20
+ * and tells the stat and the sparkline inside it which layout they are in.
21
+ */
22
+ export function KpiContent({ layout = "below", className, ...props }: KpiContentProps): ReactElement {
23
+ const { size } = useKpiPart("Kpi.Content");
24
+ return (
25
+ <KpiLayoutProvider value={layout}>
26
+ <View
27
+ className={cardVariants({ size }).content({
28
+ className: cn(kpiVariants({ layout, size }).content(), className),
29
+ })}
30
+ {...props}
31
+ />
32
+ </KpiLayoutProvider>
33
+ );
34
+ }
35
+ KpiContent.displayName = "DelacourUI.Kpi.Content";
@@ -0,0 +1,52 @@
1
+ import { Children, type ReactElement, type ReactNode } from "react";
2
+ import type { CardFooterProps } from "../card";
3
+ import { Card } from "../card";
4
+ import { Text } from "../text";
5
+ import { useKpiPart } from "./kpi.context";
6
+ import { kpiVariants } from "./kpi.variants";
7
+
8
+ export type KpiFooterProps = CardFooterProps;
9
+
10
+ /**
11
+ * Wraps each run of bare strings and numbers in one muted caption. A raw string
12
+ * inside a `View` is a red box in React Native, and the run is joined rather
13
+ * than wrapped piece by piece: `last {days} days` is three children, and three
14
+ * `Text`s in the footer's row would sit apart by its gap.
15
+ */
16
+ function wrapBareText(children: ReactNode, className: string): ReactNode[] {
17
+ const output: ReactNode[] = [];
18
+ let run: string[] = [];
19
+ const flush = (): void => {
20
+ if (run.length === 0) return;
21
+ output.push(
22
+ <Text className={className} key={`text-${output.length}`}>
23
+ {run.join("")}
24
+ </Text>
25
+ );
26
+ run = [];
27
+ };
28
+
29
+ for (const child of Children.toArray(children)) {
30
+ if (typeof child === "string" || typeof child === "number") {
31
+ run.push(String(child));
32
+ } else {
33
+ flush();
34
+ output.push(child);
35
+ }
36
+ }
37
+ flush();
38
+ return output;
39
+ }
40
+
41
+ /**
42
+ * The bottom strip — a comparison period, a caveat, a link.
43
+ *
44
+ * `Card.Footer`, so `variant="band"` sets it into the card on the next fill
45
+ * down, and it must be the last child for the same reason. Bare text becomes a
46
+ * muted caption at the card's scale.
47
+ */
48
+ export function KpiFooter({ children, ...props }: KpiFooterProps): ReactElement {
49
+ const { size } = useKpiPart("Kpi.Footer");
50
+ return <Card.Footer {...props}>{wrapBareText(children, kpiVariants({ size }).trendCaption())}</Card.Footer>;
51
+ }
52
+ KpiFooter.displayName = "DelacourUI.Kpi.Footer";
@@ -0,0 +1,70 @@
1
+ import { Children, type ReactElement, type ReactNode, useMemo } from "react";
2
+ import { View } from "react-native";
3
+ import { Separator } from "../separator";
4
+ import { Surface } from "../surface";
5
+ import { type KpiGroupContextValue, KpiGroupProvider } from "./kpi.context";
6
+ import type { KpiSlotProps } from "./kpi.types";
7
+ import { type KpiGroupOrientation, kpiVariants } from "./kpi.variants";
8
+
9
+ export type KpiGroupProps = KpiSlotProps & {
10
+ /** `horizontal` splits the row between the metrics; `vertical` stacks them. */
11
+ orientation?: KpiGroupOrientation;
12
+ /**
13
+ * Set the metrics on one surface with a rule between them, rather than as
14
+ * separate cards spaced apart. Several metrics divided by a rule read as one
15
+ * panel; several spaced apart read as several panels that happen to be
16
+ * adjacent.
17
+ */
18
+ separated?: boolean;
19
+ };
20
+
21
+ /** Puts a rule between adjacent children. `Separator` is hidden from assistive technology. */
22
+ function withRules(children: ReactNode, orientation: KpiGroupOrientation): ReactNode[] {
23
+ const output: ReactNode[] = [];
24
+ for (const [index, child] of Children.toArray(children).entries()) {
25
+ if (index > 0) {
26
+ output.push(
27
+ <Separator key={`rule-${index}`} orientation={orientation === "horizontal" ? "vertical" : "horizontal"} />
28
+ );
29
+ }
30
+ output.push(child);
31
+ }
32
+ return output;
33
+ }
34
+
35
+ /**
36
+ * Several metrics as one arrangement, in a row or a column.
37
+ *
38
+ * Each `Kpi` inside reads the group: in a row it takes an equal share of the
39
+ * width, and in a `separated` group it drops its own fill and hairline so the
40
+ * group's one surface holds them all, a rule between each.
41
+ */
42
+ export function KpiGroup({
43
+ orientation = "vertical",
44
+ separated = false,
45
+ className,
46
+ children,
47
+ ...props
48
+ }: KpiGroupProps): ReactElement {
49
+ const context = useMemo<KpiGroupContextValue>(() => ({ orientation, separated }), [orientation, separated]);
50
+ const slots = kpiVariants({ orientation, separated });
51
+
52
+ if (separated) {
53
+ return (
54
+ <KpiGroupProvider value={context}>
55
+ <Surface className={slots.group({ className })} padding="none" {...props}>
56
+ {withRules(children, orientation)}
57
+ </Surface>
58
+ </KpiGroupProvider>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <KpiGroupProvider value={context}>
64
+ <View className={slots.group({ className })} {...props}>
65
+ {children}
66
+ </View>
67
+ </KpiGroupProvider>
68
+ );
69
+ }
70
+ KpiGroup.displayName = "DelacourUI.Kpi.Group";
@@ -0,0 +1,25 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import { cn } from "../../lib/cn";
4
+ import { cardVariants } from "../card/card.variants";
5
+ import { useKpiPart } from "./kpi.context";
6
+ import type { KpiSlotProps } from "./kpi.types";
7
+ import { kpiVariants } from "./kpi.variants";
8
+
9
+ /**
10
+ * The top row: a tinted icon, the metric's name, and anything acting on it.
11
+ *
12
+ * Sits on the card header's own inset — the classes are the card's, with the
13
+ * row centred rather than top-aligned — so the title lines up with the number
14
+ * and the footer below it at every size.
15
+ */
16
+ export function KpiHeader({ className, ...props }: KpiSlotProps): ReactElement {
17
+ const { size } = useKpiPart("Kpi.Header");
18
+ return (
19
+ <View
20
+ className={cardVariants({ size }).header({ className: cn(kpiVariants({ size }).header(), className) })}
21
+ {...props}
22
+ />
23
+ );
24
+ }
25
+ KpiHeader.displayName = "DelacourUI.Kpi.Header";
@@ -0,0 +1,28 @@
1
+ import { type ReactElement, useMemo } from "react";
2
+ import { View } from "react-native";
3
+ import { IconDefaultsProvider } from "../icon";
4
+ import { useKpiPart } from "./kpi.context";
5
+ import type { KpiSlotProps } from "./kpi.types";
6
+ import { kpiVariants } from "./kpi.variants";
7
+
8
+ /**
9
+ * A tinted square for a glyph.
10
+ *
11
+ * It takes the element rather than drawing one — a metric's icon is the app's
12
+ * choice — and hands an unstyled `Icon` inside it the card's series colour and
13
+ * a step of the icon scale sized to the square. The square is that colour at
14
+ * 15%, so a row of cards given five colour indexes reads as five series.
15
+ */
16
+ export function KpiIcon({ className, children, ...props }: KpiSlotProps): ReactElement {
17
+ const { size, colorIndex } = useKpiPart("Kpi.Icon");
18
+ const slots = kpiVariants({ colorIndex, size });
19
+ const glyph = slots.iconGlyph();
20
+ const defaults = useMemo(() => ({ className: glyph, color: `chart-${colorIndex}` }), [glyph, colorIndex]);
21
+
22
+ return (
23
+ <View className={slots.icon({ className })} {...props}>
24
+ <IconDefaultsProvider value={defaults}>{children}</IconDefaultsProvider>
25
+ </View>
26
+ );
27
+ }
28
+ KpiIcon.displayName = "DelacourUI.Kpi.Icon";
@@ -0,0 +1,145 @@
1
+ import type { CurveType } from "@delacour/react-native-charts/core";
2
+ import { type ReactElement, useMemo } from "react";
3
+ import { View } from "react-native";
4
+ import { useAnimatedReaction } from "react-native-reanimated";
5
+ import { scheduleOnRN } from "react-native-worklets";
6
+ import { Chart, type ChartConfig } from "../chart";
7
+ import { useChart } from "../chart/chart.context";
8
+ import { useKpiLayout, useKpiPart } from "./kpi.context";
9
+ import {
10
+ type KpiColorIndex,
11
+ type KpiSparklineData,
12
+ kpiSparklineAccessibilityLabel,
13
+ kpiVariants,
14
+ resolveSparklineDomain,
15
+ resolveSparklineFilled,
16
+ resolveSparklineSeries,
17
+ } from "./kpi.variants";
18
+
19
+ export type KpiSparklineProps = KpiSparklineData & {
20
+ /** Overrides the KPI's `colorIndex` for this chart. */
21
+ colorIndex?: KpiColorIndex;
22
+ /** A theme token or a literal, over the series colour entirely. */
23
+ color?: string;
24
+ /**
25
+ * Fill under the line. Defaults on under the card and off beside the
26
+ * number, where a fill would make the chart a second block competing with
27
+ * the value.
28
+ */
29
+ filled?: boolean;
30
+ strokeWidth?: number;
31
+ curve?: CurveType;
32
+ /**
33
+ * Hold and drag to scrub a point: a dot rides the line and the KPI's
34
+ * `activeIndex` follows it, so a custom part can print that point's value.
35
+ * On by default. The scrub starts on a hold, so a sparkline in a scrolling
36
+ * list does not steal the scroll.
37
+ */
38
+ interactive?: boolean;
39
+ /** Formats the first and last point for the screen-reader summary. */
40
+ formatValue?: (value: number) => string;
41
+ /** Replaces the screen-reader summary — "Trend over 30 points, from 120 to 184". */
42
+ accessibilityLabel?: string;
43
+ className?: string;
44
+ testID?: string;
45
+ };
46
+
47
+ /**
48
+ * Reports the scrubbed index to the KPI, as `null` once the finger lifts.
49
+ *
50
+ * A child of `<Chart>` that is not a mark, so the chart mounts it beside the
51
+ * canvas where its context — and the scrub's shared values — are in reach. It
52
+ * crosses to the JS thread only when the index changes, which is a few times
53
+ * per drag rather than once a frame.
54
+ */
55
+ function KpiSparklineScrub({ onChange }: { onChange: (index: number | null) => void }): null {
56
+ const { scrub } = useChart();
57
+
58
+ useAnimatedReaction(
59
+ () => (scrub.isActive.value ? scrub.index.value : -1),
60
+ (next, previous) => {
61
+ if (next === previous) return;
62
+ scheduleOnRN(onChange, next < 0 ? null : next);
63
+ }
64
+ );
65
+
66
+ return null;
67
+ }
68
+ KpiSparklineScrub.displayName = "DelacourUI.Kpi.Sparkline.Scrub";
69
+
70
+ /**
71
+ * The shape the number made getting there — a `Chart` line with no grid, no
72
+ * axes and no padding, in the KPI's series colour.
73
+ *
74
+ * Takes a bare list of numbers, or rows with an `xKey` and a `yKey`. Under the
75
+ * card it is full width and filled; beside the number (`Kpi.Content
76
+ * layout="inline"`) it is a fixed 128pt column, unfilled, so a stack of cards
77
+ * lines its shapes up down the right-hand edge whatever their labels. The y
78
+ * bounds are the data's own, padded a tenth each way, so the peak and trough
79
+ * are not drawn half off the canvas.
80
+ *
81
+ * Announced as one image — how many points and where they started and ended.
82
+ * While the KPI loads it is a muted block of its own size.
83
+ */
84
+ export function KpiSparkline(props: KpiSparklineProps): ReactElement {
85
+ const {
86
+ data,
87
+ xKey,
88
+ yKey,
89
+ colorIndex: colorIndexProp,
90
+ color,
91
+ filled: filledProp,
92
+ strokeWidth = 2,
93
+ curve,
94
+ interactive = true,
95
+ formatValue,
96
+ accessibilityLabel,
97
+ className,
98
+ testID,
99
+ } = props;
100
+ const context = useKpiPart("Kpi.Sparkline");
101
+ const layout = useKpiLayout();
102
+ const slots = kpiVariants({ layout, size: context.size });
103
+ const colorIndex = colorIndexProp ?? context.colorIndex;
104
+
105
+ // Keyed on the three fields the series reads rather than on the props
106
+ // object, which is new every render and would re-plot the chart with it.
107
+ // biome-ignore lint/correctness/useExhaustiveDependencies: see above
108
+ const series = useMemo(() => resolveSparklineSeries(props), [data, xKey, yKey]);
109
+ const domain = useMemo(() => ({ y: resolveSparklineDomain(series.values) }), [series]);
110
+ const config = useMemo<ChartConfig>(
111
+ () => ({ [series.yKey]: { label: series.yKey, color: color ?? `chart-${colorIndex}` } }),
112
+ [series.yKey, color, colorIndex]
113
+ );
114
+
115
+ if (context.isLoading) {
116
+ return <View className={slots.sparklinePlaceholder({ className: slots.sparkline({ className }) })} />;
117
+ }
118
+
119
+ const filled = resolveSparklineFilled({ filled: filledProp, layout });
120
+
121
+ return (
122
+ <View
123
+ accessibilityLabel={accessibilityLabel ?? kpiSparklineAccessibilityLabel(series.values, formatValue)}
124
+ accessibilityRole="image"
125
+ accessible
126
+ className={slots.sparkline({ className })}
127
+ >
128
+ <Chart
129
+ config={config}
130
+ curve={curve}
131
+ data={series.rows}
132
+ domain={domain}
133
+ frameClassName={slots.sparklineFrame()}
134
+ testID={testID}
135
+ xKey={series.xKey}
136
+ >
137
+ {filled ? <Chart.Area yKey={series.yKey} /> : null}
138
+ <Chart.Line strokeWidth={strokeWidth} yKey={series.yKey} />
139
+ {interactive ? <Chart.Tooltip.Dot yKey={series.yKey} /> : null}
140
+ {interactive ? <KpiSparklineScrub onChange={context.setActiveIndex} /> : null}
141
+ </Chart>
142
+ </View>
143
+ );
144
+ }
145
+ KpiSparkline.displayName = "DelacourUI.Kpi.Sparkline";
@@ -0,0 +1,20 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import { useKpiLayout, useKpiPart } from "./kpi.context";
4
+ import type { KpiSlotProps } from "./kpi.types";
5
+ import { kpiVariants } from "./kpi.variants";
6
+
7
+ /**
8
+ * The value and its change, stacked tight.
9
+ *
10
+ * Its own container rather than loose children of the content, because the
11
+ * number and its change are one fact in two lines and the card's spacing is
12
+ * for the gaps *between* facts. Beside an `inline` sparkline it takes the
13
+ * row's width, leaving the chart its column on the end.
14
+ */
15
+ export function KpiStat({ className, ...props }: KpiSlotProps): ReactElement {
16
+ const { size } = useKpiPart("Kpi.Stat");
17
+ const layout = useKpiLayout();
18
+ return <View className={kpiVariants({ layout, size }).stat({ className })} {...props} />;
19
+ }
20
+ KpiStat.displayName = "DelacourUI.Kpi.Stat";
@@ -0,0 +1,19 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text } from "../text";
3
+ import { useKpiPart } from "./kpi.context";
4
+ import type { KpiTextProps } from "./kpi.types";
5
+ import { kpiVariants } from "./kpi.variants";
6
+
7
+ /**
8
+ * The metric's name. Quiet on purpose — `muted-foreground`, a step down the
9
+ * type scale — because the value is the thing being read.
10
+ *
11
+ * It never grows to fill its row: in a column that would absorb the spare
12
+ * height and land a row of cards' numbers at different heights. The header
13
+ * pushes its action to the end instead.
14
+ */
15
+ export function KpiTitle({ className, ...props }: KpiTextProps): ReactElement {
16
+ const { size } = useKpiPart("Kpi.Title");
17
+ return <Text className={kpiVariants({ size }).title({ className })} numberOfLines={1} {...props} />;
18
+ }
19
+ KpiTitle.displayName = "DelacourUI.Kpi.Title";