panelui-native 0.41.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/lib/module/components/card/index.js +8 -154
- package/lib/module/components/card/index.js.map +1 -1
- package/lib/module/components/color-picker/index.js +1395 -0
- package/lib/module/components/color-picker/index.js.map +1 -0
- package/lib/module/components/combobox/index.js +87 -19
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/kpi-chart/index.js +648 -0
- package/lib/module/components/kpi-chart/index.js.map +1 -0
- package/lib/module/components/menu/index.js +40 -10
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/radar-chart/index.js +704 -0
- package/lib/module/components/radar-chart/index.js.map +1 -0
- package/lib/module/components/section-rail/index.js +32 -7
- package/lib/module/components/section-rail/index.js.map +1 -1
- package/lib/module/components/slider/index.js +240 -39
- package/lib/module/components/slider/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +194 -10
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/theme/use-theme.js +1 -1
- package/lib/module/utils/chart.js +44 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/module/utils/color.js +268 -0
- package/lib/module/utils/color.js.map +1 -0
- package/lib/typescript/src/components/card/index.d.ts +0 -20
- package/lib/typescript/src/components/card/index.d.ts.map +1 -1
- package/lib/typescript/src/components/color-picker/index.d.ts +258 -0
- package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -0
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/kpi-chart/index.d.ts +490 -0
- package/lib/typescript/src/components/kpi-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/radar-chart/index.d.ts +214 -0
- package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
- package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slider/index.d.ts +19 -0
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts +11 -1
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +5 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +21 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/lib/typescript/src/utils/color.d.ts +74 -0
- package/lib/typescript/src/utils/color.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/components/card/index.tsx +7 -209
- package/src/components/color-picker/index.tsx +1618 -0
- package/src/components/combobox/index.tsx +89 -19
- package/src/components/kpi-chart/index.tsx +719 -0
- package/src/components/menu/index.tsx +51 -6
- package/src/components/radar-chart/index.tsx +910 -0
- package/src/components/section-rail/index.tsx +42 -8
- package/src/components/slider/index.tsx +283 -41
- package/src/components/tabs/index.tsx +263 -8
- package/src/index.ts +61 -1
- package/src/theme/use-theme.ts +1 -1
- package/src/utils/chart.ts +52 -0
- package/src/utils/color.ts +246 -0
- 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
|
+
});
|