@lovett/ui 0.0.5 → 0.0.7
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/dist/index.d.ts +279 -115
- package/dist/index.js +479 -66
- package/dist/index.js.map +1 -1
- package/dist/styles.css +74 -0
- package/dist/tokens.css +181 -60
- package/package.json +16 -4
- package/src/__tests__/button.test.tsx +137 -0
- package/src/__tests__/card.test.tsx +103 -0
- package/src/__tests__/dead-render.test.tsx +117 -0
- package/src/__tests__/input.test.tsx +134 -0
- package/src/__tests__/modal.test.tsx +154 -0
- package/src/__tests__/page-shell.test.tsx +128 -0
- package/src/__tests__/setup.ts +43 -0
- package/src/__tests__/token-shape.test.ts +193 -0
- package/src/card.tsx +1 -1
- package/src/collapsible-card.tsx +85 -0
- package/src/data-grid/table-body.tsx +8 -1
- package/src/dropdown-menu.tsx +1 -1
- package/src/floating-status-bar.tsx +1 -1
- package/src/folder-tree-picker.tsx +5 -6
- package/src/frame-stack.tsx +27 -10
- package/src/hero-form-card.tsx +2 -2
- package/src/icons/brand.tsx +187 -0
- package/src/index.ts +32 -0
- package/src/lib/clipboard.ts +14 -0
- package/src/lib/color.ts +111 -0
- package/src/meta-cell.tsx +52 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +1 -1
- package/src/meta-previews/MetaFeedPreview.tsx +1 -1
- package/src/modal.tsx +77 -6
- package/src/pill-button.tsx +23 -5
- package/src/profile-section.tsx +40 -9
- package/src/sortable-table.tsx +5 -1
- package/src/styles.css +74 -0
- package/src/tabs.tsx +4 -0
- package/src/tag-chip-input.tsx +1 -1
- package/src/theme-v2.css +466 -0
- package/src/tokens.css +181 -60
- package/src/v2/README.md +208 -0
- package/src/v2/__demo__/showcase.tsx +1045 -0
- package/src/v2/action.tsx +91 -0
- package/src/v2/callout.tsx +76 -0
- package/src/v2/document-section.tsx +82 -0
- package/src/v2/document-shell.tsx +0 -0
- package/src/v2/field-row.tsx +113 -0
- package/src/v2/icons.tsx +165 -0
- package/src/v2/index.ts +147 -0
- package/src/v2/layout.tsx +293 -0
- package/src/v2/progress-track.tsx +89 -0
- package/src/v2/stat-tile.tsx +129 -0
- package/src/v2/states.tsx +271 -0
- package/src/v2/status-pill.tsx +74 -0
- package/src/v2/theme.css +1861 -0
- package/src/v2/timeline.tsx +81 -0
- package/src/v2/tokens.ts +228 -0
- package/src/value-chip.tsx +76 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lovett/ui v2 — layout primitives
|
|
3
|
+
*
|
|
4
|
+
* Stack · Inline · Grid · Section · Panel · Toolbar
|
|
5
|
+
*
|
|
6
|
+
* The whole point of these is that they make the spacing scale
|
|
7
|
+
* unavoidable. `gap` takes a step on the 4px grid and nothing else — you
|
|
8
|
+
* cannot type `gap={13}`. That single constraint removes the most common
|
|
9
|
+
* reason a nice interface feels subtly off.
|
|
10
|
+
*
|
|
11
|
+
* Grouping is done with space first, a background shape second, and a
|
|
12
|
+
* line last. `Section` and `Panel` are the background shapes; there is
|
|
13
|
+
* deliberately no `Divider` primitive, because reaching for a divider is
|
|
14
|
+
* almost always a failure of spacing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
forwardRef,
|
|
19
|
+
type CSSProperties,
|
|
20
|
+
type ForwardRefExoticComponent,
|
|
21
|
+
type HTMLAttributes,
|
|
22
|
+
type ReactNode,
|
|
23
|
+
type RefAttributes,
|
|
24
|
+
} from 'react'
|
|
25
|
+
import { cn } from '../lib/utils'
|
|
26
|
+
import {
|
|
27
|
+
alignValue,
|
|
28
|
+
justifyValue,
|
|
29
|
+
space,
|
|
30
|
+
type AlignItems,
|
|
31
|
+
type JustifyContent,
|
|
32
|
+
type SpaceStep,
|
|
33
|
+
} from './tokens'
|
|
34
|
+
|
|
35
|
+
/* ══════════════════════════════════════════════════════════════════════
|
|
36
|
+
Stack — vertical flow
|
|
37
|
+
══════════════════════════════════════════════════════════════════════ */
|
|
38
|
+
|
|
39
|
+
export interface StackProps extends HTMLAttributes<HTMLDivElement> {
|
|
40
|
+
/** Gap between children, as a step on the 4px scale. */
|
|
41
|
+
gap?: SpaceStep
|
|
42
|
+
align?: AlignItems
|
|
43
|
+
justify?: JustifyContent
|
|
44
|
+
/** Inner padding, as a step on the 4px scale. */
|
|
45
|
+
padding?: SpaceStep
|
|
46
|
+
/** Fill the cross axis. */
|
|
47
|
+
grow?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const Stack = forwardRef<HTMLDivElement, StackProps>(function Stack(
|
|
51
|
+
{ gap = 3, align, justify, padding, grow, className, style, ...rest },
|
|
52
|
+
ref,
|
|
53
|
+
) {
|
|
54
|
+
const composed: CSSProperties = {
|
|
55
|
+
gap: space(gap),
|
|
56
|
+
...(align ? { alignItems: alignValue(align) } : null),
|
|
57
|
+
...(justify ? { justifyContent: justifyValue(justify) } : null),
|
|
58
|
+
...(padding !== undefined ? { padding: space(padding) } : null),
|
|
59
|
+
...(grow ? { flex: '1 1 auto' } : null),
|
|
60
|
+
...style,
|
|
61
|
+
}
|
|
62
|
+
return (
|
|
63
|
+
<div ref={ref} className={cn('lv-stack', className)} style={composed} {...rest} />
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/* ══════════════════════════════════════════════════════════════════════
|
|
68
|
+
Inline — horizontal flow
|
|
69
|
+
══════════════════════════════════════════════════════════════════════ */
|
|
70
|
+
|
|
71
|
+
export interface InlineProps extends HTMLAttributes<HTMLDivElement> {
|
|
72
|
+
gap?: SpaceStep
|
|
73
|
+
align?: AlignItems
|
|
74
|
+
justify?: JustifyContent
|
|
75
|
+
padding?: SpaceStep
|
|
76
|
+
/** Allow the row to wrap. Prefer this over fixed widths: strings grow
|
|
77
|
+
* substantially under translation, and a wrapped row survives that
|
|
78
|
+
* where a clipped one does not. */
|
|
79
|
+
wrap?: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const Inline = forwardRef<HTMLDivElement, InlineProps>(function Inline(
|
|
83
|
+
{ gap = 2, align = 'center', justify, padding, wrap, className, style, ...rest },
|
|
84
|
+
ref,
|
|
85
|
+
) {
|
|
86
|
+
const composed: CSSProperties = {
|
|
87
|
+
gap: space(gap),
|
|
88
|
+
alignItems: alignValue(align),
|
|
89
|
+
...(justify ? { justifyContent: justifyValue(justify) } : null),
|
|
90
|
+
...(padding !== undefined ? { padding: space(padding) } : null),
|
|
91
|
+
...(wrap ? { flexWrap: 'wrap' } : null),
|
|
92
|
+
...style,
|
|
93
|
+
}
|
|
94
|
+
return (
|
|
95
|
+
<div ref={ref} className={cn('lv-inline', className)} style={composed} {...rest} />
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/* ══════════════════════════════════════════════════════════════════════
|
|
100
|
+
Grid
|
|
101
|
+
══════════════════════════════════════════════════════════════════════ */
|
|
102
|
+
|
|
103
|
+
export interface GridProps extends HTMLAttributes<HTMLDivElement> {
|
|
104
|
+
gap?: SpaceStep
|
|
105
|
+
/** Fixed column count. Ignored when `minColumnWidth` is set. */
|
|
106
|
+
columns?: number
|
|
107
|
+
/**
|
|
108
|
+
* Responsive auto-fit: columns are added as space allows, each at
|
|
109
|
+
* least this wide (px). Preferred over `columns` + breakpoints —
|
|
110
|
+
* it breaks where the content stops fitting rather than at a device
|
|
111
|
+
* preset.
|
|
112
|
+
*/
|
|
113
|
+
minColumnWidth?: number
|
|
114
|
+
align?: AlignItems
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const Grid = forwardRef<HTMLDivElement, GridProps>(function Grid(
|
|
118
|
+
{ gap = 3, columns, minColumnWidth, align, className, style, ...rest },
|
|
119
|
+
ref,
|
|
120
|
+
) {
|
|
121
|
+
const template =
|
|
122
|
+
minColumnWidth !== undefined
|
|
123
|
+
? `repeat(auto-fit, minmax(min(${minColumnWidth}px, 100%), 1fr))`
|
|
124
|
+
: `repeat(${columns ?? 2}, minmax(0, 1fr))`
|
|
125
|
+
|
|
126
|
+
const composed: CSSProperties = {
|
|
127
|
+
gap: space(gap),
|
|
128
|
+
gridTemplateColumns: template,
|
|
129
|
+
...(align ? { alignItems: alignValue(align) } : null),
|
|
130
|
+
...style,
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
<div ref={ref} className={cn('lv-grid', className)} style={composed} {...rest} />
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
/* ══════════════════════════════════════════════════════════════════════
|
|
138
|
+
Section — a titled region, one tier up the surface ladder
|
|
139
|
+
══════════════════════════════════════════════════════════════════════ */
|
|
140
|
+
|
|
141
|
+
export interface SectionProps
|
|
142
|
+
extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
|
143
|
+
title?: ReactNode
|
|
144
|
+
/** Small uppercase kicker above the title. */
|
|
145
|
+
eyebrow?: ReactNode
|
|
146
|
+
description?: ReactNode
|
|
147
|
+
/**
|
|
148
|
+
* Trailing controls. Keep this to secondary and ghost actions — a view
|
|
149
|
+
* gets exactly one primary action, and it is rarely in a section head.
|
|
150
|
+
*/
|
|
151
|
+
actions?: ReactNode
|
|
152
|
+
/** Drop the surface and padding; keep only the heading structure. */
|
|
153
|
+
flush?: boolean
|
|
154
|
+
padding?: SpaceStep
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const Section = forwardRef<HTMLElement, SectionProps>(function Section(
|
|
158
|
+
{
|
|
159
|
+
title,
|
|
160
|
+
eyebrow,
|
|
161
|
+
description,
|
|
162
|
+
actions,
|
|
163
|
+
flush,
|
|
164
|
+
padding,
|
|
165
|
+
className,
|
|
166
|
+
style,
|
|
167
|
+
children,
|
|
168
|
+
...rest
|
|
169
|
+
},
|
|
170
|
+
ref,
|
|
171
|
+
) {
|
|
172
|
+
const hasHead = Boolean(title || eyebrow || description || actions)
|
|
173
|
+
const composed: CSSProperties = {
|
|
174
|
+
...(padding !== undefined ? { padding: space(padding) } : null),
|
|
175
|
+
...style,
|
|
176
|
+
}
|
|
177
|
+
return (
|
|
178
|
+
<section
|
|
179
|
+
ref={ref}
|
|
180
|
+
data-flush={flush ? 'true' : undefined}
|
|
181
|
+
className={cn('lv-section', className)}
|
|
182
|
+
style={composed}
|
|
183
|
+
{...rest}
|
|
184
|
+
>
|
|
185
|
+
{hasHead && (
|
|
186
|
+
<div className="lv-section-head">
|
|
187
|
+
<div style={{ minWidth: 0 }}>
|
|
188
|
+
{eyebrow && <div className="lv-section-eyebrow">{eyebrow}</div>}
|
|
189
|
+
{title && <h2 className="lv-section-title">{title}</h2>}
|
|
190
|
+
{description && <p className="lv-section-desc">{description}</p>}
|
|
191
|
+
</div>
|
|
192
|
+
{/* The actions slot is rendered whenever there is a head, so a
|
|
193
|
+
section that gains a button later does not jump. */}
|
|
194
|
+
<div
|
|
195
|
+
className="lv-inline"
|
|
196
|
+
style={{ gap: space(2), alignItems: 'center', flex: '0 0 auto' }}
|
|
197
|
+
>
|
|
198
|
+
{actions}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
{children}
|
|
203
|
+
</section>
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
/* ══════════════════════════════════════════════════════════════════════
|
|
208
|
+
Panel — the recessed inset
|
|
209
|
+
══════════════════════════════════════════════════════════════════════ */
|
|
210
|
+
|
|
211
|
+
export type PanelTone = 'inset' | 'quiet' | 'raised'
|
|
212
|
+
|
|
213
|
+
export interface PanelProps extends HTMLAttributes<HTMLDivElement> {
|
|
214
|
+
/**
|
|
215
|
+
* `inset` (default) is the recessed panel: a hole cut into the surface
|
|
216
|
+
* holding it. This is the strongest single move in the system — use it
|
|
217
|
+
* for the thing inside a card that the reader is actually here for
|
|
218
|
+
* (the extracted quote, the computed figure, the diff).
|
|
219
|
+
*
|
|
220
|
+
* `quiet` is a hairline outline with no fill, for when a recess would
|
|
221
|
+
* be too much. `raised` lifts instead, for a tray floating over a
|
|
222
|
+
* section.
|
|
223
|
+
*/
|
|
224
|
+
tone?: PanelTone
|
|
225
|
+
padding?: SpaceStep
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const Panel = forwardRef<HTMLDivElement, PanelProps>(function Panel(
|
|
229
|
+
{ tone = 'inset', padding, className, style, ...rest },
|
|
230
|
+
ref,
|
|
231
|
+
) {
|
|
232
|
+
const composed: CSSProperties = {
|
|
233
|
+
...(padding !== undefined ? { padding: space(padding) } : null),
|
|
234
|
+
...style,
|
|
235
|
+
}
|
|
236
|
+
return (
|
|
237
|
+
<div
|
|
238
|
+
ref={ref}
|
|
239
|
+
data-tone={tone}
|
|
240
|
+
className={cn('lv-panel', className)}
|
|
241
|
+
style={composed}
|
|
242
|
+
{...rest}
|
|
243
|
+
/>
|
|
244
|
+
)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
/* ══════════════════════════════════════════════════════════════════════
|
|
248
|
+
Toolbar
|
|
249
|
+
══════════════════════════════════════════════════════════════════════ */
|
|
250
|
+
|
|
251
|
+
export interface ToolbarProps extends HTMLAttributes<HTMLDivElement> {
|
|
252
|
+
/** Stick to the top of the scroll container. Controls float above the
|
|
253
|
+
* content layer; they never dam it. */
|
|
254
|
+
sticky?: boolean
|
|
255
|
+
'aria-label': string
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface ToolbarComponent
|
|
259
|
+
extends ForwardRefExoticComponent<
|
|
260
|
+
ToolbarProps & RefAttributes<HTMLDivElement>
|
|
261
|
+
> {
|
|
262
|
+
/** Pushes everything after it to the trailing edge. */
|
|
263
|
+
Spacer: typeof ToolbarSpacer
|
|
264
|
+
/** A structural rule between control groups. Try a Spacer first. */
|
|
265
|
+
Divider: typeof ToolbarDivider
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const ToolbarRoot = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
|
|
269
|
+
{ sticky, className, ...rest },
|
|
270
|
+
ref,
|
|
271
|
+
) {
|
|
272
|
+
return (
|
|
273
|
+
<div
|
|
274
|
+
ref={ref}
|
|
275
|
+
role="toolbar"
|
|
276
|
+
data-sticky={sticky ? 'true' : undefined}
|
|
277
|
+
className={cn('lv-toolbar', className)}
|
|
278
|
+
{...rest}
|
|
279
|
+
/>
|
|
280
|
+
)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
function ToolbarSpacer() {
|
|
284
|
+
return <div className="lv-toolbar-spacer" aria-hidden="true" />
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function ToolbarDivider() {
|
|
288
|
+
return <div className="lv-toolbar-divider" role="separator" aria-orientation="vertical" />
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export const Toolbar = ToolbarRoot as ToolbarComponent
|
|
292
|
+
Toolbar.Spacer = ToolbarSpacer
|
|
293
|
+
Toolbar.Divider = ToolbarDivider
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lovett/ui v2 — ProgressTrack
|
|
3
|
+
*
|
|
4
|
+
* The track is a recessed groove — the inset treatment in miniature —
|
|
5
|
+
* with the fill sitting in it. That reads as measurement; a raised bar
|
|
6
|
+
* on a raised surface reads as decoration.
|
|
7
|
+
*
|
|
8
|
+
* Two layout-stability rules are load-bearing here:
|
|
9
|
+
* - The percentage sits in a 4ch slot with tabular figures, so 9% → 100%
|
|
10
|
+
* never nudges the label beside it.
|
|
11
|
+
* - The label row exists whether or not a label was passed, so adding
|
|
12
|
+
* one later does not shift the bar down.
|
|
13
|
+
*
|
|
14
|
+
* The fill eases (never springs) to its new value: overshoot on a
|
|
15
|
+
* measured quantity reads as imprecise, which is the opposite of what a
|
|
16
|
+
* progress bar is for.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
|
20
|
+
import { cn } from '../lib/utils'
|
|
21
|
+
|
|
22
|
+
export type ProgressTone = 'accent' | 'success' | 'warning' | 'danger' | 'neutral'
|
|
23
|
+
|
|
24
|
+
export interface ProgressTrackProps
|
|
25
|
+
extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
26
|
+
/** 0–100. Clamped. Ignored when `indeterminate`. */
|
|
27
|
+
value?: number
|
|
28
|
+
label?: ReactNode
|
|
29
|
+
tone?: ProgressTone
|
|
30
|
+
/** Hide the numeric readout (keeps the reserved slot, so nothing
|
|
31
|
+
* moves if it comes back). */
|
|
32
|
+
hideValue?: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Work of unknown duration. Under `prefers-reduced-motion` this shows
|
|
35
|
+
* a static partial fill rather than a frozen animation — the final
|
|
36
|
+
* state, not a paused one.
|
|
37
|
+
*/
|
|
38
|
+
indeterminate?: boolean
|
|
39
|
+
/** Accessible name when `label` is omitted or non-textual. */
|
|
40
|
+
'aria-label'?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clamp(n: number): number {
|
|
44
|
+
if (Number.isNaN(n)) return 0
|
|
45
|
+
return Math.min(100, Math.max(0, n))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const ProgressTrack = forwardRef<HTMLDivElement, ProgressTrackProps>(
|
|
49
|
+
function ProgressTrack(
|
|
50
|
+
{
|
|
51
|
+
value = 0,
|
|
52
|
+
label,
|
|
53
|
+
tone = 'accent',
|
|
54
|
+
hideValue = false,
|
|
55
|
+
indeterminate = false,
|
|
56
|
+
className,
|
|
57
|
+
'aria-label': ariaLabel,
|
|
58
|
+
...rest
|
|
59
|
+
},
|
|
60
|
+
ref,
|
|
61
|
+
) {
|
|
62
|
+
const pct = clamp(value)
|
|
63
|
+
return (
|
|
64
|
+
<div ref={ref} data-tone={tone} className={cn('lv-progress', className)} {...rest}>
|
|
65
|
+
<div className="lv-progress-head">
|
|
66
|
+
<span className="lv-progress-label">{label}</span>
|
|
67
|
+
{/* Slot is always rendered; only its contents change. */}
|
|
68
|
+
<span className="lv-progress-value" aria-hidden="true">
|
|
69
|
+
{indeterminate || hideValue ? '' : `${Math.round(pct)}%`}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
<div
|
|
73
|
+
className="lv-progress-track"
|
|
74
|
+
role="progressbar"
|
|
75
|
+
aria-valuemin={indeterminate ? undefined : 0}
|
|
76
|
+
aria-valuemax={indeterminate ? undefined : 100}
|
|
77
|
+
aria-valuenow={indeterminate ? undefined : Math.round(pct)}
|
|
78
|
+
aria-label={ariaLabel}
|
|
79
|
+
>
|
|
80
|
+
{indeterminate ? (
|
|
81
|
+
<span className="lv-progress-indeterminate" />
|
|
82
|
+
) : (
|
|
83
|
+
<div className="lv-progress-fill" style={{ width: `${pct}%` }} />
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
},
|
|
89
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lovett/ui v2 — StatTile
|
|
3
|
+
*
|
|
4
|
+
* A metric that holds still. Two mechanics do all the work:
|
|
5
|
+
*
|
|
6
|
+
* 1. `tabular-nums` on the value, so digits are the same width and a
|
|
7
|
+
* counter going 1 → 8 does not jitter.
|
|
8
|
+
* 2. A reserved `min-width` slot sized in `ch` for the LARGEST plausible
|
|
9
|
+
* value, so 9 → 10 → 100 → 1,284,930 never reflows the tile or its
|
|
10
|
+
* neighbours. Set `slots` to the character count of your worst case
|
|
11
|
+
* (including separators). Default 6.
|
|
12
|
+
*
|
|
13
|
+
* The delta row is always in the box. When there is no delta it is
|
|
14
|
+
* `visibility: hidden`, not removed — `display: none` would resize the
|
|
15
|
+
* tile the moment a comparison period loaded.
|
|
16
|
+
*
|
|
17
|
+
* Direction is carried by an arrow glyph AND the sign in the label, so
|
|
18
|
+
* green/orange is never the only thing telling you which way it went.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { forwardRef, type CSSProperties, type HTMLAttributes, type ReactNode } from 'react'
|
|
22
|
+
import { cn } from '../lib/utils'
|
|
23
|
+
import { IconArrowDown, IconArrowUp, IconFlat } from './icons'
|
|
24
|
+
import { space } from './tokens'
|
|
25
|
+
|
|
26
|
+
export type DeltaTone = 'up' | 'down' | 'flat'
|
|
27
|
+
|
|
28
|
+
export interface StatTileProps extends HTMLAttributes<HTMLDivElement> {
|
|
29
|
+
label: string
|
|
30
|
+
/**
|
|
31
|
+
* The value. Pass a pre-formatted string — formatting is a product
|
|
32
|
+
* decision (locale, precision, abbreviation) and does not belong in a
|
|
33
|
+
* design primitive.
|
|
34
|
+
*/
|
|
35
|
+
value?: ReactNode
|
|
36
|
+
/** Suffix rendered small and muted beside the value: `%`, `ms`, `/mo`. */
|
|
37
|
+
unit?: ReactNode
|
|
38
|
+
/**
|
|
39
|
+
* Character width of the largest plausible value, including thousands
|
|
40
|
+
* separators and any sign. `1,284,930` is 9. Getting this right is the
|
|
41
|
+
* difference between a dashboard that holds still and one that
|
|
42
|
+
* breathes on every poll.
|
|
43
|
+
*/
|
|
44
|
+
slots?: number
|
|
45
|
+
/** Change vs. the comparison period, pre-formatted: `+12.4%`. */
|
|
46
|
+
delta?: string
|
|
47
|
+
deltaTone?: DeltaTone
|
|
48
|
+
/** Muted note on the foot row: `vs. last 30 days`. */
|
|
49
|
+
hint?: ReactNode
|
|
50
|
+
/** Render on the recessed inset surface instead of a raised card. Use
|
|
51
|
+
* inside a card; the raised default is for a bare section. */
|
|
52
|
+
inset?: boolean
|
|
53
|
+
/** Swap the value for a skeleton of the same size. Nothing moves. */
|
|
54
|
+
loading?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DELTA_ICON: Readonly<Record<DeltaTone, (props: { size?: number }) => ReactNode>> = {
|
|
58
|
+
up: (props) => <IconArrowUp {...props} />,
|
|
59
|
+
down: (props) => <IconArrowDown {...props} />,
|
|
60
|
+
flat: (props) => <IconFlat {...props} />,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const StatTile = forwardRef<HTMLDivElement, StatTileProps>(
|
|
64
|
+
function StatTile(
|
|
65
|
+
{
|
|
66
|
+
label,
|
|
67
|
+
value,
|
|
68
|
+
unit,
|
|
69
|
+
slots = 6,
|
|
70
|
+
delta,
|
|
71
|
+
deltaTone = 'flat',
|
|
72
|
+
hint,
|
|
73
|
+
inset,
|
|
74
|
+
loading = false,
|
|
75
|
+
className,
|
|
76
|
+
style,
|
|
77
|
+
...rest
|
|
78
|
+
},
|
|
79
|
+
ref,
|
|
80
|
+
) {
|
|
81
|
+
const hasFoot = Boolean(delta || hint)
|
|
82
|
+
const composed = {
|
|
83
|
+
...style,
|
|
84
|
+
'--lv-stat-slots': slots,
|
|
85
|
+
} as CSSProperties
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
ref={ref}
|
|
90
|
+
data-inset={inset ? 'true' : undefined}
|
|
91
|
+
className={cn('lv-stat', className)}
|
|
92
|
+
style={composed}
|
|
93
|
+
{...rest}
|
|
94
|
+
>
|
|
95
|
+
<div className="lv-stat-label" title={label}>
|
|
96
|
+
{label}
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div className="lv-stat-valuerow">
|
|
100
|
+
{loading ? (
|
|
101
|
+
<span
|
|
102
|
+
className="lv-skeleton"
|
|
103
|
+
style={{ width: `${slots}ch`, height: '1em', alignSelf: 'center' }}
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
/>
|
|
106
|
+
) : (
|
|
107
|
+
<span className="lv-stat-value">{value ?? '—'}</span>
|
|
108
|
+
)}
|
|
109
|
+
{unit && <span className="lv-stat-unit">{unit}</span>}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* Reserved regardless — see the header comment. */}
|
|
113
|
+
<div className="lv-stat-footrow" data-hidden={hasFoot ? undefined : 'true'}>
|
|
114
|
+
{delta && (
|
|
115
|
+
<span
|
|
116
|
+
className="lv-stat-delta lv-inline"
|
|
117
|
+
data-tone={deltaTone}
|
|
118
|
+
style={{ gap: space(1), alignItems: 'center' }}
|
|
119
|
+
>
|
|
120
|
+
{DELTA_ICON[deltaTone]({ size: 12 })}
|
|
121
|
+
<span>{delta}</span>
|
|
122
|
+
</span>
|
|
123
|
+
)}
|
|
124
|
+
{hint && <span className="lv-stat-hint">{hint}</span>}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
},
|
|
129
|
+
)
|