@lovett/ui 0.0.1

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 (102) hide show
  1. package/README.md +28 -0
  2. package/package.json +53 -0
  3. package/src/accordion.tsx +153 -0
  4. package/src/atoms.tsx +197 -0
  5. package/src/badge.tsx +91 -0
  6. package/src/brand-logo-tile.tsx +200 -0
  7. package/src/bulk-action-bar.tsx +104 -0
  8. package/src/button-group.tsx +46 -0
  9. package/src/button.tsx +93 -0
  10. package/src/calculator-shell-v2.tsx +380 -0
  11. package/src/calculator-shell.tsx +310 -0
  12. package/src/card.tsx +369 -0
  13. package/src/checkbox.tsx +114 -0
  14. package/src/chip-nav.tsx +347 -0
  15. package/src/choropleth.tsx +62 -0
  16. package/src/code-block.tsx +235 -0
  17. package/src/completion-ring.tsx +119 -0
  18. package/src/copy-field.tsx +169 -0
  19. package/src/data-grid/column-options-menu.tsx +127 -0
  20. package/src/data-grid/data-grid.tsx +391 -0
  21. package/src/data-grid/drawer-panel.tsx +146 -0
  22. package/src/data-grid/index.ts +29 -0
  23. package/src/data-grid/sortable-parts.tsx +215 -0
  24. package/src/data-grid/table-body.tsx +238 -0
  25. package/src/data-grid/table-elements.tsx +135 -0
  26. package/src/data-grid/table-header.tsx +95 -0
  27. package/src/data-grid/table-overlays.tsx +78 -0
  28. package/src/data-grid/table-parts.tsx +15 -0
  29. package/src/data-grid/table-summary-footer.tsx +101 -0
  30. package/src/data-grid/table-view.tsx +176 -0
  31. package/src/data-grid/types.ts +141 -0
  32. package/src/data-grid/use-grid-columns.ts +311 -0
  33. package/src/data-grid/use-grid-editing.ts +154 -0
  34. package/src/device-frame.tsx +274 -0
  35. package/src/dropdown-button.tsx +221 -0
  36. package/src/dropdown-menu.tsx +450 -0
  37. package/src/empty-state.tsx +85 -0
  38. package/src/file-upload/file-drop-zone.tsx +156 -0
  39. package/src/file-upload/file-preview-grid.tsx +39 -0
  40. package/src/file-upload/file-preview-item.tsx +175 -0
  41. package/src/file-upload/file-thumbnail.tsx +98 -0
  42. package/src/file-upload/file-upload-button.tsx +95 -0
  43. package/src/file-upload/index.ts +13 -0
  44. package/src/file-upload/use-file-upload.ts +371 -0
  45. package/src/filter-bar.tsx +125 -0
  46. package/src/filter-dropdown.tsx +289 -0
  47. package/src/floating-drawer.tsx +420 -0
  48. package/src/folder-card.tsx +188 -0
  49. package/src/folder-tree-picker.tsx +511 -0
  50. package/src/format.ts +117 -0
  51. package/src/frame-stack.tsx +73 -0
  52. package/src/hero-form-card.tsx +471 -0
  53. package/src/index.ts +372 -0
  54. package/src/input.tsx +130 -0
  55. package/src/kbd.tsx +41 -0
  56. package/src/lib/layout.ts +22 -0
  57. package/src/lib/utils.ts +6 -0
  58. package/src/markdown.tsx +214 -0
  59. package/src/menu-button.tsx +142 -0
  60. package/src/meta-previews/CreativeMedia.tsx +119 -0
  61. package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
  62. package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
  63. package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
  64. package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
  65. package/src/meta-previews/MetaFeedPreview.tsx +294 -0
  66. package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
  67. package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
  68. package/src/meta-previews/MetaReelPreview.tsx +750 -0
  69. package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
  70. package/src/meta-previews/MetaSearchPreview.tsx +113 -0
  71. package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
  72. package/src/meta-previews/ProfileAvatar.tsx +79 -0
  73. package/src/meta-previews/defaults.ts +27 -0
  74. package/src/meta-previews/index.ts +37 -0
  75. package/src/meta-previews/types.ts +219 -0
  76. package/src/metric-card.tsx +117 -0
  77. package/src/modal.tsx +131 -0
  78. package/src/option-tile.tsx +252 -0
  79. package/src/page-hero.tsx +78 -0
  80. package/src/page-shell.tsx +440 -0
  81. package/src/pagination.tsx +86 -0
  82. package/src/pill-button.tsx +103 -0
  83. package/src/profile-section.tsx +389 -0
  84. package/src/progress-bar.tsx +84 -0
  85. package/src/radio.tsx +172 -0
  86. package/src/range-slider.tsx +161 -0
  87. package/src/resizable.tsx +282 -0
  88. package/src/search-bar.tsx +81 -0
  89. package/src/segmented-pill.tsx +143 -0
  90. package/src/shell.tsx +464 -0
  91. package/src/slider.tsx +107 -0
  92. package/src/sortable-table.tsx +198 -0
  93. package/src/sortable.tsx +137 -0
  94. package/src/stat-row.tsx +132 -0
  95. package/src/stats-grid.tsx +38 -0
  96. package/src/step-loader.tsx +141 -0
  97. package/src/styles.css +562 -0
  98. package/src/tabs.tsx +143 -0
  99. package/src/tag-chip-input.tsx +394 -0
  100. package/src/textarea.tsx +39 -0
  101. package/src/toast.tsx +59 -0
  102. package/src/tokens.css +579 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * SortableTable — generic sortable table with sticky header.
