@lovett/ui 0.0.5 → 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 (56) hide show
  1. package/dist/index.d.ts +279 -115
  2. package/dist/index.js +479 -66
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +74 -0
  5. package/dist/tokens.css +181 -60
  6. package/package.json +21 -4
  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/card.tsx +1 -1
  16. package/src/collapsible-card.tsx +85 -0
  17. package/src/data-grid/table-body.tsx +8 -1
  18. package/src/dropdown-menu.tsx +1 -1
  19. package/src/floating-status-bar.tsx +1 -1
  20. package/src/folder-tree-picker.tsx +5 -6
  21. package/src/frame-stack.tsx +27 -10
  22. package/src/hero-form-card.tsx +2 -2
  23. package/src/icons/brand.tsx +187 -0
  24. package/src/index.ts +32 -0
  25. package/src/lib/clipboard.ts +14 -0
  26. package/src/lib/color.ts +111 -0
  27. package/src/meta-cell.tsx +52 -0
  28. package/src/meta-previews/MetaFeedCarousel.tsx +1 -1
  29. package/src/meta-previews/MetaFeedPreview.tsx +1 -1
  30. package/src/modal.tsx +77 -6
  31. package/src/pill-button.tsx +23 -5
  32. package/src/profile-section.tsx +40 -9
  33. package/src/sortable-table.tsx +5 -1
  34. package/src/styles.css +74 -0
  35. package/src/tabs.tsx +4 -0
  36. package/src/tag-chip-input.tsx +1 -1
  37. package/src/theme-v2.css +466 -0
  38. package/src/tokens.css +181 -60
  39. package/src/v2/README.md +208 -0
  40. package/src/v2/__demo__/showcase.tsx +1045 -0
  41. package/src/v2/action.tsx +91 -0
  42. package/src/v2/callout.tsx +76 -0
  43. package/src/v2/document-section.tsx +82 -0
  44. package/src/v2/document-shell.tsx +0 -0
  45. package/src/v2/field-row.tsx +113 -0
  46. package/src/v2/icons.tsx +165 -0
  47. package/src/v2/index.ts +147 -0
  48. package/src/v2/layout.tsx +293 -0
  49. package/src/v2/progress-track.tsx +89 -0
  50. package/src/v2/stat-tile.tsx +129 -0
  51. package/src/v2/states.tsx +271 -0
  52. package/src/v2/status-pill.tsx +74 -0
  53. package/src/v2/theme.css +1861 -0
  54. package/src/v2/timeline.tsx +81 -0
  55. package/src/v2/tokens.ts +228 -0
  56. 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
+ )