@lovett/ui 0.0.4 → 0.0.6

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.
Files changed (59) hide show
  1. package/dist/index.d.ts +2659 -0
  2. package/dist/index.js +14451 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/styles.css +636 -0
  5. package/dist/tokens.css +700 -0
  6. package/package.json +44 -17
  7. package/src/__tests__/button.test.tsx +137 -0
  8. package/src/__tests__/card.test.tsx +103 -0
  9. package/src/__tests__/dead-render.test.tsx +117 -0
  10. package/src/__tests__/input.test.tsx +134 -0
  11. package/src/__tests__/modal.test.tsx +154 -0
  12. package/src/__tests__/page-shell.test.tsx +128 -0
  13. package/src/__tests__/setup.ts +43 -0
  14. package/src/__tests__/token-shape.test.ts +193 -0
  15. package/src/allocation-sparkbar.tsx +90 -0
  16. package/src/card.tsx +1 -1
  17. package/src/collapsible-card.tsx +85 -0
  18. package/src/data-grid/table-body.tsx +8 -1
  19. package/src/dropdown-menu.tsx +1 -1
  20. package/src/floating-status-bar.tsx +112 -0
  21. package/src/folder-tree-picker.tsx +5 -6
  22. package/src/frame-stack.tsx +27 -10
  23. package/src/hero-form-card.tsx +2 -2
  24. package/src/icons/brand.tsx +187 -0
  25. package/src/index.ts +43 -0
  26. package/src/lib/clipboard.ts +14 -0
  27. package/src/lib/color.ts +111 -0
  28. package/src/meta-cell.tsx +52 -0
  29. package/src/meta-previews/MetaFeedCarousel.tsx +1 -1
  30. package/src/meta-previews/MetaFeedPreview.tsx +1 -1
  31. package/src/microsoft-logo.tsx +33 -0
  32. package/src/modal.tsx +77 -6
  33. package/src/pill-button.tsx +23 -5
  34. package/src/profile-section.tsx +40 -9
  35. package/src/sortable-table.tsx +5 -1
  36. package/src/styles.css +74 -0
  37. package/src/tabs.tsx +4 -0
  38. package/src/tag-chip-input.tsx +1 -1
  39. package/src/theme-v2.css +466 -0
  40. package/src/token-badge.tsx +92 -0
  41. package/src/tokens.css +181 -60
  42. package/src/v2/README.md +208 -0
  43. package/src/v2/__demo__/showcase.tsx +1045 -0
  44. package/src/v2/action.tsx +91 -0
  45. package/src/v2/callout.tsx +76 -0
  46. package/src/v2/document-section.tsx +82 -0
  47. package/src/v2/document-shell.tsx +0 -0
  48. package/src/v2/field-row.tsx +113 -0
  49. package/src/v2/icons.tsx +165 -0
  50. package/src/v2/index.ts +147 -0
  51. package/src/v2/layout.tsx +293 -0
  52. package/src/v2/progress-track.tsx +89 -0
  53. package/src/v2/stat-tile.tsx +129 -0
  54. package/src/v2/states.tsx +271 -0
  55. package/src/v2/status-pill.tsx +74 -0
  56. package/src/v2/theme.css +1861 -0
  57. package/src/v2/timeline.tsx +81 -0
  58. package/src/v2/tokens.ts +228 -0
  59. 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
+ )