3
+ *
4
+ * Promoted in ADR-022 (Phase 3c Discovery reports table, first
5
+ * committed consumer). Cross-batch consumers: Phase 8 keywords table,
6
+ * Phase 12 SEMSpec keywords/extensions/locations tables.
7
+ *
8
+ * Numeric columns auto-apply tabular-nums + right-align. Click column
9
+ * headers to sort. Sticky `<thead>` for scrollable bodies. Zebra-stripe
10
+ * optional.
11
+ *
12
+ * Token discipline: all colors via public tokens.
13
+ */
14
+
15
+ import { useMemo, useState, type ReactNode } from 'react'
16
+ import { ChevronDown, ChevronUp, ChevronsUpDown } from 'lucide-react'
17
+
18
+ export interface Column<T> {
19
+ /** Unique id; doubles as the sort key. Use a `keyof T` string or
20
+ * an arbitrary string if `sortValue` is provided. */
21
+ id: string
22
+ label: string
23
+ /** How to render a cell for a row. */
24
+ accessor: (row: T) => ReactNode
25
+ /** Optional explicit value used for sorting (defaults to the
26
+ * accessor's stringified output for sort comparison). */
27
+ sortValue?: (row: T) => string | number | null | undefined
28
+ sortable?: boolean
29
+ /** Right-align + tabular-nums for numeric columns. Default false. */
30
+ numeric?: boolean
31
+ /** Header tooltip text. */
32
+ tooltip?: string
33
+ /** Width as a CSS value (e.g. `120px` or `20%`). */
34
+ width?: string
35
+ }
36
+
37
+ export interface SortableTableProps<T> {
38
+ columns: Column<T>[]
39
+ rows: T[]
40
+ /** Function returning a stable React key for each row. */
41
+ rowKey: (row: T) => string
42
+ /** Initial sort. */
43
+ initialSort?: { columnId: string; dir: 'asc' | 'desc' }
44
+ /** Row click handler. */
45
+ onRowClick?: (row: T) => void
46
+ /** Rendered when rows.length === 0. */
47
+ emptyState?: ReactNode
48
+ /** Zebra stripe alternating rows. Default true. */
49
+ zebra?: boolean
50
+ /** Compact density (smaller padding). Default false. */
51
+ compact?: boolean
52
+ }
53
+
54
+ export function SortableTable<T>({
55
+ columns,
56
+ rows,
57
+ rowKey,
58
+ initialSort,
59
+ onRowClick,
60
+ emptyState,
61
+ zebra = true,
62
+ compact = false,
63
+ }: SortableTableProps<T>) {
64
+ const [sortColumnId, setSortColumnId] = useState<string | null>(
65
+ initialSort?.columnId ?? null,
66
+ )
67
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>(
68
+ initialSort?.dir ?? 'desc',
69
+ )
70
+
71
+ const sortedRows = useMemo(() => {
72
+ if (!sortColumnId) return rows
73
+ const col = columns.find((c) => c.id === sortColumnId)
74
+ if (!col) return rows
75
+ const valueOf = col.sortValue ?? ((row: T) => String(col.accessor(row)))
76
+ const dir = sortDir === 'asc' ? 1 : -1
77
+ return [...rows].sort((a, b) => {
78
+ const va = valueOf(a)
79
+ const vb = valueOf(b)
80
+ if (va == null && vb == null) return 0
81
+ if (va == null) return 1 * dir
82
+ if (vb == null) return -1 * dir
83
+ if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir
84
+ return String(va).localeCompare(String(vb)) * dir
85
+ })
86
+ }, [rows, sortColumnId, sortDir, columns])
87
+
88
+ const handleHeaderClick = (col: Column<T>) => {
89
+ if (!col.sortable) return
90
+ if (sortColumnId === col.id) {
91
+ setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
92
+ } else {
93
+ setSortColumnId(col.id)
94
+ setSortDir(col.numeric ? 'desc' : 'asc')
95
+ }
96
+ }
97
+
98
+ if (rows.length === 0 && emptyState) {
99
+ return <>{emptyState}</>
100
+ }
101
+
102
+ const cellPadding = compact ? 'px-2 py-1.5' : 'px-3 py-2.5'
103
+
104
+ return (
105
+ <div
106
+ className="w-full overflow-x-auto border"
107
+ style={{
108
+ borderColor: 'rgb(var(--border))',
109
+ borderRadius: 'var(--radius-lg)',
110
+ }}
111
+ >
112
+ <table className="w-full border-collapse text-[13px]">
113
+ <thead
114
+ className="sticky top-0 z-10"
115
+ style={{ background: 'rgb(var(--surface-overlay-soft))' }}
116
+ >
117
+ <tr>
118
+ {columns.map((col) => {
119
+ const isSorted = sortColumnId === col.id
120
+ const align = col.numeric ? 'text-right' : 'text-left'
121
+ const ariaSort: 'ascending' | 'descending' | 'none' = !col.sortable
122
+ ? 'none'
123
+ : isSorted
124
+ ? sortDir === 'asc'
125
+ ? 'ascending'
126
+ : 'descending'
127
+ : 'none'
128
+ return (
129
+ <th
130
+ key={col.id}
131
+ scope="col"
132
+ aria-sort={ariaSort}
133
+ title={col.tooltip}
134
+ className={`${cellPadding} ${align} font-semibold border-b ${
135
+ col.sortable ? 'cursor-pointer select-none' : ''
136
+ }`}
137
+ style={{
138
+ color: 'rgb(var(--text-secondary))',
139
+ borderColor: 'rgb(var(--border))',
140
+ width: col.width,
141
+ }}
142
+ onClick={() => handleHeaderClick(col)}
143
+ >
144
+ <span className="inline-flex items-center gap-1">
145
+ {col.label}
146
+ {col.sortable &&
147
+ (isSorted ? (
148
+ sortDir === 'asc' ? (
149
+ <ChevronUp className="h-3 w-3" />
150
+ ) : (
151
+ <ChevronDown className="h-3 w-3" />
152
+ )
153
+ ) : (
154
+ <ChevronsUpDown
155
+ className="h-3 w-3 opacity-40"
156
+ aria-hidden
157
+ />
158
+ ))}
159
+ </span>
160
+ </th>
161
+ )
162
+ })}
163
+ </tr>
164
+ </thead>
165
+ <tbody>
166
+ {sortedRows.map((row, idx) => (
167
+ <tr
168
+ key={rowKey(row)}
169
+ onClick={onRowClick ? () => onRowClick(row) : undefined}
170
+ className={
171
+ onRowClick ? 'cursor-pointer transition-colors' : undefined
172
+ }
173
+ style={{
174
+ background:
175
+ zebra && idx % 2 === 1
176
+ ? 'rgb(var(--surface-overlay-soft) / 0.5)'
177
+ : 'transparent',
178
+ }}
179
+ >
180
+ {columns.map((col) => (
181
+ <td
182
+ key={col.id}
183
+ className={`${cellPadding} ${col.numeric ? 'text-right tabular-nums font-mono' : 'text-left'} border-b`}
184
+ style={{
185
+ borderColor: 'rgb(var(--border))',
186
+ color: 'rgb(var(--foreground))',
187
+ }}
188
+ >
189
+ {col.accessor(row)}
190
+ </td>
191
+ ))}
192
+ </tr>
193
+ ))}
194
+ </tbody>
195
+ </table>
196
+ </div>
197
+ )
198
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Sortable primitives built on @dnd-kit. Promoted from brand-ai-extension
3
+ * `webapp/src/components/brand-profile-v2/sortable.tsx` per ADR-010
4
+ * Decision 5. CLAUDE.md §1 amended (Decision 6) to permit @dnd-kit/core +
5
+ * @dnd-kit/sortable + @dnd-kit/utilities as runtime deps.
6
+ *
7
+ * - <SortableList> wraps children with DndContext + SortableContext
8
+ * - <SortableItem> makes a single item draggable with a grab handle
9
+ * - <DragHandle> is a Lucide-grip handle pre-wired to the listeners
10
+ */
11
+
12
+ import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
13
+ import {
14
+ DndContext,
15
+ closestCenter,
16
+ KeyboardSensor,
17
+ PointerSensor,
18
+ useSensor,
19
+ useSensors,
20
+ type DragEndEvent,
21
+ } from '@dnd-kit/core'
22
+ import {
23
+ arrayMove,
24
+ SortableContext,
25
+ sortableKeyboardCoordinates,
26
+ useSortable,
27
+ verticalListSortingStrategy,
28
+ horizontalListSortingStrategy,
29
+ rectSortingStrategy,
30
+ } from '@dnd-kit/sortable'
31
+ import { CSS } from '@dnd-kit/utilities'
32
+ import { GripVertical } from 'lucide-react'
33
+
34
+ type Strategy = 'vertical' | 'horizontal' | 'grid'
35
+
36
+ export function SortableList<T>({
37
+ items,
38
+ getId,
39
+ onReorder,
40
+ strategy = 'vertical',
41
+ children,
42
+ }: {
43
+ items: T[]
44
+ getId: (item: T, index: number) => string
45
+ onReorder: (items: T[]) => void
46
+ strategy?: Strategy
47
+ children: ReactNode
48
+ }) {
49
+ const sensors = useSensors(
50
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
51
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
52
+ )
53
+
54
+ const sortIds = items.map(getId)
55
+
56
+ const sortingStrategy =
57
+ strategy === 'horizontal'
58
+ ? horizontalListSortingStrategy
59
+ : strategy === 'grid'
60
+ ? rectSortingStrategy
61
+ : verticalListSortingStrategy
62
+
63
+ const handleDragEnd = (event: DragEndEvent) => {
64
+ const { active, over } = event
65
+ if (over && active.id !== over.id) {
66
+ const oldIndex = sortIds.indexOf(String(active.id))
67
+ const newIndex = sortIds.indexOf(String(over.id))
68
+ if (oldIndex >= 0 && newIndex >= 0) {
69
+ onReorder(arrayMove(items, oldIndex, newIndex))
70
+ }
71
+ }
72
+ }
73
+
74
+ return (
75
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
76
+ <SortableContext items={sortIds} strategy={sortingStrategy}>
77
+ {children}
78
+ </SortableContext>
79
+ </DndContext>
80
+ )
81
+ }
82
+
83
+ export function SortableItem({
84
+ id,
85
+ children,
86
+ }: {
87
+ id: string
88
+ children: (props: {
89
+ dragHandleProps: HTMLAttributes<HTMLElement>
90
+ setNodeRef: (node: HTMLElement | null) => void
91
+ style: CSSProperties
92
+ isDragging: boolean
93
+ }) => ReactNode
94
+ }) {
95
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
96
+ id,
97
+ })
98
+
99
+ const style: CSSProperties = {
100
+ transform: CSS.Transform.toString(transform),
101
+ transition,
102
+ opacity: isDragging ? 0.4 : 1,
103
+ position: 'relative',
104
+ zIndex: isDragging ? 10 : 'auto',
105
+ }
106
+
107
+ return (
108
+ <>
109
+ {children({
110
+ dragHandleProps: { ...attributes, ...listeners } as HTMLAttributes<HTMLElement>,
111
+ setNodeRef,
112
+ style,
113
+ isDragging,
114
+ })}
115
+ </>
116
+ )
117
+ }
118
+
119
+ export function DragHandle({
120
+ dragHandleProps,
121
+ className = '',
122
+ }: {
123
+ dragHandleProps: HTMLAttributes<HTMLElement>
124
+ className?: string
125
+ }) {
126
+ return (
127
+ <button
128
+ {...dragHandleProps}
129
+ type="button"
130
+ className={`cursor-grab active:cursor-grabbing flex items-center justify-center text-[rgb(var(--text-muted))] hover:text-[rgb(var(--text-secondary))] transition-colors ${className}`}
131
+ style={{ touchAction: 'none' }}
132
+ aria-label="Drag to reorder"
133
+ >
134
+ <GripVertical className="h-4 w-4" />
135
+ </button>
136
+ )
137
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * StatRow — the Calculator Shell v2 signature primitive (ADR-076 D2).
3
+ *
4
+ * A horizontal row of "step" tiles showing a metric chain compounding
5
+ * left -> right. Each tile is a near-black brand-ink outer card holding a
6
+ * recessed white inner panel with the figure; a red arrow between tiles
7
+ * shows the math compounding. Exactly one tile may be `pivotal` — it
8
+ * flips from brand-ink to accent red (the "black/red/white rendered in
9
+ * miniature" that makes black survive without a heavy slab).
10
+ *
11
+ * <StatRow steps={[
12
+ * { icon: <Users size={16} />, label: 'Audience', value: '120K' },
13
+ * { icon: <Repeat size={16} />, label: 'Frequency', value: '2.0x', pivotal: true },
14
+ * { icon: <Eye size={16} />, label: 'Impressions', value: '240K' },
15
+ * { icon: <Wallet size={16} />, label: 'Budget', value: '$8,160' },
16
+ * ]} />
17
+ *
18
+ * Presentational + stateless. The caller formats `value` (tabular-nums is
19
+ * applied here). Inner panels carry a fixed `min-height` so a tile never
20
+ * resizes as its value's length changes (ADR-076 D5 zero-layout-shift).
21
+ *
22
+ * Promoted to @lovett/ui under ADR-076 D2 — the catalog of calculator
23
+ * consumers clears ADR-008 D3's 2+ consumer gate.
24
+ */
25
+
26
+ import { ArrowRight } from 'lucide-react'
27
+ import { Fragment, type ReactNode } from 'react'
28
+
29
+ export interface StatRowStep {
30
+ /** Lucide glyph (~16px). Inherits the dark-frame label colour. */
31
+ icon: ReactNode
32
+ /** Uppercase metric label (rendered ~9.5px). */
33
+ label: string
34
+ /** Pre-formatted figure. Rendered tabular-nums. */
35
+ value: string
36
+ /** The single pivotal tile — rendered in `--accent` instead of
37
+ * `--brand-ink`. At most one step should set this. */
38
+ pivotal?: boolean
39
+ }
40
+
41
+ export interface StatRowProps {
42
+ steps: StatRowStep[]
43
+ className?: string
44
+ }
45
+
46
+ export function StatRow({ steps, className }: StatRowProps) {
47
+ return (
48
+ <div className={className} style={{ display: 'flex', alignItems: 'stretch', gap: 0 }}>
49
+ {steps.map((s, i) => (
50
+ <Fragment key={i}>
51
+ {i > 0 && (
52
+ <div
53
+ className="flex items-center shrink-0"
54
+ style={{ padding: '0 2px', color: 'rgb(var(--text-muted))' }}
55
+ aria-hidden="true"
56
+ >
57
+ <ArrowRight size={14} strokeWidth={2.2} />
58
+ </div>
59
+ )}
60
+ <div
61
+ data-pivotal={s.pivotal ? 'true' : undefined}
62
+ style={{
63
+ flex: 1,
64
+ minWidth: 0,
65
+ borderRadius: 'var(--radius-lg)',
66
+ padding: 5,
67
+ background: s.pivotal
68
+ ? 'rgb(var(--accent))'
69
+ : 'rgb(var(--brand-ink))',
70
+ }}
71
+ >
72
+ {/* label row sits on the dark frame */}
73
+ <div
74
+ className="flex items-center"
75
+ style={{
76
+ gap: 5,
77
+ padding: '4px 7px 6px',
78
+ color: 'rgb(255 255 255 / 0.7)',
79
+ minWidth: 0,
80
+ }}
81
+ >
82
+ <span className="inline-flex shrink-0">{s.icon}</span>
83
+ {/* Single-line cap: long labels truncate with an ellipsis
84
+ (full text on hover via title) instead of wrapping to a
85
+ second line and unbalancing the tile row. Keep labels short. */}
86
+ <span
87
+ className="font-bold uppercase"
88
+ title={s.label}
89
+ style={{
90
+ fontSize: '9.5px',
91
+ letterSpacing: '0.05em',
92
+ whiteSpace: 'nowrap',
93
+ overflow: 'hidden',
94
+ textOverflow: 'ellipsis',
95
+ minWidth: 0,
96
+ flex: 1,
97
+ }}
98
+ >
99
+ {s.label}
100
+ </span>
101
+ </div>
102
+ {/* recessed white inner panel — fixed min-height so the tile
103
+ never resizes as the value's length changes */}
104
+ <div
105
+ className="grid place-items-center text-center"
106
+ style={{
107
+ background: 'rgb(var(--bg-card))',
108
+ borderRadius: 10,
109
+ padding: '12px 10px',
110
+ minHeight: 46,
111
+ }}
112
+ >
113
+ <span
114
+ className="font-extrabold tabular-nums"
115
+ style={{
116
+ fontSize: 22,
117
+ letterSpacing: '-0.025em',
118
+ lineHeight: 1,
119
+ color: s.pivotal
120
+ ? 'rgb(var(--accent))'
121
+ : 'rgb(var(--foreground))',
122
+ }}
123
+ >
124
+ {s.value}
125
+ </span>
126
+ </div>
127
+ </div>
128
+ </Fragment>
129
+ ))}
130
+ </div>
131
+ )
132
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * StatsGrid — auto-fit responsive grid for MetricCard rows.
3
+ *
4
+ * Promoted in ADR-022 (Phase 3c Discovery Analytics, first committed
5
+ * consumer). Cross-batch consumers: Phase 3a (generation cost row),
6
+ * Phase 8 (results summary), Phase 12 (review-overview summary).
7
+ *
8
+ * Tiny primitive — but stops the auto-fit grid pattern being
9
+ * re-written 4+ times across the batch.
10
+ */
11
+
12
+ import type { ReactNode } from 'react'
13
+
14
+ export interface StatsGridProps {
15
+ children: ReactNode
16
+ /** Minimum column width in px (controls how many cards per row at a
17
+ * given viewport). Default 220 — comfortable for 4-up at md+. */
18
+ minColumnWidth?: number
19
+ /** Optional className override for the outer container. */
20
+ className?: string
21
+ }
22
+
23
+ export function StatsGrid({
24
+ children,
25
+ minColumnWidth = 220,
26
+ className,
27
+ }: StatsGridProps) {
28
+ return (
29
+ <div
30
+ className={className ?? 'grid gap-3'}
31
+ style={{
32
+ gridTemplateColumns: `repeat(auto-fit, minmax(${minColumnWidth}px, 1fr))`,
33
+ }}
34
+ >
35
+ {children}
36
+ </div>
37
+ )
38
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * StepLoader — a sequential, multi-step loading indicator (3-5 steps). Each step
3
+ * dwells for a believable duration, then the next begins (completed → check, current
4
+ * → spinner, pending → dot), connected by a vertical rail.
5
+ *
6
+ * The defining behavior: the FINAL step is HELD until the real work reports `done`
7
+ * AND a minimum dwell has elapsed — so a backend that returns faster than usual still
8
+ * plays the full, believable sequence instead of snapping away. Non-final steps always
9
+ * advance on their own timers (independent of `done`).
10
+ *
11
+ * <StepLoader steps={[...]} done={isReady} onComplete={() => show()} />
12
+ */
13
+
14
+ import { useEffect, useRef, useState } from 'react'
15
+ import { Check, Loader2 } from 'lucide-react'
16
+
17
+ export interface StepLoaderStep {
18
+ label: string
19
+ /** Override the default dwell for this step (ms). */
20
+ durationMs?: number
21
+ }
22
+
23
+ export interface StepLoaderProps {
24
+ /** The ordered steps (3-5). Strings use the default dwell. */
25
+ steps: ReadonlyArray<string | StepLoaderStep>
26
+ /** The real work finished — gates completion of the FINAL step only. */
27
+ done?: boolean
28
+ /** Fired once the sequence has played out AND `done` is true (+ `minLastStepMs`). */
29
+ onComplete?: () => void
30
+ /** Default per-step dwell (ms) for steps without an explicit duration. */
31
+ defaultStepMs?: number
32
+ /** Minimum time the final step stays before completing — keeps a fast result believable. */
33
+ minLastStepMs?: number
34
+ className?: string
35
+ }
36
+
37
+ type StepState = 'done' | 'active' | 'pending'
38
+
39
+ export function StepLoader({
40
+ steps,
41
+ done = false,
42
+ onComplete,
43
+ defaultStepMs = 2600,
44
+ minLastStepMs = 1400,
45
+ className,
46
+ }: StepLoaderProps) {
47
+ const items = steps.map((s) => (typeof s === 'string' ? { label: s } : s))
48
+ const n = items.length
49
+ const [active, setActive] = useState(0)
50
+ const [complete, setComplete] = useState(false)
51
+ const completedRef = useRef(false)
52
+ const minElapsedRef = useRef(false)
53
+ const doneRef = useRef(done)
54
+ doneRef.current = done
55
+ const onCompleteRef = useRef(onComplete)
56
+ onCompleteRef.current = onComplete
57
+
58
+ function fire() {
59
+ if (completedRef.current) return
60
+ completedRef.current = true
61
+ setComplete(true)
62
+ onCompleteRef.current?.()
63
+ }
64
+
65
+ // Walk the non-final steps on their dwell timers, independent of `done` — the
66
+ // sequence always plays out so a fast backend still looks believable.
67
+ useEffect(() => {
68
+ if (complete || active >= n - 1) return
69
+ const ms = items[active]?.durationMs ?? defaultStepMs
70
+ const t = setTimeout(() => setActive((a) => Math.min(a + 1, n - 1)), ms)
71
+ return () => clearTimeout(t)
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, [active, n, complete, defaultStepMs])
74
+
75
+ // On the final step, hold for `minLastStepMs`, then complete if the work is already
76
+ // `done` (synchronously, so it flushes) — otherwise mark the dwell done and wait.
77
+ useEffect(() => {
78
+ if (active !== n - 1) return
79
+ const t = setTimeout(() => {
80
+ minElapsedRef.current = true
81
+ if (doneRef.current) fire()
82
+ }, minLastStepMs)
83
+ return () => clearTimeout(t)
84
+ }, [active, n, minLastStepMs])
85
+
86
+ // When the work finishes after the dwell has already elapsed, complete.
87
+ useEffect(() => {
88
+ if (active === n - 1 && done && minElapsedRef.current) fire()
89
+ }, [done, active, n])
90
+
91
+ return (
92
+ <div className={['flex flex-col', className].filter(Boolean).join(' ')} role="status" aria-live="polite">
93
+ {items.map((item, i) => {
94
+ const state: StepState = complete || i < active ? 'done' : i === active ? 'active' : 'pending'
95
+ const isLast = i === n - 1
96
+ return (
97
+ <div key={item.label} className="flex gap-3">
98
+ {/* marker + connector rail */}
99
+ <div className="flex flex-col items-center">
100
+ <span
101
+ className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-full)] transition-colors"
102
+ style={
103
+ state === 'done'
104
+ ? { background: 'rgb(var(--accent))', color: 'rgb(var(--surface-on-accent))' }
105
+ : state === 'active'
106
+ ? { boxShadow: 'inset 0 0 0 2px rgb(var(--accent))', color: 'rgb(var(--accent))' }
107
+ : { boxShadow: 'inset 0 0 0 2px rgb(var(--border))', color: 'rgb(var(--text-muted))' }
108
+ }
109
+ aria-current={state === 'active' ? 'step' : undefined}
110
+ >
111
+ {state === 'done' ? (
112
+ <Check className="h-4 w-4" />
113
+ ) : state === 'active' ? (
114
+ <Loader2 className="h-4 w-4 animate-spin" />
115
+ ) : (
116
+ <span className="h-1.5 w-1.5 rounded-[var(--radius-full)]" style={{ background: 'currentColor' }} />
117
+ )}
118
+ </span>
119
+ {!isLast && (
120
+ <span
121
+ className="my-1 w-px flex-1 transition-colors"
122
+ style={{ minHeight: 12, background: i < active || complete ? 'rgb(var(--accent))' : 'rgb(var(--border))' }}
123
+ />
124
+ )}
125
+ </div>
126
+ {/* label */}
127
+ <span
128
+ className="pt-[5px] pb-2 text-[13.5px] leading-tight transition-colors"
129
+ style={{
130
+ color: state === 'pending' ? 'rgb(var(--text-tertiary))' : 'rgb(var(--foreground))',
131
+ fontWeight: state === 'active' ? 600 : 500,
132
+ }}
133
+ >
134
+ {item.label}
135
+ </span>
136
+ </div>
137
+ )
138
+ })}
139
+ </div>
140
+ )
141
+ }