@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
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
+ }