@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
package/src/shell.tsx
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell — multi-tray framed-card pattern.
|
|
3
|
+
*
|
|
4
|
+
* Two-level card structure: an outer slate "wrapper" that holds one or
|
|
5
|
+
* more white panes inside it. Inspired by `src/lenses/brand/identity-
|
|
6
|
+
* rail.tsx` and codified into a reusable primitive after the utility-
|
|
7
|
+
* tools redesign (2026-05-19).
|
|
8
|
+
*
|
|
9
|
+
* Anatomy:
|
|
10
|
+
*
|
|
11
|
+
* ┌─────────────────────────────────────────────┐
|
|
12
|
+
* │ outer frame (--card-frame-bg + 16px │ ← <Shell>
|
|
13
|
+
* │ radius + border + rest-shadow + 10px │
|
|
14
|
+
* │ padding + 10px flex-gap) │
|
|
15
|
+
* │ │
|
|
16
|
+
* │ ┌───────────────────────────────────────┐ │ ← <Shell.Tray>
|
|
17
|
+
* │ │ Title meta │ │ <Shell.TrayHeader>
|
|
18
|
+
* │ │ │ │
|
|
19
|
+
* │ │ body content │ │ <Shell.TrayBody>
|
|
20
|
+
* │ │ │ │
|
|
21
|
+
* │ │ ─────────────────────────────────────│ │
|
|
22
|
+
* │ │ toggle toggle [Primary] │ │ <Shell.TrayFooter>
|
|
23
|
+
* │ └───────────────────────────────────────┘ │
|
|
24
|
+
* │ ┌───────────────────────────────────────┐ │
|
|
25
|
+
* │ │ Title │ │
|
|
26
|
+
* │ │ body content │ │
|
|
27
|
+
* │ └───────────────────────────────────────┘ │
|
|
28
|
+
* └─────────────────────────────────────────────┘
|
|
29
|
+
*
|
|
30
|
+
* Compound API:
|
|
31
|
+
* • <Shell> outer frame (page-level wrapper auto-centers)
|
|
32
|
+
* • <Shell.Tray> inner pane
|
|
33
|
+
* • <Shell.TrayHeader> title + meta + optional accent numeral
|
|
34
|
+
* • <Shell.TrayBody> body content
|
|
35
|
+
* • <Shell.TrayFooter> recessed footer strip — secondary controls
|
|
36
|
+
* left, primary right via marginLeft:auto
|
|
37
|
+
* • <Shell.SubCard> card-inside-a-tray for grouped items
|
|
38
|
+
*
|
|
39
|
+
* Token discipline: internal-only tokens (--card-frame-bg, --tray-bg,
|
|
40
|
+
* --card-foot-bg, --border-card, --card-shadow-rest) are touched HERE,
|
|
41
|
+
* not in app code. Consumers compose Shell.* with their own content.
|
|
42
|
+
*
|
|
43
|
+
* Icon-agnostic. No hardcoded icons.
|
|
44
|
+
*
|
|
45
|
+
* SPACING REFERENCE (per the locked spec):
|
|
46
|
+
* • Outer frame padding: 10px (all sides)
|
|
47
|
+
* • Gap between trays: 10px (flex gap, NOT margin)
|
|
48
|
+
* • Tray header padding: 20px 24px 0
|
|
49
|
+
* • Tray body padding: 14px 24px 22px (top 14 = header bottom shim)
|
|
50
|
+
* • Tray footer padding: 14px 18px (or 12px 18px compact)
|
|
51
|
+
* • Outer radius: 16px (--radius-lg) — inner tray radius: 12px
|
|
52
|
+
* (--radius-md) — DO NOT change one without the other.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
forwardRef,
|
|
57
|
+
type HTMLAttributes,
|
|
58
|
+
type ReactNode,
|
|
59
|
+
} from 'react'
|
|
60
|
+
import { cn } from './lib/utils'
|
|
61
|
+
|
|
62
|
+
// ─── Shell (outer frame) ───────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export interface ShellProps extends HTMLAttributes<HTMLDivElement> {
|
|
65
|
+
/** Max-width for the auto-centered page-level wrapper, in pixels.
|
|
66
|
+
* Default 760 (tool-page width). New Set / similar form pages use
|
|
67
|
+
* 880. When `centered` is false, the value is ignored. */
|
|
68
|
+
maxWidth?: number
|
|
69
|
+
/** When true (default), the frame is centered horizontally inside
|
|
70
|
+
* its parent via `mx-auto` + `max-width`. Pass `false` if you need
|
|
71
|
+
* the frame to fill its parent (e.g. inside a tight grid cell). */
|
|
72
|
+
centered?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ShellRoot = forwardRef<HTMLDivElement, ShellProps>(function Shell(
|
|
76
|
+
{ maxWidth = 760, centered = true, className, style, children, ...rest },
|
|
77
|
+
ref,
|
|
78
|
+
) {
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
ref={ref}
|
|
82
|
+
data-framed="true"
|
|
83
|
+
data-slot="shell"
|
|
84
|
+
className={cn(
|
|
85
|
+
'ds-card-surface flex flex-col',
|
|
86
|
+
centered && 'mx-auto w-full',
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
style={{
|
|
90
|
+
background: 'rgb(var(--card-frame-bg))',
|
|
91
|
+
// 10px padding + 10px gap is what makes the trays "float"
|
|
92
|
+
// inside the frame. Concentric 16px outer / 12px inner radii
|
|
93
|
+
// depend on the 4px wall + 10px gap working together.
|
|
94
|
+
padding: 10,
|
|
95
|
+
gap: 10,
|
|
96
|
+
...(centered ? { maxWidth } : {}),
|
|
97
|
+
...style,
|
|
98
|
+
}}
|
|
99
|
+
{...rest}
|
|
100
|
+
>
|
|
101
|
+
{children}
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ─── Shell.Tray ─────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export interface ShellTrayProps extends HTMLAttributes<HTMLElement> {
|
|
109
|
+
/** Visual variant. `default` (white inner pane, used for primary
|
|
110
|
+
* content) or `compact` (tighter padding for dense rows). */
|
|
111
|
+
variant?: 'default' | 'compact'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const Tray = forwardRef<HTMLElement, ShellTrayProps>(function ShellTray(
|
|
115
|
+
{ variant = 'default', className, style, children, ...rest },
|
|
116
|
+
ref,
|
|
117
|
+
) {
|
|
118
|
+
void variant
|
|
119
|
+
return (
|
|
120
|
+
<section
|
|
121
|
+
ref={ref}
|
|
122
|
+
data-slot="shell-tray"
|
|
123
|
+
// `overflow: hidden` is required so the recessed footer's flat
|
|
124
|
+
// top edge clips cleanly inside the tray's 12px corners.
|
|
125
|
+
className={cn('flex flex-col', className)}
|
|
126
|
+
style={{
|
|
127
|
+
background: 'rgb(var(--tray-bg))',
|
|
128
|
+
border: '1px solid rgb(var(--border-card))',
|
|
129
|
+
borderRadius: 12,
|
|
130
|
+
overflow: 'hidden',
|
|
131
|
+
// Reset any margin the global `.ds-card-tray` class would
|
|
132
|
+
// apply — Shell uses flex-gap on the frame instead.
|
|
133
|
+
margin: 0,
|
|
134
|
+
...style,
|
|
135
|
+
}}
|
|
136
|
+
{...rest}
|
|
137
|
+
>
|
|
138
|
+
{children}
|
|
139
|
+
</section>
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// ─── Shell.TrayHeader ───────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export interface ShellTrayHeaderProps
|
|
146
|
+
extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
|
147
|
+
title: ReactNode
|
|
148
|
+
/** Right-aligned meta — e.g. "12 lines · 3 dupes" or
|
|
149
|
+
* "Name is required · everything else is optional". */
|
|
150
|
+
meta?: ReactNode
|
|
151
|
+
/** Optional left-side numeral chip ("01", "02") in accent color.
|
|
152
|
+
* Skip for tools that don't need step indication. */
|
|
153
|
+
index?: ReactNode
|
|
154
|
+
/** Optional supporting copy beneath the title row. */
|
|
155
|
+
description?: ReactNode
|
|
156
|
+
/** Tighter padding for dense layouts. */
|
|
157
|
+
compact?: boolean
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function TrayHeader({
|
|
161
|
+
title,
|
|
162
|
+
meta,
|
|
163
|
+
index,
|
|
164
|
+
description,
|
|
165
|
+
compact,
|
|
166
|
+
className,
|
|
167
|
+
style,
|
|
168
|
+
...rest
|
|
169
|
+
}: ShellTrayHeaderProps) {
|
|
170
|
+
return (
|
|
171
|
+
<header
|
|
172
|
+
data-slot="shell-tray-header"
|
|
173
|
+
className={cn('flex flex-col gap-1.5', className)}
|
|
174
|
+
style={{
|
|
175
|
+
// 20px top / 24px sides / 0 bottom — body padding shimmies
|
|
176
|
+
// the 14px top to land at 14px from the title baseline.
|
|
177
|
+
padding: compact ? '16px 18px 0' : '20px 24px 0',
|
|
178
|
+
...style,
|
|
179
|
+
}}
|
|
180
|
+
{...rest}
|
|
181
|
+
>
|
|
182
|
+
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
|
183
|
+
<div className="flex items-baseline gap-2 min-w-0">
|
|
184
|
+
{index && (
|
|
185
|
+
<span
|
|
186
|
+
className="text-[11px] font-bold uppercase tracking-[0.12em] shrink-0"
|
|
187
|
+
style={{ color: 'rgb(var(--accent))' }}
|
|
188
|
+
>
|
|
189
|
+
{index}
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
<h2
|
|
193
|
+
className="text-[15px] font-semibold leading-tight truncate"
|
|
194
|
+
style={{
|
|
195
|
+
color: 'rgb(var(--foreground))',
|
|
196
|
+
letterSpacing: '-0.005em',
|
|
197
|
+
}}
|
|
198
|
+
>
|
|
199
|
+
{title}
|
|
200
|
+
</h2>
|
|
201
|
+
</div>
|
|
202
|
+
{meta && (
|
|
203
|
+
<span
|
|
204
|
+
className="text-[11.5px] tabular-nums shrink-0"
|
|
205
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
206
|
+
>
|
|
207
|
+
{meta}
|
|
208
|
+
</span>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
{description && (
|
|
212
|
+
<p
|
|
213
|
+
className="text-[13px] leading-[1.45]"
|
|
214
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
215
|
+
>
|
|
216
|
+
{description}
|
|
217
|
+
</p>
|
|
218
|
+
)}
|
|
219
|
+
</header>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Shell.TrayBody ─────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
export interface ShellTrayBodyProps extends HTMLAttributes<HTMLDivElement> {
|
|
226
|
+
/** Vertical gap between children. Default `form` (16px). `grid`
|
|
227
|
+
* (14px) is for tighter sub-card grids. `flush` (0) lets the
|
|
228
|
+
* consumer manage spacing entirely. */
|
|
229
|
+
spacing?: 'form' | 'grid' | 'flush'
|
|
230
|
+
/** Tighter padding for dense layouts. */
|
|
231
|
+
compact?: boolean
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function TrayBody({
|
|
235
|
+
spacing = 'form',
|
|
236
|
+
compact,
|
|
237
|
+
className,
|
|
238
|
+
style,
|
|
239
|
+
children,
|
|
240
|
+
...rest
|
|
241
|
+
}: ShellTrayBodyProps) {
|
|
242
|
+
const gap = spacing === 'flush' ? 0 : spacing === 'grid' ? 14 : 16
|
|
243
|
+
return (
|
|
244
|
+
<div
|
|
245
|
+
data-slot="shell-tray-body"
|
|
246
|
+
className={cn('flex flex-col', className)}
|
|
247
|
+
style={{
|
|
248
|
+
padding: compact ? '12px 18px 18px' : '14px 24px 22px',
|
|
249
|
+
gap,
|
|
250
|
+
...style,
|
|
251
|
+
}}
|
|
252
|
+
{...rest}
|
|
253
|
+
>
|
|
254
|
+
{children}
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Shell.TrayFooter ───────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
export interface ShellTrayFooterProps
|
|
262
|
+
extends HTMLAttributes<HTMLDivElement> {
|
|
263
|
+
/** Tighter padding for dense layouts. */
|
|
264
|
+
compact?: boolean
|
|
265
|
+
/** Left-aligned secondary controls (toggles, disclosure, etc.).
|
|
266
|
+
* Renders inline before any extra children. */
|
|
267
|
+
left?: ReactNode
|
|
268
|
+
/** Right-aligned primary action (button, link, etc.). Pushed to
|
|
269
|
+
* the right edge via `margin-left: auto`. */
|
|
270
|
+
right?: ReactNode
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function TrayFooter({
|
|
274
|
+
compact,
|
|
275
|
+
left,
|
|
276
|
+
right,
|
|
277
|
+
className,
|
|
278
|
+
style,
|
|
279
|
+
children,
|
|
280
|
+
...rest
|
|
281
|
+
}: ShellTrayFooterProps) {
|
|
282
|
+
return (
|
|
283
|
+
<div
|
|
284
|
+
data-slot="shell-tray-footer"
|
|
285
|
+
className={cn(
|
|
286
|
+
'flex items-center gap-3 flex-wrap',
|
|
287
|
+
className,
|
|
288
|
+
)}
|
|
289
|
+
style={{
|
|
290
|
+
background: 'rgb(var(--card-foot-bg))',
|
|
291
|
+
borderTop: '1px solid rgb(var(--border-card))',
|
|
292
|
+
padding: compact ? '12px 18px' : '14px 18px',
|
|
293
|
+
...style,
|
|
294
|
+
}}
|
|
295
|
+
{...rest}
|
|
296
|
+
>
|
|
297
|
+
{left && (
|
|
298
|
+
<div className="flex items-center gap-4 flex-wrap">{left}</div>
|
|
299
|
+
)}
|
|
300
|
+
{children}
|
|
301
|
+
{right && <div className="ml-auto">{right}</div>}
|
|
302
|
+
</div>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── Shell.SubCard ──────────────────────────────────────────────────
|
|
307
|
+
// Card-inside-a-tray for grouped items (e.g. Keyword Combiner's
|
|
308
|
+
// list-A / list-B sub-cards). Uses --surface-overlay-soft so it reads
|
|
309
|
+
// as a sub-element of the white tray, not another framed tray.
|
|
310
|
+
|
|
311
|
+
export interface ShellSubCardProps extends HTMLAttributes<HTMLDivElement> {
|
|
312
|
+
/** Optional header content rendered above the body. */
|
|
313
|
+
header?: ReactNode
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function SubCard({
|
|
317
|
+
header,
|
|
318
|
+
className,
|
|
319
|
+
style,
|
|
320
|
+
children,
|
|
321
|
+
...rest
|
|
322
|
+
}: ShellSubCardProps) {
|
|
323
|
+
return (
|
|
324
|
+
<div
|
|
325
|
+
data-slot="shell-subcard"
|
|
326
|
+
className={cn('flex flex-col gap-3', className)}
|
|
327
|
+
style={{
|
|
328
|
+
background: 'rgb(var(--surface-overlay-soft))',
|
|
329
|
+
border: '1px solid rgb(var(--border))',
|
|
330
|
+
borderRadius: 12,
|
|
331
|
+
padding: 12,
|
|
332
|
+
...style,
|
|
333
|
+
}}
|
|
334
|
+
{...rest}
|
|
335
|
+
>
|
|
336
|
+
{header && (
|
|
337
|
+
<div className="flex items-center justify-between gap-2 min-w-0">
|
|
338
|
+
{header}
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
{children}
|
|
342
|
+
</div>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ─── ShellStat (KPI card) ───────────────────────────────────────────
|
|
347
|
+
// A KPI card built on the shell pattern, inverted: the metric LABEL
|
|
348
|
+
// sits in the outer slate frame, the metric VALUE sits in the inner
|
|
349
|
+
// white tray. Drop several into a grid for an overview row.
|
|
350
|
+
|
|
351
|
+
/** Value-text tone for <ShellStat>. Semantic tones map to the public
|
|
352
|
+
* status tokens — handy for traffic-light KPIs (e.g. competition). */
|
|
353
|
+
export type ShellStatTone =
|
|
354
|
+
| 'default'
|
|
355
|
+
| 'accent'
|
|
356
|
+
| 'success'
|
|
357
|
+
| 'warning'
|
|
358
|
+
| 'destructive'
|
|
359
|
+
|
|
360
|
+
const SHELL_STAT_TONE: Record<ShellStatTone, string> = {
|
|
361
|
+
default: 'rgb(var(--foreground))',
|
|
362
|
+
accent: 'rgb(var(--accent))',
|
|
363
|
+
success: 'rgb(var(--success))',
|
|
364
|
+
warning: 'rgb(var(--warning))',
|
|
365
|
+
destructive: 'rgb(var(--destructive))',
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export interface ShellStatProps
|
|
369
|
+
extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
|
370
|
+
/** KPI name — rendered in the outer frame, above the tray. */
|
|
371
|
+
label: ReactNode
|
|
372
|
+
/** The metric value — rendered large inside the inner white tray. */
|
|
373
|
+
value: ReactNode
|
|
374
|
+
/** Optional icon shown beside the label in the frame. */
|
|
375
|
+
icon?: ReactNode
|
|
376
|
+
/** Optional supporting text under the value (e.g. "+3 this week"). */
|
|
377
|
+
hint?: ReactNode
|
|
378
|
+
/** Value text tone — `default`, `accent`, or a semantic status
|
|
379
|
+
* color (`success` / `warning` / `destructive`). */
|
|
380
|
+
valueTone?: ShellStatTone
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function ShellStat({
|
|
384
|
+
label,
|
|
385
|
+
value,
|
|
386
|
+
icon,
|
|
387
|
+
hint,
|
|
388
|
+
valueTone = 'default',
|
|
389
|
+
className,
|
|
390
|
+
...rest
|
|
391
|
+
}: ShellStatProps) {
|
|
392
|
+
return (
|
|
393
|
+
// `h-full` + the flex-1 tray below let a row of ShellStats keep
|
|
394
|
+
// equal-height trays even when only some cards carry a `hint`.
|
|
395
|
+
<Shell
|
|
396
|
+
centered={false}
|
|
397
|
+
className={cn('h-full', className)}
|
|
398
|
+
{...rest}
|
|
399
|
+
>
|
|
400
|
+
<div className="flex items-center gap-1.5 px-1.5 pt-0.5">
|
|
401
|
+
{icon && (
|
|
402
|
+
<span
|
|
403
|
+
className="inline-flex shrink-0"
|
|
404
|
+
style={{ color: 'rgb(var(--text-muted))' }}
|
|
405
|
+
aria-hidden="true"
|
|
406
|
+
>
|
|
407
|
+
{icon}
|
|
408
|
+
</span>
|
|
409
|
+
)}
|
|
410
|
+
<span
|
|
411
|
+
className="text-[11px] font-bold uppercase tracking-[0.1em] truncate"
|
|
412
|
+
style={{ color: 'rgb(var(--text-secondary))' }}
|
|
413
|
+
>
|
|
414
|
+
{label}
|
|
415
|
+
</span>
|
|
416
|
+
</div>
|
|
417
|
+
{/* `justify-center` vertically centers the value block inside
|
|
418
|
+
the flex-1 tray, so a row of ShellStats keeps every number on
|
|
419
|
+
the same baseline even when only some cards carry a `hint`. */}
|
|
420
|
+
<Shell.Tray className="flex-1 justify-center">
|
|
421
|
+
<div className="flex flex-col gap-0.5 px-4 py-3.5">
|
|
422
|
+
<span
|
|
423
|
+
className="text-[26px] font-bold leading-none tabular-nums"
|
|
424
|
+
style={{
|
|
425
|
+
color: SHELL_STAT_TONE[valueTone],
|
|
426
|
+
letterSpacing: '-0.02em',
|
|
427
|
+
}}
|
|
428
|
+
>
|
|
429
|
+
{value}
|
|
430
|
+
</span>
|
|
431
|
+
{hint && (
|
|
432
|
+
<span
|
|
433
|
+
className="text-[12px]"
|
|
434
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
435
|
+
>
|
|
436
|
+
{hint}
|
|
437
|
+
</span>
|
|
438
|
+
)}
|
|
439
|
+
</div>
|
|
440
|
+
</Shell.Tray>
|
|
441
|
+
</Shell>
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ─── Compose the compound API ───────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
type ShellComponent = typeof ShellRoot & {
|
|
448
|
+
Tray: typeof Tray
|
|
449
|
+
TrayHeader: typeof TrayHeader
|
|
450
|
+
TrayBody: typeof TrayBody
|
|
451
|
+
TrayFooter: typeof TrayFooter
|
|
452
|
+
SubCard: typeof SubCard
|
|
453
|
+
Stat: typeof ShellStat
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const Shell = ShellRoot as ShellComponent
|
|
457
|
+
Shell.Tray = Tray
|
|
458
|
+
Shell.TrayHeader = TrayHeader
|
|
459
|
+
Shell.TrayBody = TrayBody
|
|
460
|
+
Shell.TrayFooter = TrayFooter
|
|
461
|
+
Shell.SubCard = SubCard
|
|
462
|
+
Shell.Stat = ShellStat
|
|
463
|
+
|
|
464
|
+
export { Shell, ShellStat }
|
package/src/slider.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slider — single-value numeric slider (ADR-076 D8).
|
|
3
|
+
*
|
|
4
|
+
* Distinct from <RangeSlider>, which is dual-thumb (`value: [number,
|
|
5
|
+
* number]`) and cannot represent a single value. This is the control
|
|
6
|
+
* the Calculator Shell v2 frequency input uses: an accent fill from the
|
|
7
|
+
* track start to the current value, a `--bg-card` thumb with a 2px
|
|
8
|
+
* accent ring, and a transparent native `<input type="range">` overlaid
|
|
9
|
+
* for keyboard + accessibility (arrows / Home / End come free).
|
|
10
|
+
*
|
|
11
|
+
* <Slider value={freq} min={1} max={10} step={0.5}
|
|
12
|
+
* onChange={setFreq} aria-label="Target frequency" />
|
|
13
|
+
*
|
|
14
|
+
* Controlled — the parent owns `value`. Presentational otherwise.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { CSSProperties } from 'react'
|
|
18
|
+
|
|
19
|
+
export interface SliderProps {
|
|
20
|
+
value: number
|
|
21
|
+
min: number
|
|
22
|
+
max: number
|
|
23
|
+
/** Step granularity. Default 1. */
|
|
24
|
+
step?: number
|
|
25
|
+
onChange: (value: number) => void
|
|
26
|
+
/** Accessible name for the native range input. */
|
|
27
|
+
'aria-label'?: string
|
|
28
|
+
disabled?: boolean
|
|
29
|
+
className?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function Slider({
|
|
33
|
+
value,
|
|
34
|
+
min,
|
|
35
|
+
max,
|
|
36
|
+
step = 1,
|
|
37
|
+
onChange,
|
|
38
|
+
'aria-label': ariaLabel,
|
|
39
|
+
disabled,
|
|
40
|
+
className,
|
|
41
|
+
}: SliderProps) {
|
|
42
|
+
const span = max - min || 1
|
|
43
|
+
const pct = Math.max(0, Math.min(100, ((value - min) / span) * 100))
|
|
44
|
+
|
|
45
|
+
const railStyle: CSSProperties = {
|
|
46
|
+
position: 'absolute',
|
|
47
|
+
height: 7,
|
|
48
|
+
borderRadius: 'var(--radius-full)',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={className}
|
|
54
|
+
style={{ position: 'relative', height: 18, display: 'flex', alignItems: 'center' }}
|
|
55
|
+
>
|
|
56
|
+
{/* base track */}
|
|
57
|
+
<div style={{ ...railStyle, left: 0, right: 0, background: 'rgb(var(--muted))' }} />
|
|
58
|
+
{/* accent fill */}
|
|
59
|
+
<div
|
|
60
|
+
style={{
|
|
61
|
+
...railStyle,
|
|
62
|
+
left: 0,
|
|
63
|
+
width: `${pct}%`,
|
|
64
|
+
background: disabled
|
|
65
|
+
? 'rgb(var(--border-strong))'
|
|
66
|
+
: 'rgb(var(--accent))',
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
{/* thumb */}
|
|
70
|
+
<div
|
|
71
|
+
style={{
|
|
72
|
+
position: 'absolute',
|
|
73
|
+
left: `${pct}%`,
|
|
74
|
+
transform: 'translateX(-50%)',
|
|
75
|
+
width: 18,
|
|
76
|
+
height: 18,
|
|
77
|
+
borderRadius: 'var(--radius-full)',
|
|
78
|
+
background: 'rgb(var(--bg-card))',
|
|
79
|
+
border: `2px solid ${disabled ? 'rgb(var(--border-strong))' : 'rgb(var(--accent))'}`,
|
|
80
|
+
boxShadow: 'var(--shadow-sm)',
|
|
81
|
+
pointerEvents: 'none',
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
{/* native range — transparent overlay for keyboard + a11y */}
|
|
85
|
+
<input
|
|
86
|
+
type="range"
|
|
87
|
+
min={min}
|
|
88
|
+
max={max}
|
|
89
|
+
step={step}
|
|
90
|
+
value={value}
|
|
91
|
+
disabled={disabled}
|
|
92
|
+
aria-label={ariaLabel}
|
|
93
|
+
aria-valuetext={String(value)}
|
|
94
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
95
|
+
style={{
|
|
96
|
+
position: 'absolute',
|
|
97
|
+
left: 0,
|
|
98
|
+
width: '100%',
|
|
99
|
+
height: 18,
|
|
100
|
+
margin: 0,
|
|
101
|
+
opacity: 0,
|
|
102
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|