@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.
- package/README.md +28 -0
- package/package.json +53 -0
- package/src/accordion.tsx +153 -0
- package/src/atoms.tsx +197 -0
- package/src/badge.tsx +91 -0
- package/src/brand-logo-tile.tsx +200 -0
- package/src/bulk-action-bar.tsx +104 -0
- package/src/button-group.tsx +46 -0
- package/src/button.tsx +93 -0
- package/src/calculator-shell-v2.tsx +380 -0
- package/src/calculator-shell.tsx +310 -0
- package/src/card.tsx +369 -0
- package/src/checkbox.tsx +114 -0
- package/src/chip-nav.tsx +347 -0
- package/src/choropleth.tsx +62 -0
- package/src/code-block.tsx +235 -0
- package/src/completion-ring.tsx +119 -0
- package/src/copy-field.tsx +169 -0
- package/src/data-grid/column-options-menu.tsx +127 -0
- package/src/data-grid/data-grid.tsx +391 -0
- package/src/data-grid/drawer-panel.tsx +146 -0
- package/src/data-grid/index.ts +29 -0
- package/src/data-grid/sortable-parts.tsx +215 -0
- package/src/data-grid/table-body.tsx +238 -0
- package/src/data-grid/table-elements.tsx +135 -0
- package/src/data-grid/table-header.tsx +95 -0
- package/src/data-grid/table-overlays.tsx +78 -0
- package/src/data-grid/table-parts.tsx +15 -0
- package/src/data-grid/table-summary-footer.tsx +101 -0
- package/src/data-grid/table-view.tsx +176 -0
- package/src/data-grid/types.ts +141 -0
- package/src/data-grid/use-grid-columns.ts +311 -0
- package/src/data-grid/use-grid-editing.ts +154 -0
- package/src/device-frame.tsx +274 -0
- package/src/dropdown-button.tsx +221 -0
- package/src/dropdown-menu.tsx +450 -0
- package/src/empty-state.tsx +85 -0
- package/src/file-upload/file-drop-zone.tsx +156 -0
- package/src/file-upload/file-preview-grid.tsx +39 -0
- package/src/file-upload/file-preview-item.tsx +175 -0
- package/src/file-upload/file-thumbnail.tsx +98 -0
- package/src/file-upload/file-upload-button.tsx +95 -0
- package/src/file-upload/index.ts +13 -0
- package/src/file-upload/use-file-upload.ts +371 -0
- package/src/filter-bar.tsx +125 -0
- package/src/filter-dropdown.tsx +289 -0
- package/src/floating-drawer.tsx +420 -0
- package/src/folder-card.tsx +188 -0
- package/src/folder-tree-picker.tsx +511 -0
- package/src/format.ts +117 -0
- package/src/frame-stack.tsx +73 -0
- package/src/hero-form-card.tsx +471 -0
- package/src/index.ts +372 -0
- package/src/input.tsx +130 -0
- package/src/kbd.tsx +41 -0
- package/src/lib/layout.ts +22 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown.tsx +214 -0
- package/src/menu-button.tsx +142 -0
- package/src/meta-previews/CreativeMedia.tsx +119 -0
- package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
- package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
- package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
- package/src/meta-previews/MetaFeedPreview.tsx +294 -0
- package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
- package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
- package/src/meta-previews/MetaReelPreview.tsx +750 -0
- package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
- package/src/meta-previews/MetaSearchPreview.tsx +113 -0
- package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
- package/src/meta-previews/ProfileAvatar.tsx +79 -0
- package/src/meta-previews/defaults.ts +27 -0
- package/src/meta-previews/index.ts +37 -0
- package/src/meta-previews/types.ts +219 -0
- package/src/metric-card.tsx +117 -0
- package/src/modal.tsx +131 -0
- package/src/option-tile.tsx +252 -0
- package/src/page-hero.tsx +78 -0
- package/src/page-shell.tsx +440 -0
- package/src/pagination.tsx +86 -0
- package/src/pill-button.tsx +103 -0
- package/src/profile-section.tsx +389 -0
- package/src/progress-bar.tsx +84 -0
- package/src/radio.tsx +172 -0
- package/src/range-slider.tsx +161 -0
- package/src/resizable.tsx +282 -0
- package/src/search-bar.tsx +81 -0
- package/src/segmented-pill.tsx +143 -0
- package/src/shell.tsx +464 -0
- package/src/slider.tsx +107 -0
- package/src/sortable-table.tsx +198 -0
- package/src/sortable.tsx +137 -0
- package/src/stat-row.tsx +132 -0
- package/src/stats-grid.tsx +38 -0
- package/src/step-loader.tsx +141 -0
- package/src/styles.css +562 -0
- package/src/tabs.tsx +143 -0
- package/src/tag-chip-input.tsx +394 -0
- package/src/textarea.tsx +39 -0
- package/src/toast.tsx +59 -0
- 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
|
+
}
|
package/src/sortable.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/stat-row.tsx
ADDED
|
@@ -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
|
+
}
|