@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 +3 -2
- package/src/components/chart/AGENTS.md +8 -0
- package/src/components/chart/chart.tsx +8 -1
- package/src/components/kpi/AGENTS.md +125 -0
- package/src/components/kpi/index.ts +45 -0
- package/src/components/kpi/kpi-action.tsx +12 -0
- package/src/components/kpi/kpi-content.tsx +35 -0
- package/src/components/kpi/kpi-footer.tsx +52 -0
- package/src/components/kpi/kpi-group.tsx +70 -0
- package/src/components/kpi/kpi-header.tsx +25 -0
- package/src/components/kpi/kpi-icon.tsx +28 -0
- package/src/components/kpi/kpi-sparkline.tsx +145 -0
- package/src/components/kpi/kpi-stat.tsx +20 -0
- package/src/components/kpi/kpi-title.tsx +19 -0
- package/src/components/kpi/kpi-trend.tsx +105 -0
- package/src/components/kpi/kpi-value.tsx +32 -0
- package/src/components/kpi/kpi.context.tsx +111 -0
- package/src/components/kpi/kpi.tsx +154 -0
- package/src/components/kpi/kpi.types.ts +9 -0
- package/src/components/kpi/kpi.variants.test.ts +428 -0
- package/src/components/kpi/kpi.variants.ts +347 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
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";
|