@startsimpli/ui 0.4.89 → 0.4.90
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 -3
- package/src/components/dashboard/AreaTrend.tsx +182 -0
- package/src/components/dashboard/DeltaPill.tsx +119 -0
- package/src/components/dashboard/DonutChart.tsx +189 -0
- package/src/components/dashboard/StatTile.tsx +98 -0
- package/src/components/dashboard/__tests__/display-primitives.test.tsx +452 -0
- package/src/components/dashboard/index.ts +12 -0
- package/src/theme/contract.ts +2 -0
- package/src/theme/tailwind.config.js +4 -0
- package/src/theme/tailwind.preset.ts +6 -0
- package/tailwind.preset.js +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.90",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
44
44
|
"react": "^18.0.0 || ^19.0.0",
|
|
45
45
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
46
|
-
"@startsimpli/
|
|
47
|
-
"@startsimpli/
|
|
46
|
+
"@startsimpli/auth": "^0.4.34",
|
|
47
|
+
"@startsimpli/api": "^0.5.54"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@startsimpli/api": {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
|
|
4
|
+
export interface AreaTrendProps {
|
|
5
|
+
/** The series, oldest first. Empty / single-point / flat series all render. */
|
|
6
|
+
data: number[]
|
|
7
|
+
/** Any CSS colour. Defaults to `currentColor` so the caller tints via `text-*`. */
|
|
8
|
+
color?: string
|
|
9
|
+
/** Plot height in px (excludes the optional header row). */
|
|
10
|
+
height?: number
|
|
11
|
+
/** Optional header row, left side. */
|
|
12
|
+
label?: string
|
|
13
|
+
/** Optional header row, right side. */
|
|
14
|
+
value?: React.ReactNode
|
|
15
|
+
/** Draw faint horizontal baselines behind the plot. */
|
|
16
|
+
showBaseline?: boolean
|
|
17
|
+
/** How many baselines when `showBaseline` — clamped to 1..10. */
|
|
18
|
+
baselineCount?: number
|
|
19
|
+
/** Overrides the generated description. */
|
|
20
|
+
ariaLabel?: string
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Internal viewBox width; `preserveAspectRatio="none"` stretches it to the box. */
|
|
25
|
+
const VIEW_W = 100
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Deterministic id from a seed so the gradient survives SSR without `useId`
|
|
29
|
+
* (which would force this into a client component). Two AreaTrends sharing a
|
|
30
|
+
* colour share a gradient def — same stops, so the collision is harmless.
|
|
31
|
+
*/
|
|
32
|
+
function gradientIdFor(seed: string): string {
|
|
33
|
+
let hash = 0
|
|
34
|
+
for (let i = 0; i < seed.length; i += 1) {
|
|
35
|
+
hash = (hash * 31 + seed.charCodeAt(i)) | 0
|
|
36
|
+
}
|
|
37
|
+
return `area-trend-${(hash >>> 0).toString(36)}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Geometry {
|
|
41
|
+
line: string
|
|
42
|
+
area: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Maps a series onto the viewBox. A flat series (max === min, including a
|
|
47
|
+
* single point and an all-zero series) rides the vertical centre rather than
|
|
48
|
+
* dividing by a zero range.
|
|
49
|
+
*/
|
|
50
|
+
export function areaTrendGeometry(data: number[], height: number): Geometry | null {
|
|
51
|
+
const points = data.filter((n) => Number.isFinite(n))
|
|
52
|
+
if (points.length === 0) return null
|
|
53
|
+
|
|
54
|
+
const padding = 2
|
|
55
|
+
const usable = Math.max(height - padding * 2, 1)
|
|
56
|
+
const min = Math.min(...points)
|
|
57
|
+
const max = Math.max(...points)
|
|
58
|
+
const flat = max === min
|
|
59
|
+
|
|
60
|
+
const coords = points.map((v, i) => {
|
|
61
|
+
const x = points.length === 1 ? VIEW_W / 2 : (i / (points.length - 1)) * VIEW_W
|
|
62
|
+
const y = flat ? height / 2 : height - padding - ((v - min) / (max - min)) * usable
|
|
63
|
+
return { x, y }
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// A single point has no segment to stroke — draw a flat rule across the box
|
|
67
|
+
// so the caller still sees the value's level instead of an empty frame.
|
|
68
|
+
const path =
|
|
69
|
+
coords.length === 1
|
|
70
|
+
? `M 0,${coords[0].y} L ${VIEW_W},${coords[0].y}`
|
|
71
|
+
: `M ${coords.map((p) => `${p.x},${p.y}`).join(' L ')}`
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
line: path,
|
|
75
|
+
area: `${path} L ${VIEW_W},${height} L 0,${height} Z`,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function describe(data: number[], label?: string): string {
|
|
80
|
+
const points = data.filter((n) => Number.isFinite(n))
|
|
81
|
+
const subject = label ? `${label} trend` : 'Trend'
|
|
82
|
+
if (points.length === 0) return `${subject}: no data`
|
|
83
|
+
if (points.length === 1) return `${subject}: single value ${points[0]}`
|
|
84
|
+
|
|
85
|
+
const first = points[0]
|
|
86
|
+
const last = points[points.length - 1]
|
|
87
|
+
const shape = last > first ? 'rising' : last < first ? 'falling' : 'flat'
|
|
88
|
+
return `${subject}: ${shape} over ${points.length} points, from ${first} to ${last}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A gradient-filled area chart over a plain number series. Pure SVG — no
|
|
93
|
+
* charting dependency, no hooks, safe to render on the server.
|
|
94
|
+
*/
|
|
95
|
+
export function AreaTrend({
|
|
96
|
+
data,
|
|
97
|
+
color = 'currentColor',
|
|
98
|
+
height = 96,
|
|
99
|
+
label,
|
|
100
|
+
value,
|
|
101
|
+
showBaseline = false,
|
|
102
|
+
baselineCount = 3,
|
|
103
|
+
ariaLabel,
|
|
104
|
+
className,
|
|
105
|
+
}: AreaTrendProps) {
|
|
106
|
+
const safeHeight = Math.max(height, 1)
|
|
107
|
+
const geometry = areaTrendGeometry(data, safeHeight)
|
|
108
|
+
const gradientId = gradientIdFor(`${color}|${safeHeight}`)
|
|
109
|
+
const lines = Math.min(Math.max(Math.round(baselineCount), 1), 10)
|
|
110
|
+
const hasHeader = label !== undefined || value !== undefined
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className={cn('w-full', className)}>
|
|
114
|
+
{hasHeader && (
|
|
115
|
+
<div className="mb-2 flex items-baseline justify-between gap-2">
|
|
116
|
+
{label !== undefined && (
|
|
117
|
+
<span className="truncate text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
118
|
+
{label}
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
{value !== undefined && (
|
|
122
|
+
<span className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
|
|
123
|
+
{value}
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
<svg
|
|
129
|
+
width="100%"
|
|
130
|
+
height={safeHeight}
|
|
131
|
+
viewBox={`0 0 ${VIEW_W} ${safeHeight}`}
|
|
132
|
+
preserveAspectRatio="none"
|
|
133
|
+
role="img"
|
|
134
|
+
aria-label={ariaLabel ?? describe(data, label)}
|
|
135
|
+
className="block w-full overflow-visible"
|
|
136
|
+
>
|
|
137
|
+
<defs>
|
|
138
|
+
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
139
|
+
<stop offset="0%" stopColor={color} stopOpacity={0.35} />
|
|
140
|
+
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
|
141
|
+
</linearGradient>
|
|
142
|
+
</defs>
|
|
143
|
+
|
|
144
|
+
{showBaseline && (
|
|
145
|
+
<g className="text-border" aria-hidden="true">
|
|
146
|
+
{Array.from({ length: lines }, (_, i) => {
|
|
147
|
+
const y = ((i + 1) / (lines + 1)) * safeHeight
|
|
148
|
+
return (
|
|
149
|
+
<line
|
|
150
|
+
key={i}
|
|
151
|
+
x1={0}
|
|
152
|
+
y1={y}
|
|
153
|
+
x2={VIEW_W}
|
|
154
|
+
y2={y}
|
|
155
|
+
stroke="currentColor"
|
|
156
|
+
strokeWidth={1}
|
|
157
|
+
strokeDasharray="2 3"
|
|
158
|
+
vectorEffect="non-scaling-stroke"
|
|
159
|
+
/>
|
|
160
|
+
)
|
|
161
|
+
})}
|
|
162
|
+
</g>
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
{geometry && (
|
|
166
|
+
<>
|
|
167
|
+
<path d={geometry.area} fill={`url(#${gradientId})`} stroke="none" />
|
|
168
|
+
<path
|
|
169
|
+
d={geometry.line}
|
|
170
|
+
fill="none"
|
|
171
|
+
stroke={color}
|
|
172
|
+
strokeWidth={2}
|
|
173
|
+
strokeLinecap="round"
|
|
174
|
+
strokeLinejoin="round"
|
|
175
|
+
vectorEffect="non-scaling-stroke"
|
|
176
|
+
/>
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
</svg>
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
|
|
4
|
+
export type DeltaDirection = 'up' | 'down' | 'neutral'
|
|
5
|
+
|
|
6
|
+
export interface DeltaPillProps {
|
|
7
|
+
/** The change itself. A number is signed + suffixed for you; a string is rendered verbatim. */
|
|
8
|
+
value: number | string
|
|
9
|
+
/** Overrides the direction inferred from a numeric `value`. Required to colour a string value. */
|
|
10
|
+
direction?: DeltaDirection
|
|
11
|
+
/** Appended to a numeric value — '%' by default. Ignored for string values. */
|
|
12
|
+
suffix?: string
|
|
13
|
+
/** Trailing context, e.g. 'vs last month'. */
|
|
14
|
+
label?: string
|
|
15
|
+
/** Prefix positive numbers with '+'. Defaults to true. */
|
|
16
|
+
showSign?: boolean
|
|
17
|
+
size?: 'sm' | 'md'
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Direction of a delta. Explicit `direction` always wins; otherwise a finite
|
|
23
|
+
* number is read by sign and everything else (strings, NaN, Infinity) is
|
|
24
|
+
* neutral — a pill can't claim a direction it can't derive.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveDeltaDirection(
|
|
27
|
+
value: number | string,
|
|
28
|
+
direction?: DeltaDirection,
|
|
29
|
+
): DeltaDirection {
|
|
30
|
+
if (direction) return direction
|
|
31
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
32
|
+
if (value > 0) return 'up'
|
|
33
|
+
if (value < 0) return 'down'
|
|
34
|
+
}
|
|
35
|
+
return 'neutral'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Em dash for a number that can't be shown (NaN / ±Infinity). */
|
|
39
|
+
export const DELTA_UNAVAILABLE = '—'
|
|
40
|
+
|
|
41
|
+
function formatDelta(
|
|
42
|
+
value: number | string,
|
|
43
|
+
suffix: string,
|
|
44
|
+
showSign: boolean,
|
|
45
|
+
): string {
|
|
46
|
+
if (typeof value === 'string') return value
|
|
47
|
+
if (!Number.isFinite(value)) return DELTA_UNAVAILABLE
|
|
48
|
+
const sign = showSign && value > 0 ? '+' : ''
|
|
49
|
+
return `${sign}${value}${suffix}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DIRECTION_TEXT: Record<DeltaDirection, string> = {
|
|
53
|
+
up: 'text-success',
|
|
54
|
+
down: 'text-destructive',
|
|
55
|
+
neutral: 'text-secondary-foreground',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Screen-reader wording so direction never rests on colour alone. */
|
|
59
|
+
const DIRECTION_WORD: Record<DeltaDirection, string> = {
|
|
60
|
+
up: 'up',
|
|
61
|
+
down: 'down',
|
|
62
|
+
neutral: 'unchanged',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function DirectionArrow({ direction }: { direction: DeltaDirection }) {
|
|
66
|
+
const d =
|
|
67
|
+
direction === 'up'
|
|
68
|
+
? 'M12 19V5M5 12l7-7 7 7'
|
|
69
|
+
: direction === 'down'
|
|
70
|
+
? 'M12 5v14M5 12l7 7 7-7'
|
|
71
|
+
: 'M5 12h14'
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<svg
|
|
75
|
+
className="h-3 w-3 shrink-0"
|
|
76
|
+
fill="none"
|
|
77
|
+
viewBox="0 0 24 24"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
strokeWidth={2.5}
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
>
|
|
82
|
+
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
|
|
83
|
+
</svg>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A rounded chip carrying a signed change and its direction — "+12% vs last
|
|
89
|
+
* month". Used standalone or as the `delta` of a {@link StatTile}.
|
|
90
|
+
*/
|
|
91
|
+
export function DeltaPill({
|
|
92
|
+
value,
|
|
93
|
+
direction,
|
|
94
|
+
suffix = '%',
|
|
95
|
+
label,
|
|
96
|
+
showSign = true,
|
|
97
|
+
size = 'md',
|
|
98
|
+
className,
|
|
99
|
+
}: DeltaPillProps) {
|
|
100
|
+
const resolved = resolveDeltaDirection(value, direction)
|
|
101
|
+
const text = formatDelta(value, suffix, showSign)
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<span
|
|
105
|
+
className={cn(
|
|
106
|
+
'inline-flex items-center gap-1 rounded-full bg-secondary font-medium',
|
|
107
|
+
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
|
|
108
|
+
DIRECTION_TEXT[resolved],
|
|
109
|
+
className,
|
|
110
|
+
)}
|
|
111
|
+
data-direction={resolved}
|
|
112
|
+
>
|
|
113
|
+
<DirectionArrow direction={resolved} />
|
|
114
|
+
<span className="sr-only">{DIRECTION_WORD[resolved]}</span>
|
|
115
|
+
<span className="tabular-nums">{text}</span>
|
|
116
|
+
{label ? <span className="opacity-80">{label}</span> : null}
|
|
117
|
+
</span>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
|
|
4
|
+
export interface DonutSegment {
|
|
5
|
+
label: string
|
|
6
|
+
value: number
|
|
7
|
+
/** Any CSS colour. Brand palettes are the caller's business; defaults to `currentColor`. */
|
|
8
|
+
color?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DonutChartProps {
|
|
12
|
+
segments: DonutSegment[]
|
|
13
|
+
/** Big number in the hole. */
|
|
14
|
+
centerValue?: React.ReactNode
|
|
15
|
+
/** Muted caption under `centerValue`. */
|
|
16
|
+
centerLabel?: string
|
|
17
|
+
/** Outer diameter in px. */
|
|
18
|
+
size?: number
|
|
19
|
+
/** Ring width in px — clamped so the ring always fits inside `size`. */
|
|
20
|
+
thickness?: number
|
|
21
|
+
/** Render the label/value list beside the ring. */
|
|
22
|
+
showLegend?: boolean
|
|
23
|
+
/** Formats the right-hand column of a legend row. Defaults to the raw value. */
|
|
24
|
+
formatLegendValue?: (segment: DonutSegment) => React.ReactNode
|
|
25
|
+
/** Overrides the generated description. */
|
|
26
|
+
ariaLabel?: string
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DonutSlice extends DonutSegment {
|
|
31
|
+
/** 0..1 share of the total. */
|
|
32
|
+
fraction: number
|
|
33
|
+
/** 0..1 share consumed by the preceding slices. */
|
|
34
|
+
offset: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Turns raw segments into ring slices. Negative values are clamped to zero
|
|
39
|
+
* (a ring cannot show a negative arc) and a total of zero yields no slices, so
|
|
40
|
+
* an all-zero or empty input renders the bare track instead of NaN arcs.
|
|
41
|
+
*/
|
|
42
|
+
export function donutSlices(segments: DonutSegment[]): {
|
|
43
|
+
slices: DonutSlice[]
|
|
44
|
+
total: number
|
|
45
|
+
} {
|
|
46
|
+
const cleaned = segments.map((s) => ({
|
|
47
|
+
...s,
|
|
48
|
+
value: Number.isFinite(s.value) ? Math.max(s.value, 0) : 0,
|
|
49
|
+
}))
|
|
50
|
+
const total = cleaned.reduce((sum, s) => sum + s.value, 0)
|
|
51
|
+
if (total <= 0) return { slices: [], total: 0 }
|
|
52
|
+
|
|
53
|
+
let offset = 0
|
|
54
|
+
const slices = cleaned.map((s) => {
|
|
55
|
+
const fraction = s.value / total
|
|
56
|
+
const slice: DonutSlice = { ...s, fraction, offset }
|
|
57
|
+
offset += fraction
|
|
58
|
+
return slice
|
|
59
|
+
})
|
|
60
|
+
return { slices, total }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function describe(slices: DonutSlice[], centerLabel?: string): string {
|
|
64
|
+
const subject = centerLabel ? `${centerLabel} breakdown` : 'Breakdown'
|
|
65
|
+
if (slices.length === 0) return `${subject}: no data`
|
|
66
|
+
const parts = slices.map(
|
|
67
|
+
(s) => `${s.label} ${Math.round(s.fraction * 1000) / 10}%`,
|
|
68
|
+
)
|
|
69
|
+
return `${subject}: ${parts.join(', ')}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A proportional ring with a total in the middle. Pure SVG `stroke-dasharray` —
|
|
74
|
+
* no charting dependency, no hooks, safe to render on the server.
|
|
75
|
+
*/
|
|
76
|
+
export function DonutChart({
|
|
77
|
+
segments,
|
|
78
|
+
centerValue,
|
|
79
|
+
centerLabel,
|
|
80
|
+
size = 180,
|
|
81
|
+
thickness = 18,
|
|
82
|
+
showLegend = false,
|
|
83
|
+
formatLegendValue,
|
|
84
|
+
ariaLabel,
|
|
85
|
+
className,
|
|
86
|
+
}: DonutChartProps) {
|
|
87
|
+
const safeSize = Math.max(size, 8)
|
|
88
|
+
// Keep the stroke inside the viewBox no matter what the caller asks for.
|
|
89
|
+
const safeThickness = Math.min(Math.max(thickness, 1), safeSize / 2)
|
|
90
|
+
const radius = Math.max((safeSize - safeThickness) / 2, 0.5)
|
|
91
|
+
const circumference = 2 * Math.PI * radius
|
|
92
|
+
const center = safeSize / 2
|
|
93
|
+
|
|
94
|
+
const { slices } = donutSlices(segments)
|
|
95
|
+
const hasCenter = centerValue !== undefined || centerLabel !== undefined
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
className={cn(
|
|
100
|
+
'flex items-center gap-6',
|
|
101
|
+
showLegend ? 'flex-row flex-wrap' : 'flex-col',
|
|
102
|
+
className,
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
<div
|
|
106
|
+
className="relative shrink-0"
|
|
107
|
+
style={{ width: safeSize, height: safeSize }}
|
|
108
|
+
>
|
|
109
|
+
<svg
|
|
110
|
+
width={safeSize}
|
|
111
|
+
height={safeSize}
|
|
112
|
+
viewBox={`0 0 ${safeSize} ${safeSize}`}
|
|
113
|
+
role="img"
|
|
114
|
+
aria-label={ariaLabel ?? describe(slices, centerLabel)}
|
|
115
|
+
className="block -rotate-90"
|
|
116
|
+
>
|
|
117
|
+
{/* Track — also the entire chart when there is nothing to plot. */}
|
|
118
|
+
<circle
|
|
119
|
+
cx={center}
|
|
120
|
+
cy={center}
|
|
121
|
+
r={radius}
|
|
122
|
+
fill="none"
|
|
123
|
+
stroke="currentColor"
|
|
124
|
+
strokeWidth={safeThickness}
|
|
125
|
+
className="text-border"
|
|
126
|
+
/>
|
|
127
|
+
{slices.map((slice, i) => {
|
|
128
|
+
const dash = slice.fraction * circumference
|
|
129
|
+
return (
|
|
130
|
+
<circle
|
|
131
|
+
key={`${slice.label}-${i}`}
|
|
132
|
+
cx={center}
|
|
133
|
+
cy={center}
|
|
134
|
+
r={radius}
|
|
135
|
+
fill="none"
|
|
136
|
+
stroke={slice.color ?? 'currentColor'}
|
|
137
|
+
strokeWidth={safeThickness}
|
|
138
|
+
// A lone 100% slice gets a zero-length gap, which draws the
|
|
139
|
+
// full ring rather than a hairline.
|
|
140
|
+
strokeDasharray={`${dash} ${Math.max(circumference - dash, 0)}`}
|
|
141
|
+
strokeDashoffset={-slice.offset * circumference}
|
|
142
|
+
/>
|
|
143
|
+
)
|
|
144
|
+
})}
|
|
145
|
+
</svg>
|
|
146
|
+
|
|
147
|
+
{hasCenter && (
|
|
148
|
+
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-3 text-center">
|
|
149
|
+
{centerValue !== undefined && (
|
|
150
|
+
<span className="text-xl font-semibold tabular-nums tracking-tight text-foreground">
|
|
151
|
+
{centerValue}
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
{centerLabel !== undefined && (
|
|
155
|
+
<span className="mt-0.5 text-xs text-muted-foreground">
|
|
156
|
+
{centerLabel}
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{showLegend && segments.length > 0 && (
|
|
164
|
+
<ul className="min-w-0 flex-1 space-y-2">
|
|
165
|
+
{segments.map((segment, i) => (
|
|
166
|
+
<li
|
|
167
|
+
key={`${segment.label}-${i}`}
|
|
168
|
+
className="flex items-center justify-between gap-3 text-sm"
|
|
169
|
+
>
|
|
170
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
171
|
+
<span
|
|
172
|
+
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
|
173
|
+
style={{ backgroundColor: segment.color ?? 'currentColor' }}
|
|
174
|
+
aria-hidden="true"
|
|
175
|
+
/>
|
|
176
|
+
<span className="truncate text-muted-foreground">
|
|
177
|
+
{segment.label}
|
|
178
|
+
</span>
|
|
179
|
+
</span>
|
|
180
|
+
<span className="shrink-0 font-medium tabular-nums text-foreground">
|
|
181
|
+
{formatLegendValue ? formatLegendValue(segment) : segment.value}
|
|
182
|
+
</span>
|
|
183
|
+
</li>
|
|
184
|
+
))}
|
|
185
|
+
</ul>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
import { AreaTrend } from './AreaTrend'
|
|
4
|
+
import { DeltaPill, type DeltaPillProps } from './DeltaPill'
|
|
5
|
+
|
|
6
|
+
export type StatTileSize = 'sm' | 'md' | 'lg' | 'xl'
|
|
7
|
+
|
|
8
|
+
export interface StatTileProps {
|
|
9
|
+
/** Small uppercase kicker above the numeral. */
|
|
10
|
+
label: string
|
|
11
|
+
/** The headline figure. Pre-format it (currency, compaction) — the tile only styles it. */
|
|
12
|
+
value: React.ReactNode
|
|
13
|
+
/** Muted line under the value, e.g. 'Streams · last 28 days'. */
|
|
14
|
+
caption?: string
|
|
15
|
+
/** Change chip under the caption. */
|
|
16
|
+
delta?: DeltaPillProps
|
|
17
|
+
/** Inline trend drawn to the right of the numeral. */
|
|
18
|
+
sparkline?: number[]
|
|
19
|
+
/** Any CSS colour for the sparkline; defaults to the tile's `text-primary` tint. */
|
|
20
|
+
sparklineColor?: string
|
|
21
|
+
/** Numeral scale — `lg` (text-4xl) by default, `xl` for a hero figure. */
|
|
22
|
+
size?: StatTileSize
|
|
23
|
+
/** Extra content below the delta. */
|
|
24
|
+
children?: React.ReactNode
|
|
25
|
+
className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const VALUE_SIZE: Record<StatTileSize, string> = {
|
|
29
|
+
sm: 'text-2xl',
|
|
30
|
+
md: 'text-3xl',
|
|
31
|
+
lg: 'text-4xl',
|
|
32
|
+
xl: 'text-5xl',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A card built around one big display numeral: kicker, figure, optional
|
|
37
|
+
* caption, optional delta chip, optional inline sparkline. Presentational and
|
|
38
|
+
* hook-free — safe to render on the server.
|
|
39
|
+
*/
|
|
40
|
+
export function StatTile({
|
|
41
|
+
label,
|
|
42
|
+
value,
|
|
43
|
+
caption,
|
|
44
|
+
delta,
|
|
45
|
+
sparkline,
|
|
46
|
+
sparklineColor,
|
|
47
|
+
size = 'lg',
|
|
48
|
+
children,
|
|
49
|
+
className,
|
|
50
|
+
}: StatTileProps) {
|
|
51
|
+
const hasSparkline = Array.isArray(sparkline) && sparkline.length > 0
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
className={cn(
|
|
56
|
+
'rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm',
|
|
57
|
+
className,
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
61
|
+
{label}
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
<div className="mt-2 flex items-center justify-between gap-4">
|
|
65
|
+
<p
|
|
66
|
+
className={cn(
|
|
67
|
+
'min-w-0 font-semibold tabular-nums tracking-tight text-foreground',
|
|
68
|
+
VALUE_SIZE[size],
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{value}
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
{hasSparkline && (
|
|
75
|
+
<AreaTrend
|
|
76
|
+
data={sparkline}
|
|
77
|
+
color={sparklineColor}
|
|
78
|
+
height={44}
|
|
79
|
+
ariaLabel={`${label} trend`}
|
|
80
|
+
className="w-24 shrink-0 text-primary sm:w-32"
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
{caption && (
|
|
86
|
+
<p className="mt-1 text-sm text-muted-foreground">{caption}</p>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
{delta && (
|
|
90
|
+
<div className="mt-3">
|
|
91
|
+
<DeltaPill size="sm" {...delta} />
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{children}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { AreaTrend, areaTrendGeometry } from '../AreaTrend'
|
|
3
|
+
import { DeltaPill, resolveDeltaDirection, DELTA_UNAVAILABLE } from '../DeltaPill'
|
|
4
|
+
import { DonutChart, donutSlices } from '../DonutChart'
|
|
5
|
+
import { StatTile } from '../StatTile'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/** Every path `d` in the rendered SVG — the thing that goes NaN when maths breaks. */
|
|
12
|
+
function pathData(container: HTMLElement): string[] {
|
|
13
|
+
return Array.from(container.querySelectorAll('path')).map(
|
|
14
|
+
(p) => p.getAttribute('d') ?? '',
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function circleAttrs(container: HTMLElement, attr: string): string[] {
|
|
19
|
+
return Array.from(container.querySelectorAll('circle')).map(
|
|
20
|
+
(c) => c.getAttribute(attr) ?? '',
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function expectNoNaN(values: string[]) {
|
|
25
|
+
values.forEach((v) => expect(v).not.toMatch(/NaN|Infinity/))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// DeltaPill
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
describe('resolveDeltaDirection', () => {
|
|
33
|
+
it('reads direction from the sign of a finite number', () => {
|
|
34
|
+
expect(resolveDeltaDirection(12)).toBe('up')
|
|
35
|
+
expect(resolveDeltaDirection(-3.5)).toBe('down')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('treats zero as neutral', () => {
|
|
39
|
+
expect(resolveDeltaDirection(0)).toBe('neutral')
|
|
40
|
+
expect(resolveDeltaDirection(-0)).toBe('neutral')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('treats a non-finite number as neutral rather than guessing', () => {
|
|
44
|
+
expect(resolveDeltaDirection(NaN)).toBe('neutral')
|
|
45
|
+
expect(resolveDeltaDirection(Infinity)).toBe('neutral')
|
|
46
|
+
expect(resolveDeltaDirection(-Infinity)).toBe('neutral')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('treats a string as neutral unless direction is given', () => {
|
|
50
|
+
expect(resolveDeltaDirection('+12%')).toBe('neutral')
|
|
51
|
+
expect(resolveDeltaDirection('+12%', 'up')).toBe('up')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('lets an explicit direction override the sign', () => {
|
|
55
|
+
expect(resolveDeltaDirection(-4, 'up')).toBe('up')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('DeltaPill', () => {
|
|
60
|
+
it('signs and suffixes a positive number', () => {
|
|
61
|
+
render(<DeltaPill value={12} />)
|
|
62
|
+
expect(screen.getByText('+12%')).toBeInTheDocument()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('does not double-sign a negative number', () => {
|
|
66
|
+
render(<DeltaPill value={-3} />)
|
|
67
|
+
expect(screen.getByText('-3%')).toBeInTheDocument()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('omits the sign when showSign is false', () => {
|
|
71
|
+
render(<DeltaPill value={12} showSign={false} />)
|
|
72
|
+
expect(screen.getByText('12%')).toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('renders a string value verbatim', () => {
|
|
76
|
+
render(<DeltaPill value="2.4x" />)
|
|
77
|
+
expect(screen.getByText('2.4x')).toBeInTheDocument()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('shows a dash instead of NaN', () => {
|
|
81
|
+
render(<DeltaPill value={NaN} />)
|
|
82
|
+
expect(screen.getByText(DELTA_UNAVAILABLE)).toBeInTheDocument()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('colours up as success and down as destructive', () => {
|
|
86
|
+
const { container: up } = render(<DeltaPill value={5} />)
|
|
87
|
+
expect(up.firstChild).toHaveClass('text-success')
|
|
88
|
+
|
|
89
|
+
const { container: down } = render(<DeltaPill value={-5} />)
|
|
90
|
+
expect(down.firstChild).toHaveClass('text-destructive')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('falls back to a neutral tone at zero', () => {
|
|
94
|
+
const { container } = render(<DeltaPill value={0} />)
|
|
95
|
+
expect(container.firstChild).toHaveClass('text-secondary-foreground')
|
|
96
|
+
expect(container.firstChild).toHaveAttribute('data-direction', 'neutral')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('names the direction for screen readers, not just in colour', () => {
|
|
100
|
+
render(<DeltaPill value={12} label="vs last month" />)
|
|
101
|
+
expect(screen.getByText('up')).toHaveClass('sr-only')
|
|
102
|
+
expect(screen.getByText('vs last month')).toBeInTheDocument()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('sits on the secondary surface so it reads on light and dark', () => {
|
|
106
|
+
const { container } = render(<DeltaPill value={1} />)
|
|
107
|
+
expect(container.firstChild).toHaveClass('bg-secondary')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// StatTile
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe('StatTile', () => {
|
|
116
|
+
it('renders the label, value and caption', () => {
|
|
117
|
+
render(
|
|
118
|
+
<StatTile
|
|
119
|
+
label="Estimated this month"
|
|
120
|
+
value="$48,500"
|
|
121
|
+
caption="Streams · last 28 days"
|
|
122
|
+
/>,
|
|
123
|
+
)
|
|
124
|
+
expect(screen.getByText('Estimated this month')).toBeInTheDocument()
|
|
125
|
+
expect(screen.getByText('$48,500')).toBeInTheDocument()
|
|
126
|
+
expect(screen.getByText('Streams · last 28 days')).toBeInTheDocument()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('defaults to the large numeral and honours an explicit size', () => {
|
|
130
|
+
const { container: def } = render(<StatTile label="A" value="1" />)
|
|
131
|
+
expect(def.querySelector('p.text-4xl')).toBeInTheDocument()
|
|
132
|
+
|
|
133
|
+
const { container: xl } = render(<StatTile label="A" value="1" size="xl" />)
|
|
134
|
+
expect(xl.querySelector('p.text-5xl')).toBeInTheDocument()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('renders a delta pill when given one', () => {
|
|
138
|
+
render(
|
|
139
|
+
<StatTile
|
|
140
|
+
label="Earnings"
|
|
141
|
+
value="$48,500"
|
|
142
|
+
delta={{ value: 12, label: 'vs last month' }}
|
|
143
|
+
/>,
|
|
144
|
+
)
|
|
145
|
+
expect(screen.getByText('+12%')).toBeInTheDocument()
|
|
146
|
+
expect(screen.getByText('vs last month')).toBeInTheDocument()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('renders an inline sparkline with a meaningful label', () => {
|
|
150
|
+
render(<StatTile label="Streams" value="18.4M" sparkline={[1, 4, 2, 8]} />)
|
|
151
|
+
expect(screen.getByRole('img', { name: /Streams trend/ })).toBeInTheDocument()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('omits the sparkline for an empty series', () => {
|
|
155
|
+
render(<StatTile label="Streams" value="18.4M" sparkline={[]} />)
|
|
156
|
+
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('styles against theme tokens, not a fixed grey', () => {
|
|
160
|
+
const { container } = render(<StatTile label="A" value="1" />)
|
|
161
|
+
expect(container.firstChild).toHaveClass('bg-card')
|
|
162
|
+
expect(container.firstChild).toHaveClass('border-border')
|
|
163
|
+
expect(screen.getByText('A')).toHaveClass('text-muted-foreground')
|
|
164
|
+
expect(screen.getByText('1')).toHaveClass('text-foreground')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// AreaTrend
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe('areaTrendGeometry', () => {
|
|
173
|
+
it('returns null for an empty series', () => {
|
|
174
|
+
expect(areaTrendGeometry([], 100)).toBeNull()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('centres a flat series instead of dividing by a zero range', () => {
|
|
178
|
+
const geo = areaTrendGeometry([5, 5, 5], 100)
|
|
179
|
+
expect(geo).not.toBeNull()
|
|
180
|
+
expect(geo!.line).toBe('M 0,50 L 50,50 L 100,50')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('centres an all-zero series the same way', () => {
|
|
184
|
+
const geo = areaTrendGeometry([0, 0, 0, 0], 80)
|
|
185
|
+
expectNoNaN([geo!.line])
|
|
186
|
+
expect(geo!.line).toBe('M 0,40 L 33.33333333333333,40 L 66.66666666666666,40 L 100,40')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('draws a single point as a level rule across the box', () => {
|
|
190
|
+
const geo = areaTrendGeometry([7], 100)
|
|
191
|
+
expect(geo!.line).toBe('M 0,50 L 100,50')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('normalises a series that crosses zero', () => {
|
|
195
|
+
const geo = areaTrendGeometry([-10, 0, 10], 100)
|
|
196
|
+
expectNoNaN([geo!.line, geo!.area])
|
|
197
|
+
// Lowest value sits at the bottom, highest at the top.
|
|
198
|
+
expect(geo!.line).toBe('M 0,98 L 50,50 L 100,2')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('ignores non-finite points rather than emitting NaN', () => {
|
|
202
|
+
const geo = areaTrendGeometry([1, NaN, 3], 100)
|
|
203
|
+
expectNoNaN([geo!.line, geo!.area])
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('AreaTrend', () => {
|
|
208
|
+
it('exposes an accessible description of the shape', () => {
|
|
209
|
+
render(<AreaTrend data={[1, 2, 3]} label="Spotify" />)
|
|
210
|
+
expect(
|
|
211
|
+
screen.getByRole('img', { name: /Spotify trend: rising over 3 points, from 1 to 3/ }),
|
|
212
|
+
).toBeInTheDocument()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('describes a falling series', () => {
|
|
216
|
+
render(<AreaTrend data={[9, 4, 1]} />)
|
|
217
|
+
expect(screen.getByRole('img', { name: /falling/ })).toBeInTheDocument()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('renders an empty frame for no data without crashing', () => {
|
|
221
|
+
const { container } = render(<AreaTrend data={[]} />)
|
|
222
|
+
expect(screen.getByRole('img', { name: /no data/ })).toBeInTheDocument()
|
|
223
|
+
expect(pathData(container)).toEqual([])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('never emits NaN coordinates for degenerate input', () => {
|
|
227
|
+
const cases: number[][] = [[], [0], [0, 0, 0], [4, 4], [-5, -5], [-2, 7, -9]]
|
|
228
|
+
cases.forEach((data) => {
|
|
229
|
+
const { container } = render(<AreaTrend data={data} />)
|
|
230
|
+
expectNoNaN(pathData(container))
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('tints from currentColor by default so the caller controls the colour', () => {
|
|
235
|
+
const { container } = render(<AreaTrend data={[1, 2]} />)
|
|
236
|
+
const line = container.querySelectorAll('path')[1]
|
|
237
|
+
expect(line).toHaveAttribute('stroke', 'currentColor')
|
|
238
|
+
expect(container.querySelector('stop')).toHaveAttribute(
|
|
239
|
+
'stop-color',
|
|
240
|
+
'currentColor',
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('accepts an explicit colour for both the line and the gradient', () => {
|
|
245
|
+
const { container } = render(<AreaTrend data={[1, 2]} color="#22C55E" />)
|
|
246
|
+
expect(container.querySelectorAll('path')[1]).toHaveAttribute('stroke', '#22C55E')
|
|
247
|
+
Array.from(container.querySelectorAll('stop')).forEach((stop) =>
|
|
248
|
+
expect(stop).toHaveAttribute('stop-color', '#22C55E'),
|
|
249
|
+
)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('fades the fill from 35% to transparent', () => {
|
|
253
|
+
const { container } = render(<AreaTrend data={[1, 2]} />)
|
|
254
|
+
const stops = container.querySelectorAll('stop')
|
|
255
|
+
expect(stops[0]).toHaveAttribute('stop-opacity', '0.35')
|
|
256
|
+
expect(stops[1]).toHaveAttribute('stop-opacity', '0')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('generates the same gradient id on every render so SSR matches the client', () => {
|
|
260
|
+
const { container: a } = render(<AreaTrend data={[1, 2]} color="#22C55E" />)
|
|
261
|
+
const { container: b } = render(<AreaTrend data={[9, 4]} color="#22C55E" />)
|
|
262
|
+
expect(a.querySelector('linearGradient')!.id).toBe(
|
|
263
|
+
b.querySelector('linearGradient')!.id,
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('draws baselines only when asked, and clamps the count', () => {
|
|
268
|
+
const { container: off } = render(<AreaTrend data={[1, 2]} />)
|
|
269
|
+
expect(off.querySelectorAll('line')).toHaveLength(0)
|
|
270
|
+
|
|
271
|
+
const { container: on } = render(<AreaTrend data={[1, 2]} showBaseline />)
|
|
272
|
+
expect(on.querySelectorAll('line')).toHaveLength(3)
|
|
273
|
+
|
|
274
|
+
const { container: clamped } = render(
|
|
275
|
+
<AreaTrend data={[1, 2]} showBaseline baselineCount={0} />,
|
|
276
|
+
)
|
|
277
|
+
expect(clamped.querySelectorAll('line')).toHaveLength(1)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('renders the optional header row', () => {
|
|
281
|
+
render(<AreaTrend data={[1, 2]} label="Apple Music" value="4.1M" />)
|
|
282
|
+
expect(screen.getByText('Apple Music')).toBeInTheDocument()
|
|
283
|
+
expect(screen.getByText('4.1M')).toBeInTheDocument()
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// DonutChart
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
describe('donutSlices', () => {
|
|
292
|
+
it('splits values into proportional fractions with running offsets', () => {
|
|
293
|
+
const { slices, total } = donutSlices([
|
|
294
|
+
{ label: 'a', value: 3 },
|
|
295
|
+
{ label: 'b', value: 1 },
|
|
296
|
+
])
|
|
297
|
+
expect(total).toBe(4)
|
|
298
|
+
expect(slices.map((s) => s.fraction)).toEqual([0.75, 0.25])
|
|
299
|
+
expect(slices.map((s) => s.offset)).toEqual([0, 0.75])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('gives a lone segment the whole ring', () => {
|
|
303
|
+
const { slices } = donutSlices([{ label: 'only', value: 42 }])
|
|
304
|
+
expect(slices).toHaveLength(1)
|
|
305
|
+
expect(slices[0].fraction).toBe(1)
|
|
306
|
+
expect(slices[0].offset).toBe(0)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('returns no slices for an empty input', () => {
|
|
310
|
+
expect(donutSlices([])).toEqual({ slices: [], total: 0 })
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('returns no slices when every value is zero', () => {
|
|
314
|
+
expect(
|
|
315
|
+
donutSlices([
|
|
316
|
+
{ label: 'a', value: 0 },
|
|
317
|
+
{ label: 'b', value: 0 },
|
|
318
|
+
]).slices,
|
|
319
|
+
).toEqual([])
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('clamps negative values to zero instead of inverting the ring', () => {
|
|
323
|
+
const { slices, total } = donutSlices([
|
|
324
|
+
{ label: 'a', value: -5 },
|
|
325
|
+
{ label: 'b', value: 5 },
|
|
326
|
+
])
|
|
327
|
+
expect(total).toBe(5)
|
|
328
|
+
expect(slices.map((s) => s.fraction)).toEqual([0, 1])
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('treats an all-negative input as empty', () => {
|
|
332
|
+
expect(
|
|
333
|
+
donutSlices([
|
|
334
|
+
{ label: 'a', value: -1 },
|
|
335
|
+
{ label: 'b', value: -2 },
|
|
336
|
+
]).slices,
|
|
337
|
+
).toEqual([])
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('ignores non-finite values', () => {
|
|
341
|
+
const { total } = donutSlices([
|
|
342
|
+
{ label: 'a', value: NaN },
|
|
343
|
+
{ label: 'b', value: 2 },
|
|
344
|
+
])
|
|
345
|
+
expect(total).toBe(2)
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
describe('DonutChart', () => {
|
|
350
|
+
const segments = [
|
|
351
|
+
{ label: 'Streaming', value: 22400, color: '#22B8F0' },
|
|
352
|
+
{ label: 'Video', value: 6800, color: '#22C55E' },
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
it('renders the centre value and caption', () => {
|
|
356
|
+
render(
|
|
357
|
+
<DonutChart
|
|
358
|
+
segments={segments}
|
|
359
|
+
centerValue="$29,200"
|
|
360
|
+
centerLabel="Revenue"
|
|
361
|
+
/>,
|
|
362
|
+
)
|
|
363
|
+
expect(screen.getByText('$29,200')).toBeInTheDocument()
|
|
364
|
+
expect(screen.getByText('Revenue')).toBeInTheDocument()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('describes the split for screen readers', () => {
|
|
368
|
+
render(<DonutChart segments={segments} centerLabel="Revenue" />)
|
|
369
|
+
expect(
|
|
370
|
+
screen.getByRole('img', {
|
|
371
|
+
name: /Revenue breakdown: Streaming 76.7%, Video 23.3%/,
|
|
372
|
+
}),
|
|
373
|
+
).toBeInTheDocument()
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('draws one arc per segment plus the track', () => {
|
|
377
|
+
const { container } = render(<DonutChart segments={segments} />)
|
|
378
|
+
expect(container.querySelectorAll('circle')).toHaveLength(3)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('draws a full ring with a zero-length gap for one 100% segment', () => {
|
|
382
|
+
const { container } = render(
|
|
383
|
+
<DonutChart segments={[{ label: 'All', value: 10 }]} size={100} thickness={10} />,
|
|
384
|
+
)
|
|
385
|
+
const circumference = 2 * Math.PI * 45
|
|
386
|
+
const dashes = circleAttrs(container, 'stroke-dasharray').filter(Boolean)
|
|
387
|
+
expect(dashes).toHaveLength(1)
|
|
388
|
+
expect(dashes[0]).toBe(`${circumference} 0`)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('renders only the track for an empty or all-zero input', () => {
|
|
392
|
+
const { container: empty } = render(<DonutChart segments={[]} />)
|
|
393
|
+
expect(empty.querySelectorAll('circle')).toHaveLength(1)
|
|
394
|
+
expect(screen.getByRole('img', { name: /no data/ })).toBeInTheDocument()
|
|
395
|
+
|
|
396
|
+
const { container: zeros } = render(
|
|
397
|
+
<DonutChart segments={[{ label: 'a', value: 0 }]} />,
|
|
398
|
+
)
|
|
399
|
+
expect(zeros.querySelectorAll('circle')).toHaveLength(1)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('never emits NaN geometry for degenerate input', () => {
|
|
403
|
+
const cases = [
|
|
404
|
+
[],
|
|
405
|
+
[{ label: 'a', value: 0 }],
|
|
406
|
+
[{ label: 'a', value: -1 }],
|
|
407
|
+
[{ label: 'a', value: 1 }],
|
|
408
|
+
]
|
|
409
|
+
cases.forEach((segs) => {
|
|
410
|
+
const { container } = render(<DonutChart segments={segs} />)
|
|
411
|
+
expectNoNaN([
|
|
412
|
+
...circleAttrs(container, 'r'),
|
|
413
|
+
...circleAttrs(container, 'stroke-dasharray'),
|
|
414
|
+
...circleAttrs(container, 'stroke-dashoffset'),
|
|
415
|
+
...circleAttrs(container, 'stroke-width'),
|
|
416
|
+
])
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('keeps the ring inside the box when thickness exceeds the size', () => {
|
|
421
|
+
const { container } = render(
|
|
422
|
+
<DonutChart segments={segments} size={40} thickness={999} />,
|
|
423
|
+
)
|
|
424
|
+
const r = Number(circleAttrs(container, 'r')[0])
|
|
425
|
+
const width = Number(circleAttrs(container, 'stroke-width')[0])
|
|
426
|
+
expect(width).toBe(20)
|
|
427
|
+
expect(r).toBe(10)
|
|
428
|
+
expect(r + width / 2).toBeLessThanOrEqual(20)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('shows a legend with each label and value when asked', () => {
|
|
432
|
+
render(
|
|
433
|
+
<DonutChart
|
|
434
|
+
segments={segments}
|
|
435
|
+
showLegend
|
|
436
|
+
formatLegendValue={(s) => `$${s.value.toLocaleString()}`}
|
|
437
|
+
/>,
|
|
438
|
+
)
|
|
439
|
+
expect(screen.getByText('Streaming')).toBeInTheDocument()
|
|
440
|
+
expect(screen.getByText('$22,400')).toBeInTheDocument()
|
|
441
|
+
expect(screen.getByText('$6,800')).toBeInTheDocument()
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('uses caller colours for the arcs and falls back to currentColor', () => {
|
|
445
|
+
const { container } = render(
|
|
446
|
+
<DonutChart segments={[{ label: 'a', value: 1, color: '#FF0000' }, { label: 'b', value: 1 }]} />,
|
|
447
|
+
)
|
|
448
|
+
const strokes = circleAttrs(container, 'stroke')
|
|
449
|
+
expect(strokes).toContain('#FF0000')
|
|
450
|
+
expect(strokes.filter((s) => s === 'currentColor').length).toBeGreaterThan(0)
|
|
451
|
+
})
|
|
452
|
+
})
|
|
@@ -18,3 +18,15 @@ export type { PipelineFunnelProps, FunnelStage } from './PipelineFunnel'
|
|
|
18
18
|
|
|
19
19
|
export { TopCampaigns } from './TopCampaigns'
|
|
20
20
|
export type { TopCampaignsProps, TopCampaign } from './TopCampaigns'
|
|
21
|
+
|
|
22
|
+
export { StatTile } from './StatTile'
|
|
23
|
+
export type { StatTileProps, StatTileSize } from './StatTile'
|
|
24
|
+
|
|
25
|
+
export { DeltaPill, resolveDeltaDirection, DELTA_UNAVAILABLE } from './DeltaPill'
|
|
26
|
+
export type { DeltaPillProps, DeltaDirection } from './DeltaPill'
|
|
27
|
+
|
|
28
|
+
export { DonutChart, donutSlices } from './DonutChart'
|
|
29
|
+
export type { DonutChartProps, DonutSegment, DonutSlice } from './DonutChart'
|
|
30
|
+
|
|
31
|
+
export { AreaTrend, areaTrendGeometry } from './AreaTrend'
|
|
32
|
+
export type { AreaTrendProps } from './AreaTrend'
|
package/src/theme/contract.ts
CHANGED
|
@@ -28,6 +28,10 @@ module.exports = {
|
|
|
28
28
|
DEFAULT: "hsl(var(--destructive))",
|
|
29
29
|
foreground: "hsl(var(--destructive-foreground))",
|
|
30
30
|
},
|
|
31
|
+
success: {
|
|
32
|
+
DEFAULT: "hsl(var(--success, 142.1 76.2% 36.3%))",
|
|
33
|
+
foreground: "hsl(var(--success-foreground, 210 40% 98%))",
|
|
34
|
+
},
|
|
31
35
|
muted: {
|
|
32
36
|
DEFAULT: "hsl(var(--muted))",
|
|
33
37
|
foreground: "hsl(var(--muted-foreground))",
|
|
@@ -37,6 +37,12 @@ const preset: Partial<Config> = {
|
|
|
37
37
|
DEFAULT: 'hsl(var(--destructive))',
|
|
38
38
|
foreground: 'hsl(var(--destructive-foreground))',
|
|
39
39
|
},
|
|
40
|
+
// Fallback channels keep `text-success` legible in apps that predate
|
|
41
|
+
// the token; apps that declare --success override it as usual.
|
|
42
|
+
success: {
|
|
43
|
+
DEFAULT: 'hsl(var(--success, 142.1 76.2% 36.3%))',
|
|
44
|
+
foreground: 'hsl(var(--success-foreground, 210 40% 98%))',
|
|
45
|
+
},
|
|
40
46
|
border: 'hsl(var(--border))',
|
|
41
47
|
input: 'hsl(var(--input))',
|
|
42
48
|
ring: 'hsl(var(--ring))',
|
package/tailwind.preset.js
CHANGED
|
@@ -34,6 +34,12 @@ module.exports = {
|
|
|
34
34
|
DEFAULT: 'hsl(var(--destructive))',
|
|
35
35
|
foreground: 'hsl(var(--destructive-foreground))',
|
|
36
36
|
},
|
|
37
|
+
// Fallback channels keep `text-success` legible in apps that predate
|
|
38
|
+
// the token; apps that declare --success override it as usual.
|
|
39
|
+
success: {
|
|
40
|
+
DEFAULT: 'hsl(var(--success, 142.1 76.2% 36.3%))',
|
|
41
|
+
foreground: 'hsl(var(--success-foreground, 210 40% 98%))',
|
|
42
|
+
},
|
|
37
43
|
border: 'hsl(var(--border))',
|
|
38
44
|
input: 'hsl(var(--input))',
|
|
39
45
|
ring: 'hsl(var(--ring))',
|