@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,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lovett/ui v2 — EmptyState · LoadingSkeleton · ErrorState
|
|
3
|
+
*
|
|
4
|
+
* These three live in one file because the contract between them is the
|
|
5
|
+
* whole point: **all three reserve the same box**. A region that swaps
|
|
6
|
+
* loading → error → empty → loaded must not change height at any step.
|
|
7
|
+
*
|
|
8
|
+
* The mechanism is a shared `reserve` prop feeding one `min-height`
|
|
9
|
+
* token (`--lv-state-min-h`, 216px). Give all three the same value as
|
|
10
|
+
* the loaded content's typical height and the page stops jumping.
|
|
11
|
+
*
|
|
12
|
+
* `LoadingSkeleton` additionally offers shape variants that mirror the
|
|
13
|
+
* real primitives (`stat`, `fields`, `document`), so the skeleton is
|
|
14
|
+
* genuinely the same size as what replaces it rather than a grey box of
|
|
15
|
+
* roughly the right area.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
forwardRef,
|
|
20
|
+
type CSSProperties,
|
|
21
|
+
type HTMLAttributes,
|
|
22
|
+
type ReactNode,
|
|
23
|
+
} from 'react'
|
|
24
|
+
import { cn } from '../lib/utils'
|
|
25
|
+
import { IconDanger, IconEmpty } from './icons'
|
|
26
|
+
import { space } from './tokens'
|
|
27
|
+
|
|
28
|
+
type StateSurface = 'none' | 'inset' | 'card'
|
|
29
|
+
|
|
30
|
+
interface StateFrameProps
|
|
31
|
+
extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
|
32
|
+
/** Reserved min-height in px. Match this across all three states AND
|
|
33
|
+
* to the loaded content's typical height. */
|
|
34
|
+
reserve?: number
|
|
35
|
+
surface?: StateSurface
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function frameStyle(
|
|
39
|
+
reserve: number | undefined,
|
|
40
|
+
style: CSSProperties | undefined,
|
|
41
|
+
): CSSProperties | undefined {
|
|
42
|
+
if (reserve === undefined) return style
|
|
43
|
+
return { ...style, minHeight: `${reserve}px` }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ══════════════════════════════════════════════════════════════════════
|
|
47
|
+
EmptyState
|
|
48
|
+
══════════════════════════════════════════════════════════════════════ */
|
|
49
|
+
|
|
50
|
+
export interface EmptyStateProps extends StateFrameProps {
|
|
51
|
+
title: ReactNode
|
|
52
|
+
/** One sentence saying what would be here and how it gets here. */
|
|
53
|
+
description?: ReactNode
|
|
54
|
+
icon?: ReactNode
|
|
55
|
+
/**
|
|
56
|
+
* The one thing to do about it. An empty state gets at most one
|
|
57
|
+
* primary action — if there are two ways forward, one of them is
|
|
58
|
+
* secondary.
|
|
59
|
+
*/
|
|
60
|
+
actions?: ReactNode
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
64
|
+
function EmptyState(
|
|
65
|
+
{ title, description, icon, actions, reserve, surface = 'none', className, style, ...rest },
|
|
66
|
+
ref,
|
|
67
|
+
) {
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
ref={ref}
|
|
71
|
+
data-surface={surface === 'none' ? undefined : surface}
|
|
72
|
+
className={cn('lv-state', className)}
|
|
73
|
+
style={frameStyle(reserve, style)}
|
|
74
|
+
{...rest}
|
|
75
|
+
>
|
|
76
|
+
<span className="lv-state-icon">{icon ?? <IconEmpty size={18} />}</span>
|
|
77
|
+
<p className="lv-state-title">{title}</p>
|
|
78
|
+
{description && <p className="lv-state-body">{description}</p>}
|
|
79
|
+
{/* Rendered unconditionally: the action row is part of the
|
|
80
|
+
reserved box whether or not it holds a button. */}
|
|
81
|
+
<div className="lv-state-actions">{actions}</div>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
/* ══════════════════════════════════════════════════════════════════════
|
|
88
|
+
ErrorState
|
|
89
|
+
══════════════════════════════════════════════════════════════════════ */
|
|
90
|
+
|
|
91
|
+
export interface ErrorStateProps extends StateFrameProps {
|
|
92
|
+
title?: ReactNode
|
|
93
|
+
/** What went wrong, in the reader's terms. */
|
|
94
|
+
description?: ReactNode
|
|
95
|
+
/**
|
|
96
|
+
* The raw error, tucked behind a disclosure. Never the headline: a
|
|
97
|
+
* stack trace is not a message. Rendered in a recessed panel.
|
|
98
|
+
*/
|
|
99
|
+
detail?: string
|
|
100
|
+
detailLabel?: string
|
|
101
|
+
actions?: ReactNode
|
|
102
|
+
icon?: ReactNode
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const ErrorState = forwardRef<HTMLDivElement, ErrorStateProps>(
|
|
106
|
+
function ErrorState(
|
|
107
|
+
{
|
|
108
|
+
title = 'Something went wrong',
|
|
109
|
+
description,
|
|
110
|
+
detail,
|
|
111
|
+
detailLabel = 'Show details',
|
|
112
|
+
actions,
|
|
113
|
+
icon,
|
|
114
|
+
reserve,
|
|
115
|
+
surface = 'none',
|
|
116
|
+
className,
|
|
117
|
+
style,
|
|
118
|
+
...rest
|
|
119
|
+
},
|
|
120
|
+
ref,
|
|
121
|
+
) {
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
ref={ref}
|
|
125
|
+
role="alert"
|
|
126
|
+
data-tone="error"
|
|
127
|
+
data-surface={surface === 'none' ? undefined : surface}
|
|
128
|
+
className={cn('lv-state', className)}
|
|
129
|
+
style={frameStyle(reserve, style)}
|
|
130
|
+
{...rest}
|
|
131
|
+
>
|
|
132
|
+
<span className="lv-state-icon">{icon ?? <IconDanger size={18} />}</span>
|
|
133
|
+
<p className="lv-state-title">{title}</p>
|
|
134
|
+
{description && <p className="lv-state-body">{description}</p>}
|
|
135
|
+
<div className="lv-state-actions">{actions}</div>
|
|
136
|
+
{detail && (
|
|
137
|
+
<details className="lv-state-detail">
|
|
138
|
+
<summary>{detailLabel}</summary>
|
|
139
|
+
<pre>{detail}</pre>
|
|
140
|
+
</details>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
/* ══════════════════════════════════════════════════════════════════════
|
|
148
|
+
LoadingSkeleton
|
|
149
|
+
══════════════════════════════════════════════════════════════════════ */
|
|
150
|
+
|
|
151
|
+
export type SkeletonVariant = 'text' | 'block' | 'stat' | 'fields' | 'document'
|
|
152
|
+
|
|
153
|
+
export interface LoadingSkeletonProps extends StateFrameProps {
|
|
154
|
+
/**
|
|
155
|
+
* Shape of the thing being loaded. Each variant mirrors the geometry
|
|
156
|
+
* of the real primitive so the swap is invisible:
|
|
157
|
+
* text — n lines of body copy, last one short
|
|
158
|
+
* block — one solid rectangle (`height`)
|
|
159
|
+
* stat — a StatTile's label / value / delta stack
|
|
160
|
+
* fields — DefinitionList rows on the shared label column
|
|
161
|
+
* document — a section heading plus a paragraph
|
|
162
|
+
*/
|
|
163
|
+
variant?: SkeletonVariant
|
|
164
|
+
/** Line/row count for `text`, `fields`, `document`. */
|
|
165
|
+
lines?: number
|
|
166
|
+
/** Height in px for `block`. */
|
|
167
|
+
height?: number
|
|
168
|
+
/** Accessible status text announced while loading. */
|
|
169
|
+
label?: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function Bar({ width, height }: { width: string; height: number }) {
|
|
173
|
+
return (
|
|
174
|
+
<span className="lv-skeleton" style={{ width, height: `${height}px` }} />
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const LoadingSkeleton = forwardRef<HTMLDivElement, LoadingSkeletonProps>(
|
|
179
|
+
function LoadingSkeleton(
|
|
180
|
+
{
|
|
181
|
+
variant = 'text',
|
|
182
|
+
lines = 3,
|
|
183
|
+
height = 96,
|
|
184
|
+
label = 'Loading',
|
|
185
|
+
reserve,
|
|
186
|
+
surface = 'none',
|
|
187
|
+
className,
|
|
188
|
+
style,
|
|
189
|
+
...rest
|
|
190
|
+
},
|
|
191
|
+
ref,
|
|
192
|
+
) {
|
|
193
|
+
let content: ReactNode
|
|
194
|
+
switch (variant) {
|
|
195
|
+
case 'block':
|
|
196
|
+
content = <Bar width="100%" height={height} />
|
|
197
|
+
break
|
|
198
|
+
case 'stat':
|
|
199
|
+
content = (
|
|
200
|
+
<>
|
|
201
|
+
<Bar width="52%" height={10} />
|
|
202
|
+
<Bar width="72%" height={28} />
|
|
203
|
+
<Bar width="40%" height={10} />
|
|
204
|
+
</>
|
|
205
|
+
)
|
|
206
|
+
break
|
|
207
|
+
case 'fields':
|
|
208
|
+
content = (
|
|
209
|
+
<>
|
|
210
|
+
{Array.from({ length: lines }, (_, i) => (
|
|
211
|
+
<span
|
|
212
|
+
key={i}
|
|
213
|
+
className="lv-grid"
|
|
214
|
+
style={{
|
|
215
|
+
gridTemplateColumns: 'var(--lv-dl-label-w) minmax(0, 1fr)',
|
|
216
|
+
gap: space(4),
|
|
217
|
+
alignItems: 'center',
|
|
218
|
+
// Derived from the type scale, not guessed: this is
|
|
219
|
+
// exactly one FieldRow's line box plus its block
|
|
220
|
+
// padding, so the skeleton is the same height as the
|
|
221
|
+
// rows that replace it.
|
|
222
|
+
minHeight:
|
|
223
|
+
'calc(var(--lv-text-sm) * var(--lv-lh-sm) + var(--lv-space-2))',
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<Bar width="72%" height={10} />
|
|
227
|
+
<Bar width={i % 3 === 0 ? '46%' : '84%'} height={10} />
|
|
228
|
+
</span>
|
|
229
|
+
))}
|
|
230
|
+
</>
|
|
231
|
+
)
|
|
232
|
+
break
|
|
233
|
+
case 'document':
|
|
234
|
+
content = (
|
|
235
|
+
<>
|
|
236
|
+
<Bar width="38%" height={18} />
|
|
237
|
+
<span style={{ height: space(2) }} />
|
|
238
|
+
{Array.from({ length: lines }, (_, i) => (
|
|
239
|
+
<Bar key={i} width={i === lines - 1 ? '58%' : '100%'} height={12} />
|
|
240
|
+
))}
|
|
241
|
+
</>
|
|
242
|
+
)
|
|
243
|
+
break
|
|
244
|
+
case 'text':
|
|
245
|
+
default:
|
|
246
|
+
content = (
|
|
247
|
+
<>
|
|
248
|
+
{Array.from({ length: lines }, (_, i) => (
|
|
249
|
+
<Bar key={i} width={i === lines - 1 ? '62%' : '100%'} height={12} />
|
|
250
|
+
))}
|
|
251
|
+
</>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div
|
|
257
|
+
ref={ref}
|
|
258
|
+
role="status"
|
|
259
|
+
aria-busy="true"
|
|
260
|
+
aria-live="polite"
|
|
261
|
+
data-surface={surface === 'none' ? undefined : surface}
|
|
262
|
+
className={cn('lv-skeleton-group', className)}
|
|
263
|
+
style={frameStyle(reserve, style)}
|
|
264
|
+
{...rest}
|
|
265
|
+
>
|
|
266
|
+
<span className="lv-sr-only">{label}</span>
|
|
267
|
+
{content}
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
},
|
|
271
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lovett/ui v2 — StatusPill
|
|
3
|
+
*
|
|
4
|
+
* A compact state badge. The marker's SHAPE changes with the tone as
|
|
5
|
+
* well as its colour — filled dot, hollow ring, square, triangle, bar —
|
|
6
|
+
* so the pill still reads correctly in greyscale or to a colour-blind
|
|
7
|
+
* reader. Shape is the primary signal; hue reinforces it.
|
|
8
|
+
*
|
|
9
|
+
* The pill is a fixed 20px tall and never changes size between tones,
|
|
10
|
+
* states, or loading. Long labels ellipsize rather than wrapping, since
|
|
11
|
+
* a two-line pill in a table row looks broken.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
|
15
|
+
import { cn } from '../lib/utils'
|
|
16
|
+
|
|
17
|
+
export type StatusTone =
|
|
18
|
+
| 'neutral'
|
|
19
|
+
| 'accent'
|
|
20
|
+
| 'success'
|
|
21
|
+
| 'warning'
|
|
22
|
+
| 'danger'
|
|
23
|
+
| 'info'
|
|
24
|
+
|
|
25
|
+
export type MarkShape = 'dot' | 'ring' | 'square' | 'triangle' | 'bar'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default shape per tone. Chosen so no two commonly co-occurring states
|
|
29
|
+
* share a silhouette: `success` (dot) next to `warning` (triangle) next
|
|
30
|
+
* to `danger` (square) is legible with the colour stripped out.
|
|
31
|
+
*/
|
|
32
|
+
const TONE_SHAPE: Readonly<Record<StatusTone, MarkShape>> = {
|
|
33
|
+
neutral: 'ring',
|
|
34
|
+
accent: 'bar',
|
|
35
|
+
success: 'dot',
|
|
36
|
+
warning: 'triangle',
|
|
37
|
+
danger: 'square',
|
|
38
|
+
info: 'dot',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StatusPillProps extends HTMLAttributes<HTMLSpanElement> {
|
|
42
|
+
tone?: StatusTone
|
|
43
|
+
/** Override the marker silhouette. */
|
|
44
|
+
shape?: MarkShape
|
|
45
|
+
/** Drop the marker entirely — only for pills whose label is already
|
|
46
|
+
* unambiguous on its own (a version number, a count). */
|
|
47
|
+
hideMark?: boolean
|
|
48
|
+
children?: ReactNode
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const StatusPill = forwardRef<HTMLSpanElement, StatusPillProps>(
|
|
52
|
+
function StatusPill(
|
|
53
|
+
{ tone = 'neutral', shape, hideMark, className, children, ...rest },
|
|
54
|
+
ref,
|
|
55
|
+
) {
|
|
56
|
+
return (
|
|
57
|
+
<span
|
|
58
|
+
ref={ref}
|
|
59
|
+
data-tone={tone}
|
|
60
|
+
className={cn('lv-pill', className)}
|
|
61
|
+
{...rest}
|
|
62
|
+
>
|
|
63
|
+
{!hideMark && (
|
|
64
|
+
<span
|
|
65
|
+
className="lv-pill-mark"
|
|
66
|
+
data-shape={shape ?? TONE_SHAPE[tone]}
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
70
|
+
<span className="lv-pill-text">{children}</span>
|
|
71
|
+
</span>
|
|
72
|
+
)
|
|
73
|
+
},
|
|
74
|
+
)
|