@hilum/ui 3.4.0 → 3.5.0

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 (65) hide show
  1. package/README.md +4 -0
  2. package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
  3. package/dist/chunk-RI6QJR2L.js.map +1 -0
  4. package/dist/index.d.ts +789 -115
  5. package/dist/index.js +10404 -1099
  6. package/dist/index.js.map +1 -1
  7. package/dist/tokens.css +17 -0
  8. package/dist/tokens.d.ts +3 -0
  9. package/dist/tokens.js +1 -1
  10. package/llms.txt +18 -4
  11. package/package.json +19 -1
  12. package/src/components/accordion.tsx +746 -58
  13. package/src/components/alert-dialog.tsx +11 -5
  14. package/src/components/ask-user-questions.tsx +1366 -0
  15. package/src/components/badge.tsx +105 -24
  16. package/src/components/button.tsx +207 -38
  17. package/src/components/card.tsx +3 -2
  18. package/src/components/chat-message.tsx +116 -0
  19. package/src/components/checkbox-group.tsx +403 -0
  20. package/src/components/checkbox.tsx +6 -3
  21. package/src/components/color-input.tsx +3 -1
  22. package/src/components/color-picker.tsx +1992 -80
  23. package/src/components/dialog.tsx +141 -81
  24. package/src/components/dropdown-menu.tsx +21 -13
  25. package/src/components/dropdown.tsx +245 -0
  26. package/src/components/file-thumbnail.tsx +156 -0
  27. package/src/components/hover-card.tsx +2 -0
  28. package/src/components/input-copy.tsx +307 -0
  29. package/src/components/input-group.tsx +303 -108
  30. package/src/components/input-message.tsx +883 -0
  31. package/src/components/input.tsx +6 -3
  32. package/src/components/menu-item.tsx +156 -0
  33. package/src/components/mobile-drawer.tsx +50 -0
  34. package/src/components/nav-item.tsx +43 -0
  35. package/src/components/nav-menu.tsx +47 -0
  36. package/src/components/popover.tsx +3 -1
  37. package/src/components/radio-group.tsx +381 -35
  38. package/src/components/scroll-area.tsx +9 -2
  39. package/src/components/select.tsx +708 -121
  40. package/src/components/sheet.tsx +15 -6
  41. package/src/components/slider.tsx +1583 -20
  42. package/src/components/status-badge.tsx +2 -0
  43. package/src/components/switch.tsx +245 -28
  44. package/src/components/table.tsx +191 -77
  45. package/src/components/tabs-subtle.tsx +411 -0
  46. package/src/components/tabs.tsx +459 -49
  47. package/src/components/textarea.tsx +7 -3
  48. package/src/components/thinking-indicator.tsx +106 -0
  49. package/src/components/thinking-steps.tsx +382 -0
  50. package/src/components/tooltip.tsx +158 -33
  51. package/src/hooks/use-merge-split.tsx +359 -0
  52. package/src/hooks/use-proximity-hover.ts +208 -0
  53. package/src/index.ts +14 -0
  54. package/src/lib/elevated.tsx +52 -0
  55. package/src/lib/font-weight.ts +18 -0
  56. package/src/lib/icon-context.tsx +98 -0
  57. package/src/lib/icon-map.tsx +669 -0
  58. package/src/lib/interaction.ts +102 -0
  59. package/src/lib/scroll-fade.tsx +241 -0
  60. package/src/lib/shape-context.tsx +121 -0
  61. package/src/lib/springs.ts +30 -0
  62. package/src/lib/surface-classes.ts +43 -0
  63. package/src/lib/surface-context.tsx +17 -0
  64. package/src/tokens/tokens.ts +3 -0
  65. package/dist/chunk-MAHPAP65.js.map +0 -1
@@ -1,24 +1,1587 @@
1
1
  "use client";
2
2
 
3
- import * as React from "react";
4
- import { Slider } from "radix-ui";
3
+ import {
4
+ forwardRef,
5
+ useRef,
6
+ useState,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useCallback,
10
+ useMemo,
11
+ type CSSProperties,
12
+ type HTMLAttributes,
13
+ } from "react";
14
+ import {
15
+ motion,
16
+ useMotionValue,
17
+ useTransform,
18
+ animate,
19
+ AnimatePresence,
20
+ type MotionValue,
21
+ } from "framer-motion";
22
+ import * as SliderPrimitive from "@radix-ui/react-slider";
5
23
  import { cn } from "../lib/utils";
24
+ import { spring } from "../lib/springs";
25
+ import { fontWeights } from "../lib/font-weight";
26
+ import { useShape } from "../lib/shape-context";
6
27
 
7
- const SliderRoot = React.forwardRef<
8
- React.ComponentRef<typeof Slider.Root>,
9
- React.ComponentPropsWithoutRef<typeof Slider.Root>
10
- >(({ className, ...props }, ref) => (
11
- <Slider.Root
12
- ref={ref}
13
- className={cn("relative flex w-full touch-none select-none items-center", className)}
14
- {...props}
15
- >
16
- <Slider.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted">
17
- <Slider.Range className="absolute h-full bg-brand-primary" />
18
- </Slider.Track>
19
- <Slider.Thumb className="relative block size-4 rounded-full border-2 border-brand-primary bg-card shadow-natural transition-colors after:absolute after:left-1/2 after:top-1/2 after:size-10 after:-translate-x-1/2 after:-translate-y-1/2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/30 disabled:pointer-events-none disabled:opacity-50" />
20
- </Slider.Root>
21
- ));
22
- SliderRoot.displayName = "Slider";
23
-
24
- export { SliderRoot as Slider };
28
+ // ---------------------------------------------------------------------------
29
+ // Types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ type SliderValue = number | [number, number];
33
+ type ValuePosition = "left" | "right" | "top" | "bottom" | "tooltip";
34
+
35
+ interface SliderProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> {
36
+ value?: SliderValue | number[];
37
+ defaultValue?: number[];
38
+ onChange?: (value: SliderValue) => void;
39
+ onValueChange?: (value: number[]) => void;
40
+ min?: number;
41
+ max?: number;
42
+ step?: number;
43
+ showSteps?: boolean;
44
+ showValue?: boolean;
45
+ valuePosition?: ValuePosition;
46
+ formatValue?: (v: number) => string;
47
+ label?: string;
48
+ disabled?: boolean;
49
+ trackClassName?: string;
50
+ trackStyle?: CSSProperties;
51
+ fillClassName?: string;
52
+ fillStyle?: CSSProperties;
53
+ hideFill?: boolean;
54
+ thumbColor?: string;
55
+ thumbBorderColor?: string;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Constants
60
+ // ---------------------------------------------------------------------------
61
+
62
+ const THUMB_SIZE = 20;
63
+ const THUMB_SIZE_REST = 16;
64
+ const TRACK_BG_HEIGHT = 18;
65
+ const DOT_SIZE = 4;
66
+ const PIP_SIZE = 5;
67
+ // Inset track BG so its rounded-end centers align with thumb centers at min/max
68
+ const TRACK_INSET = (THUMB_SIZE - TRACK_BG_HEIGHT) / 2;
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Helpers
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function valueToPixel(v: number, min: number, max: number, trackWidth: number): number {
75
+ if (max === min) return 0;
76
+ const usable = trackWidth - THUMB_SIZE;
77
+ return ((v - min) / (max - min)) * usable;
78
+ }
79
+
80
+ function pixelToValue(
81
+ px: number,
82
+ min: number,
83
+ max: number,
84
+ step: number,
85
+ trackWidth: number,
86
+ ): number {
87
+ const usable = trackWidth - THUMB_SIZE;
88
+ if (usable <= 0) return min;
89
+ const raw = (px / usable) * (max - min) + min;
90
+ const snapped = Math.round((raw - min) / step) * step + min;
91
+ return Math.max(min, Math.min(max, snapped));
92
+ }
93
+
94
+ function toRadixValue(value: SliderValue): number[] {
95
+ return Array.isArray(value) ? value : [value];
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // ValueDisplay (internal)
100
+ // ---------------------------------------------------------------------------
101
+
102
+ interface ValueDisplayProps {
103
+ values: number[];
104
+ editingIndex: number | null;
105
+ onStartEdit: (index: number) => void;
106
+ onCommitEdit: (index: number, v: number) => void;
107
+ onCancelEdit: () => void;
108
+ min: number;
109
+ max: number;
110
+ step: number;
111
+ formatValue: (v: number) => string;
112
+ label?: string;
113
+ isRange: boolean;
114
+ isInteracting: boolean;
115
+ }
116
+
117
+ function ValueDisplay({
118
+ values,
119
+ editingIndex,
120
+ onStartEdit,
121
+ onCommitEdit,
122
+ onCancelEdit,
123
+ min,
124
+ max,
125
+ step,
126
+ formatValue,
127
+ label,
128
+ isRange,
129
+ isInteracting,
130
+ }: ValueDisplayProps) {
131
+ const shape = useShape();
132
+ const [inputValue, setInputValue] = useState("");
133
+ const inputRef = useRef<HTMLInputElement>(null);
134
+
135
+ useEffect(() => {
136
+ if (editingIndex !== null) {
137
+ setInputValue(String(values[editingIndex]));
138
+ requestAnimationFrame(() => inputRef.current?.select());
139
+ }
140
+ }, [editingIndex]);
141
+
142
+ const commitEdit = useCallback(
143
+ (index: number) => {
144
+ const parsed = parseFloat(inputValue);
145
+ if (!isNaN(parsed)) {
146
+ const clamped = Math.max(min, Math.min(max, parsed));
147
+ const snapped = Math.round((clamped - min) / step) * step + min;
148
+ onCommitEdit(index, snapped);
149
+ } else {
150
+ onCancelEdit();
151
+ }
152
+ },
153
+ [inputValue, min, max, step, onCommitEdit, onCancelEdit],
154
+ );
155
+
156
+ const renderValue = (index: number) => {
157
+ if (editingIndex === index) {
158
+ return (
159
+ <span className="inline-grid text-[13px]">
160
+ {/* Ghost for layout stability — widest possible value */}
161
+ <span
162
+ className="col-start-1 row-start-1 invisible"
163
+ style={{ fontVariationSettings: fontWeights.medium }}
164
+ aria-hidden="true"
165
+ >
166
+ {label ? `${label}: ` : ""}
167
+ {formatValue(max)}
168
+ </span>
169
+ <span className="col-start-1 row-start-1 flex items-center gap-1">
170
+ {label && <span className="text-muted-foreground">{label}:</span>}
171
+ <input
172
+ ref={inputRef}
173
+ type="number"
174
+ value={inputValue}
175
+ min={min}
176
+ max={max}
177
+ step={step}
178
+ onChange={(e) => setInputValue(e.target.value)}
179
+ onBlur={() => commitEdit(index)}
180
+ onKeyDown={(e) => {
181
+ if (e.key === "Enter") commitEdit(index);
182
+ if (e.key === "Escape") onCancelEdit();
183
+ }}
184
+ aria-label={`Edit slider value${isRange ? (index === 0 ? " (start)" : " (end)") : ""}`}
185
+ className={cn(
186
+ "w-[5ch] bg-transparent text-foreground outline-none border-b border-border text-center",
187
+ shape.input,
188
+ )}
189
+ style={{ fontVariationSettings: fontWeights.medium }}
190
+ />
191
+ </span>
192
+ </span>
193
+ );
194
+ }
195
+
196
+ return (
197
+ <span className="cursor-text select-none" onClick={() => onStartEdit(index)}>
198
+ {formatValue(values[index])}
199
+ </span>
200
+ );
201
+ };
202
+
203
+ const widestValue = isRange
204
+ ? `${label ? `${label}: ` : ""}${formatValue(max)} — ${formatValue(max)}`
205
+ : `${label ? `${label}: ` : ""}${formatValue(max)}`;
206
+
207
+ return (
208
+ <span
209
+ className={cn(
210
+ "inline-grid shrink-0 text-[13px] leading-none text-muted-foreground transition-[font-variation-settings] duration-100",
211
+ "tabular-nums",
212
+ )}
213
+ style={{
214
+ fontVariationSettings: isInteracting ? fontWeights.medium : fontWeights.normal,
215
+ }}
216
+ >
217
+ {/* Invisible ghost — reserves width of widest possible value */}
218
+ <span
219
+ className="col-start-1 row-start-1 invisible whitespace-nowrap"
220
+ style={{ fontVariationSettings: fontWeights.medium }}
221
+ aria-hidden="true"
222
+ >
223
+ {widestValue}
224
+ </span>
225
+ <span className="col-start-1 row-start-1 whitespace-nowrap">
226
+ {label && editingIndex === null && <span className="text-muted-foreground">{label}: </span>}
227
+ {isRange ? (
228
+ <>
229
+ <span className="sr-only">
230
+ {values[0]} - {values[1]}
231
+ </span>
232
+ {renderValue(0)}
233
+ <span className="mx-1 text-muted-foreground/50" aria-hidden="true">
234
+ -
235
+ </span>
236
+ {renderValue(1)}
237
+ </>
238
+ ) : (
239
+ renderValue(0)
240
+ )}
241
+ </span>
242
+ </span>
243
+ );
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // TooltipValue (internal)
248
+ // ---------------------------------------------------------------------------
249
+
250
+ interface TooltipValueProps {
251
+ value: number;
252
+ formatValue: (v: number) => string;
253
+ motionX: MotionValue<number>;
254
+ }
255
+
256
+ function TooltipValue({ value, formatValue, motionX }: TooltipValueProps) {
257
+ const shape = useShape();
258
+ const tooltipX = useTransform(motionX, (x) => x + THUMB_SIZE / 2);
259
+ return (
260
+ <motion.div
261
+ className="absolute -translate-x-1/2 pointer-events-none z-20"
262
+ style={{
263
+ x: tooltipX,
264
+ top: -16,
265
+ }}
266
+ initial={{ opacity: 0, y: 4 }}
267
+ animate={{ opacity: 1, y: 0 }}
268
+ exit={{ opacity: 0, y: 4, transition: spring.fast.exit }}
269
+ transition={spring.fast}
270
+ >
271
+ <span
272
+ className={cn(
273
+ "text-[12px] text-background tabular-nums whitespace-nowrap bg-foreground px-2 py-1",
274
+ shape.bg,
275
+ )}
276
+ style={{ fontVariationSettings: fontWeights.medium }}
277
+ >
278
+ {formatValue(value)}
279
+ </span>
280
+ </motion.div>
281
+ );
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Slider
286
+ // ---------------------------------------------------------------------------
287
+
288
+ const Slider = forwardRef<HTMLDivElement, SliderProps>(
289
+ (
290
+ {
291
+ value,
292
+ defaultValue,
293
+ onChange,
294
+ onValueChange,
295
+ min = 0,
296
+ max = 100,
297
+ step = 1,
298
+ showSteps = false,
299
+ showValue = true,
300
+ valuePosition = "left",
301
+ formatValue = String,
302
+ label,
303
+ disabled = false,
304
+ trackClassName,
305
+ trackStyle,
306
+ fillClassName,
307
+ fillStyle,
308
+ hideFill = false,
309
+ thumbColor,
310
+ thumbBorderColor,
311
+ className,
312
+ ...props
313
+ },
314
+ ref,
315
+ ) => {
316
+ const [uncontrolledValue, setUncontrolledValue] = useState<SliderValue>(() => {
317
+ const initial = defaultValue ?? [min];
318
+ return initial.length > 1
319
+ ? ([initial[0] ?? min, initial[1] ?? max] as [number, number])
320
+ : (initial[0] ?? min);
321
+ });
322
+ const resolvedValue =
323
+ value === undefined
324
+ ? uncontrolledValue
325
+ : Array.isArray(value)
326
+ ? value.length > 1
327
+ ? ([value[0] ?? min, value[1] ?? max] as [number, number])
328
+ : (value[0] ?? min)
329
+ : value;
330
+ const emitResolvedValue = useCallback(
331
+ (nextValue: SliderValue) => {
332
+ if (value === undefined) setUncontrolledValue(nextValue);
333
+ onChange?.(nextValue);
334
+ onValueChange?.(toRadixValue(nextValue));
335
+ },
336
+ [value, onChange, onValueChange],
337
+ );
338
+ const isRange = Array.isArray(resolvedValue);
339
+ const values = toRadixValue(resolvedValue);
340
+ const shape = useShape();
341
+
342
+ // --- Refs ---
343
+ const trackRef = useRef<HTMLDivElement>(null);
344
+ const trackWidthRef = useRef(0);
345
+ const dragging = useRef(false);
346
+ const activeDragThumb = useRef<number>(0);
347
+ const valuesRef = useRef(values);
348
+ const minRef = useRef(min);
349
+ const maxRef = useRef(max);
350
+ valuesRef.current = values;
351
+ minRef.current = min;
352
+ maxRef.current = max;
353
+
354
+ // --- State ---
355
+ const [isHovered, setIsHovered] = useState(false);
356
+ const [isPressed, setIsPressed] = useState(false);
357
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
358
+ const [hoverPreview, setHoverPreview] = useState<{
359
+ left: number;
360
+ width: number;
361
+ snappedValue: number;
362
+ cursorX: number;
363
+ } | null>(null);
364
+ const [focusedThumb, setFocusedThumb] = useState<number | null>(null);
365
+ const [showHoverTooltip, setShowHoverTooltip] = useState(false);
366
+ const hoverDelayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
367
+
368
+ // Show hover tooltip after 100ms delay
369
+ useEffect(() => {
370
+ if (isHovered) {
371
+ hoverDelayRef.current = setTimeout(() => setShowHoverTooltip(true), 100);
372
+ } else {
373
+ if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current);
374
+ setShowHoverTooltip(false);
375
+ }
376
+ return () => {
377
+ if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current);
378
+ };
379
+ }, [isHovered]);
380
+
381
+ // --- Motion values ---
382
+ const motionX0 = useMotionValue(0);
383
+ const motionX1 = useMotionValue(0);
384
+
385
+ // --- Derived motion values for fill ---
386
+ const fillLeft = useTransform(motionX0, (x) =>
387
+ isRange ? x + THUMB_SIZE / 2 - TRACK_INSET : 0,
388
+ );
389
+ const fillWidthSingle = useTransform(motionX0, (x) => x + THUMB_SIZE / 2 - TRACK_INSET);
390
+ const fillWidthRange = useTransform(
391
+ [motionX0, motionX1] as MotionValue<number>[],
392
+ ([x0, x1]) => (x1 as number) - (x0 as number),
393
+ );
394
+ const fillWidth = isRange ? fillWidthRange : fillWidthSingle;
395
+
396
+ // --- Step dots mask (hides dots on filled side, like SliderComfortable pips) ---
397
+ const stepDotsMaskSingle = useTransform(motionX0, (x) => {
398
+ const edge = x + THUMB_SIZE / 2;
399
+ return `linear-gradient(to right, transparent ${edge}px, black ${edge + 2}px)`;
400
+ });
401
+ const stepDotsMaskRange = useTransform(
402
+ [motionX0, motionX1] as MotionValue<number>[],
403
+ ([x0, x1]) => {
404
+ const left = (x0 as number) + THUMB_SIZE / 2;
405
+ const right = (x1 as number) + THUMB_SIZE / 2;
406
+ return `linear-gradient(to right, black ${left - 2}px, transparent ${left}px, transparent ${right}px, black ${right + 2}px)`;
407
+ },
408
+ );
409
+ const stepDotsMask = isRange ? stepDotsMaskRange : stepDotsMaskSingle;
410
+
411
+ // --- Hover preview computation ---
412
+ const computeHoverPreview = useCallback(
413
+ (cursorX: number, trackWidth: number) => {
414
+ // cursorX and trackWidth are in layout space (offsetWidth-relative),
415
+ // unaffected by ancestor CSS transforms. THUMB_SIZE / TRACK_INSET are
416
+ // also layout-space, so the math below is consistent end-to-end.
417
+ const usable = trackWidth - THUMB_SIZE;
418
+ const rawPx = cursorX - THUMB_SIZE / 2;
419
+ const clampedPx = Math.max(0, Math.min(usable, rawPx));
420
+ const rawVal = usable > 0 ? (clampedPx / usable) * (max - min) + min : min;
421
+ const snappedVal = Math.max(
422
+ min,
423
+ Math.min(max, Math.round((rawVal - min) / step) * step + min),
424
+ );
425
+ const snappedPercent = max === min ? 0 : (snappedVal - min) / (max - min);
426
+ const snappedX = THUMB_SIZE / 2 + snappedPercent * usable;
427
+
428
+ // Find nearest thumb center
429
+ const c0 = motionX0.get() + THUMB_SIZE / 2;
430
+ const c1 = motionX1.get() + THUMB_SIZE / 2;
431
+ const nearestIdx = isRange
432
+ ? Math.abs(snappedX - c0) <= Math.abs(snappedX - c1)
433
+ ? 0
434
+ : 1
435
+ : 0;
436
+ const nearest = nearestIdx === 0 ? c0 : c1;
437
+
438
+ // Extend hover bar to track edges at extremes so there's no gap
439
+ const edgeX = snappedVal === min ? 0 : snappedVal === max ? trackWidth : snappedX;
440
+ const left = Math.min(nearest, edgeX);
441
+ const width = Math.abs(edgeX - nearest);
442
+ setHoverPreview({ left, width, snappedValue: snappedVal, cursorX: snappedX });
443
+ },
444
+ [min, max, step, isRange, motionX0, motionX1],
445
+ );
446
+
447
+ // --- Initial sync (before paint) ---
448
+ const initialSyncDone = useRef(false);
449
+ const [ready, setReady] = useState(false);
450
+ useLayoutEffect(() => {
451
+ const el = trackRef.current;
452
+ if (!el || initialSyncDone.current) return;
453
+ const w = el.offsetWidth;
454
+ trackWidthRef.current = w;
455
+ const px0 = valueToPixel(values[0], min, max, w);
456
+ motionX0.set(px0);
457
+ if (isRange && values[1] !== undefined) {
458
+ const px1 = valueToPixel(values[1], min, max, w);
459
+ motionX1.set(px1);
460
+ }
461
+ initialSyncDone.current = true;
462
+ setReady(true);
463
+ }, []);
464
+
465
+ // --- Track width measurement (resize only) ---
466
+ useEffect(() => {
467
+ const el = trackRef.current;
468
+ if (!el) return;
469
+ const ro = new ResizeObserver(([entry]) => {
470
+ const w = entry.contentRect.width;
471
+ trackWidthRef.current = w;
472
+ if (!dragging.current && initialSyncDone.current) {
473
+ const v = valuesRef.current;
474
+ const mn = minRef.current;
475
+ const mx = maxRef.current;
476
+ const px0 = valueToPixel(v[0], mn, mx, w);
477
+ animate(motionX0, px0, spring.moderate);
478
+ if (isRange && v[1] !== undefined) {
479
+ const px1 = valueToPixel(v[1], mn, mx, w);
480
+ animate(motionX1, px1, spring.moderate);
481
+ }
482
+ }
483
+ });
484
+ ro.observe(el);
485
+ return () => ro.disconnect();
486
+ }, [isRange, motionX0, motionX1]);
487
+
488
+ // --- Sync motion values on value change (keyboard, programmatic) ---
489
+ useEffect(() => {
490
+ if (!initialSyncDone.current) return;
491
+ if (dragging.current) return;
492
+ const tw = trackWidthRef.current;
493
+ if (tw <= 0) return;
494
+ const px0 = valueToPixel(values[0], min, max, tw);
495
+ animate(motionX0, px0, spring.moderate);
496
+ if (isRange && values[1] !== undefined) {
497
+ const px1 = valueToPixel(values[1], min, max, tw);
498
+ animate(motionX1, px1, spring.moderate);
499
+ }
500
+ }, [values, min, max, isRange, motionX0, motionX1]);
501
+
502
+ // --- Range crossing prevention ---
503
+ const clampForRange = useCallback(
504
+ (px: number, thumbIndex: number): number => {
505
+ if (!isRange) return px;
506
+ if (thumbIndex === 0) {
507
+ return Math.min(px, motionX1.get() - THUMB_SIZE * 0.5);
508
+ } else {
509
+ return Math.max(px, motionX0.get() + THUMB_SIZE * 0.5);
510
+ }
511
+ },
512
+ [isRange, motionX0, motionX1],
513
+ );
514
+
515
+ // --- Emit value change ---
516
+ const emitChange = useCallback(
517
+ (thumbIndex: number, newValue: number) => {
518
+ if (isRange) {
519
+ const newValues: [number, number] = [...(values as [number, number])];
520
+ newValues[thumbIndex] = newValue;
521
+ emitResolvedValue(newValues);
522
+ } else {
523
+ emitResolvedValue(newValue);
524
+ }
525
+ },
526
+ [isRange, values, emitResolvedValue],
527
+ );
528
+
529
+ // --- Pointer handlers on track ---
530
+ const handlePointerDown = useCallback(
531
+ (e: React.PointerEvent<HTMLDivElement>) => {
532
+ if (disabled) return;
533
+ if (e.pointerType === "mouse" && e.button !== 0) return;
534
+ e.preventDefault();
535
+ e.stopPropagation(); // Prevent Radix from also handling the drag
536
+
537
+ const trackEl = trackRef.current;
538
+ if (!trackEl) return;
539
+ const trackRect = trackEl.getBoundingClientRect();
540
+ const layoutWidth = trackEl.offsetWidth;
541
+ if (layoutWidth <= 0 || trackRect.width <= 0) return;
542
+ // Normalize cursor to layout space so it matches motionX (which is
543
+ // rendered as a CSS-pixel transform), even under ancestor CSS scale.
544
+ const scale = trackRect.width / layoutWidth;
545
+ const localX = (e.clientX - trackRect.left) / scale - THUMB_SIZE / 2;
546
+ const clamped = Math.max(0, Math.min(layoutWidth - THUMB_SIZE, localX));
547
+
548
+ // Determine which thumb to drag
549
+ if (isRange) {
550
+ const dist0 = Math.abs(clamped - motionX0.get());
551
+ const dist1 = Math.abs(clamped - motionX1.get());
552
+ activeDragThumb.current = dist0 <= dist1 ? 0 : 1;
553
+ } else {
554
+ activeDragThumb.current = 0;
555
+ }
556
+
557
+ dragging.current = true;
558
+ setIsPressed(true);
559
+
560
+ const motionX = activeDragThumb.current === 0 ? motionX0 : motionX1;
561
+
562
+ // Snap to step grid immediately
563
+ const snappedValue = pixelToValue(clamped, min, max, step, layoutWidth);
564
+ const snappedPx = valueToPixel(snappedValue, min, max, layoutWidth);
565
+
566
+ // Clamp for range crossing
567
+ const finalPx = clampForRange(snappedPx, activeDragThumb.current);
568
+ // Spring-animate thumb to clicked position
569
+ animate(motionX, finalPx, spring.moderate);
570
+
571
+ // Update value
572
+ const finalValue = pixelToValue(finalPx, min, max, step, layoutWidth);
573
+ emitChange(activeDragThumb.current, finalValue);
574
+
575
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
576
+ },
577
+ [disabled, isRange, min, max, step, motionX0, motionX1, clampForRange, emitChange],
578
+ );
579
+
580
+ const handlePointerMove = useCallback(
581
+ (e: React.PointerEvent<HTMLDivElement>) => {
582
+ if (!dragging.current) return;
583
+ e.stopPropagation();
584
+ const trackEl = trackRef.current;
585
+ if (!trackEl) return;
586
+ const trackRect = trackEl.getBoundingClientRect();
587
+ const layoutWidth = trackEl.offsetWidth;
588
+ if (layoutWidth <= 0 || trackRect.width <= 0) return;
589
+ const scale = trackRect.width / layoutWidth;
590
+ const localX = (e.clientX - trackRect.left) / scale - THUMB_SIZE / 2;
591
+ const clamped = Math.max(0, Math.min(layoutWidth - THUMB_SIZE, localX));
592
+
593
+ const motionX = activeDragThumb.current === 0 ? motionX0 : motionX1;
594
+
595
+ // Snap to step grid during drag
596
+ const snappedValue = pixelToValue(clamped, min, max, step, layoutWidth);
597
+ const snappedPx = valueToPixel(snappedValue, min, max, layoutWidth);
598
+ const finalPx = clampForRange(snappedPx, activeDragThumb.current);
599
+ motionX.set(finalPx);
600
+
601
+ const finalValue = pixelToValue(finalPx, min, max, step, layoutWidth);
602
+ emitChange(activeDragThumb.current, finalValue);
603
+ },
604
+ [min, max, step, motionX0, motionX1, clampForRange, emitChange],
605
+ );
606
+
607
+ const handlePointerUp = useCallback(() => {
608
+ if (!dragging.current) return;
609
+ dragging.current = false;
610
+ setIsPressed(false);
611
+ setHoverPreview(null);
612
+
613
+ // Spring settle to final quantized position
614
+ const tw = trackWidthRef.current;
615
+ const motionX = activeDragThumb.current === 0 ? motionX0 : motionX1;
616
+ const currentPx = motionX.get();
617
+ const snapped = pixelToValue(currentPx, min, max, step, tw);
618
+ const snappedPx = valueToPixel(snapped, min, max, tw);
619
+ animate(motionX, snappedPx, spring.moderate);
620
+ }, [min, max, step, motionX0, motionX1]);
621
+
622
+ // --- Radix keyboard handler ---
623
+ const handleRadixChange = useCallback(
624
+ (newValues: number[]) => {
625
+ if (dragging.current) return;
626
+ if (isRange) {
627
+ emitResolvedValue(newValues as [number, number]);
628
+ } else {
629
+ emitResolvedValue(newValues[0] ?? min);
630
+ }
631
+ },
632
+ [isRange, emitResolvedValue, min],
633
+ );
634
+
635
+ // --- Click-to-edit handlers ---
636
+ const handleStartEdit = useCallback((index: number) => {
637
+ setEditingIndex(index);
638
+ }, []);
639
+
640
+ const handleCommitEdit = useCallback(
641
+ (index: number, v: number) => {
642
+ emitChange(index, v);
643
+ setEditingIndex(null);
644
+ },
645
+ [emitChange],
646
+ );
647
+
648
+ const handleCancelEdit = useCallback(() => {
649
+ setEditingIndex(null);
650
+ }, []);
651
+
652
+ // --- Step dots ---
653
+ const stepDots = useMemo(
654
+ () =>
655
+ showSteps
656
+ ? Array.from({ length: Math.round((max - min) / step) + 1 }, (_, i) => {
657
+ const v = min + i * step;
658
+ const percent = (v - min) / (max - min);
659
+ return { value: v, percent };
660
+ })
661
+ : [],
662
+ [showSteps, min, max, step],
663
+ );
664
+
665
+ // --- Interaction state for tooltip ---
666
+ const isInteracting = isHovered || isPressed;
667
+
668
+ // --- Value display component ---
669
+ const valueDisplay = showValue && valuePosition !== "tooltip" && (
670
+ <ValueDisplay
671
+ values={values}
672
+ editingIndex={editingIndex}
673
+ onStartEdit={handleStartEdit}
674
+ onCommitEdit={handleCommitEdit}
675
+ onCancelEdit={handleCancelEdit}
676
+ min={min}
677
+ max={max}
678
+ step={step}
679
+ formatValue={formatValue}
680
+ {...(label !== undefined ? { label } : {})}
681
+ isRange={isRange}
682
+ isInteracting={isInteracting}
683
+ />
684
+ );
685
+
686
+ // --- Render visual thumb (not Radix — purely visual) ---
687
+ const renderVisualThumb = (index: number) => {
688
+ const motionX = index === 0 ? motionX0 : motionX1;
689
+ return (
690
+ <motion.span
691
+ key={`visual-thumb-${index}`}
692
+ className="flex items-center justify-center pointer-events-none"
693
+ style={{
694
+ width: THUMB_SIZE,
695
+ height: THUMB_SIZE,
696
+ marginTop: -THUMB_SIZE / 2,
697
+ x: motionX,
698
+ position: "absolute",
699
+ top: "50%",
700
+ left: 0,
701
+ zIndex: 10,
702
+ }}
703
+ initial={false}
704
+ >
705
+ <motion.span
706
+ className="block rounded-full"
707
+ initial={false}
708
+ animate={{
709
+ width: THUMB_SIZE_REST,
710
+ height: THUMB_SIZE_REST,
711
+ }}
712
+ transition={spring.fast}
713
+ style={{
714
+ backgroundColor: thumbColor ?? "white",
715
+ boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
716
+ border: thumbBorderColor ? `1px solid ${thumbBorderColor}` : undefined,
717
+ }}
718
+ />
719
+ {/* Focus ring */}
720
+ <motion.span
721
+ className="absolute rounded-full border border-[#6B97FF] pointer-events-none"
722
+ initial={false}
723
+ animate={{
724
+ opacity: focusedThumb === index ? 1 : 0,
725
+ width: THUMB_SIZE + 4,
726
+ height: THUMB_SIZE + 4,
727
+ }}
728
+ transition={spring.fast}
729
+ />
730
+ </motion.span>
731
+ );
732
+ };
733
+
734
+ return (
735
+ <div
736
+ ref={ref}
737
+ className={cn(
738
+ "flex flex-col gap-0 w-full select-none touch-none overflow-visible",
739
+ valuePosition === "left" || valuePosition === "right"
740
+ ? "flex-row items-center gap-2 mb-2"
741
+ : "flex-col",
742
+ disabled && "opacity-50 pointer-events-none",
743
+ className,
744
+ )}
745
+ {...props}
746
+ >
747
+ {/* Top / Left value */}
748
+ {(valuePosition === "top" || valuePosition === "left") && valueDisplay}
749
+
750
+ {/* Track area */}
751
+ <div
752
+ className="relative flex-1 overflow-visible"
753
+ style={{
754
+ height:
755
+ valuePosition === "left" || valuePosition === "right"
756
+ ? THUMB_SIZE + 16
757
+ : THUMB_SIZE + (valuePosition === "tooltip" ? 16 : 0),
758
+ paddingTop: valuePosition === "tooltip" ? 16 : 0,
759
+ }}
760
+ onPointerEnter={() => setIsHovered(true)}
761
+ onPointerLeave={() => {
762
+ setIsHovered(false);
763
+ setHoverPreview(null);
764
+ }}
765
+ onMouseMove={(e) => {
766
+ if (dragging.current) return;
767
+ const trackEl = trackRef.current;
768
+ if (!trackEl) return;
769
+ const trackRect = trackEl.getBoundingClientRect();
770
+ const layoutWidth = trackEl.offsetWidth;
771
+ if (layoutWidth <= 0 || trackRect.width <= 0) return;
772
+ // Normalize to layout space so the formula's THUMB_SIZE / TRACK_INSET
773
+ // constants (layout px) match the cursor's coordinate space, even
774
+ // when an ancestor applies a CSS scale transform (e.g. /demo).
775
+ const scale = trackRect.width / layoutWidth;
776
+ const layoutX = (e.clientX - trackRect.left) / scale;
777
+ const clamped = Math.max(0, Math.min(layoutWidth, layoutX));
778
+ computeHoverPreview(clamped, layoutWidth);
779
+ }}
780
+ >
781
+ {/* Tooltip values */}
782
+ {showValue && valuePosition === "tooltip" && (
783
+ <AnimatePresence>
784
+ {isInteracting && (
785
+ <TooltipValue
786
+ key="tooltip-0"
787
+ value={values[0]}
788
+ formatValue={formatValue}
789
+ motionX={motionX0}
790
+ />
791
+ )}
792
+ {isInteracting && isRange && values[1] !== undefined && (
793
+ <TooltipValue
794
+ key="tooltip-1"
795
+ value={values[1]}
796
+ formatValue={formatValue}
797
+ motionX={motionX1}
798
+ />
799
+ )}
800
+ </AnimatePresence>
801
+ )}
802
+
803
+ {/* Radix Slider — invisible, provides ARIA + keyboard nav */}
804
+ <SliderPrimitive.Root
805
+ value={values}
806
+ onValueChange={handleRadixChange}
807
+ min={min}
808
+ max={max}
809
+ step={step}
810
+ disabled={disabled}
811
+ aria-label={label}
812
+ className="absolute inset-0 opacity-0 pointer-events-none"
813
+ style={{ height: THUMB_SIZE }}
814
+ >
815
+ <SliderPrimitive.Track className="w-full h-full">
816
+ <SliderPrimitive.Range />
817
+ </SliderPrimitive.Track>
818
+ <SliderPrimitive.Thumb
819
+ className="block outline-none"
820
+ style={{ width: THUMB_SIZE, height: THUMB_SIZE }}
821
+ onFocus={(e) => {
822
+ if (e.currentTarget.matches(":focus-visible")) setFocusedThumb(0);
823
+ }}
824
+ onBlur={() => setFocusedThumb((prev) => (prev === 0 ? null : prev))}
825
+ />
826
+ {isRange && (
827
+ <SliderPrimitive.Thumb
828
+ className="block outline-none"
829
+ style={{ width: THUMB_SIZE, height: THUMB_SIZE }}
830
+ onFocus={(e) => {
831
+ if (e.currentTarget.matches(":focus-visible")) setFocusedThumb(1);
832
+ }}
833
+ onBlur={() => setFocusedThumb((prev) => (prev === 1 ? null : prev))}
834
+ />
835
+ )}
836
+ </SliderPrimitive.Root>
837
+
838
+ {/* Visual track with pointer handlers */}
839
+ <div
840
+ ref={trackRef}
841
+ className="relative w-full cursor-ew-resize py-2"
842
+ style={{ height: THUMB_SIZE + 16, opacity: ready ? 1 : 0 }}
843
+ onPointerDown={handlePointerDown}
844
+ onPointerMove={handlePointerMove}
845
+ onPointerUp={handlePointerUp}
846
+ >
847
+ {/* Extended hit area — 8px beyond each edge */}
848
+ <div
849
+ className="absolute cursor-ew-resize"
850
+ style={{ left: -8, right: -8, top: 0, bottom: 0 }}
851
+ onPointerDown={handlePointerDown}
852
+ onPointerMove={handlePointerMove}
853
+ onPointerUp={handlePointerUp}
854
+ />
855
+ {/* Hover value tooltip */}
856
+ <AnimatePresence>
857
+ {hoverPreview && showHoverTooltip && !isPressed && valuePosition !== "tooltip" && (
858
+ <motion.div
859
+ key="hover-tooltip"
860
+ className="absolute -translate-x-1/2 pointer-events-none z-20"
861
+ initial={{ opacity: 0, y: 4 }}
862
+ animate={{ opacity: 1, y: 0 }}
863
+ exit={{ opacity: 0, y: 4, transition: spring.fast.exit }}
864
+ transition={spring.fast}
865
+ style={{
866
+ left: hoverPreview.cursorX,
867
+ top: -20,
868
+ }}
869
+ >
870
+ <span
871
+ className={cn(
872
+ "text-[12px] text-background tabular-nums whitespace-nowrap bg-foreground px-2 py-1",
873
+ shape.bg,
874
+ )}
875
+ style={{ fontVariationSettings: fontWeights.medium }}
876
+ >
877
+ {formatValue(hoverPreview.snappedValue)}
878
+ </span>
879
+ </motion.div>
880
+ )}
881
+ </AnimatePresence>
882
+
883
+ {/* Track background */}
884
+ <motion.div
885
+ className={cn(
886
+ "absolute border border-border overflow-hidden rounded-full",
887
+ trackClassName,
888
+ )}
889
+ initial={false}
890
+ animate={{
891
+ height: TRACK_BG_HEIGHT,
892
+ top: 8 + (THUMB_SIZE - TRACK_BG_HEIGHT) / 2,
893
+ }}
894
+ transition={spring.fast}
895
+ style={
896
+ {
897
+ left: TRACK_INSET,
898
+ right: TRACK_INSET,
899
+ backgroundColor: "transparent",
900
+ ...trackStyle,
901
+ } as any
902
+ }
903
+ >
904
+ {/* Filled range */}
905
+ {!hideFill && (
906
+ <motion.div
907
+ className={cn("absolute h-full bg-selected/50 dark:bg-accent/40", fillClassName)}
908
+ style={
909
+ {
910
+ left: fillLeft,
911
+ width: fillWidth,
912
+ ...fillStyle,
913
+ } as any
914
+ }
915
+ />
916
+ )}
917
+
918
+ {/* Hover preview */}
919
+ <motion.div
920
+ className="absolute h-full pointer-events-none z-[2]"
921
+ initial={false}
922
+ animate={{
923
+ opacity: hoverPreview && !isPressed ? 1 : 0,
924
+ }}
925
+ transition={{
926
+ opacity: { duration: 0.15 },
927
+ }}
928
+ style={{
929
+ left: hoverPreview ? hoverPreview.left - TRACK_INSET : 0,
930
+ width: hoverPreview ? hoverPreview.width : 0,
931
+ borderRadius:
932
+ hoverPreview && hoverPreview.cursorX > hoverPreview.left
933
+ ? "0 9999px 9999px 0"
934
+ : "9999px 0 0 9999px",
935
+ backgroundColor: "color-mix(in srgb, var(--color-accent) 40%, transparent)",
936
+ }}
937
+ />
938
+ </motion.div>
939
+
940
+ {/* Step dots — masked so filled side is hidden */}
941
+ {stepDots.length > 0 && (
942
+ <motion.div
943
+ className="absolute left-0 right-0 pointer-events-none"
944
+ style={{
945
+ top: 8 + (THUMB_SIZE - TRACK_BG_HEIGHT) / 2,
946
+ height: TRACK_BG_HEIGHT,
947
+ WebkitMaskImage: stepDotsMask,
948
+ maskImage: stepDotsMask,
949
+ }}
950
+ >
951
+ {stepDots.map(({ value: v, percent }) => (
952
+ <div
953
+ key={v}
954
+ className="absolute pointer-events-none flex items-center justify-center"
955
+ style={{
956
+ left: `calc(${THUMB_SIZE / 2}px + ${percent} * (100% - ${THUMB_SIZE}px))`,
957
+ top: "50%",
958
+ width: 0,
959
+ height: 0,
960
+ }}
961
+ >
962
+ <motion.div
963
+ className="rounded-full flex-shrink-0"
964
+ initial={false}
965
+ animate={{
966
+ width: isHovered ? DOT_SIZE * 1.25 : DOT_SIZE,
967
+ height: isHovered ? DOT_SIZE * 1.25 : DOT_SIZE,
968
+ }}
969
+ transition={spring.moderate}
970
+ style={{
971
+ backgroundColor: "var(--muted-foreground)",
972
+ opacity: 0.3,
973
+ }}
974
+ />
975
+ </div>
976
+ ))}
977
+ </motion.div>
978
+ )}
979
+
980
+ {/* Visual thumbs */}
981
+ {renderVisualThumb(0)}
982
+ {isRange && renderVisualThumb(1)}
983
+ </div>
984
+ </div>
985
+
986
+ {/* Bottom / Right value */}
987
+ {(valuePosition === "bottom" || valuePosition === "right") && valueDisplay}
988
+ </div>
989
+ );
990
+ },
991
+ );
992
+
993
+ Slider.displayName = "Slider";
994
+
995
+ // ---------------------------------------------------------------------------
996
+ // SliderComfortable
997
+ // ---------------------------------------------------------------------------
998
+
999
+ interface SliderComfortableProps extends Omit<
1000
+ HTMLAttributes<HTMLDivElement>,
1001
+ | "onChange"
1002
+ | "defaultValue"
1003
+ | "onDrag"
1004
+ | "onDragStart"
1005
+ | "onDragEnd"
1006
+ | "onDragOver"
1007
+ | "onAnimationStart"
1008
+ > {
1009
+ value: number;
1010
+ onChange: (value: number) => void;
1011
+ min?: number;
1012
+ max?: number;
1013
+ step?: number;
1014
+ variant?: "pips" | "scrubber";
1015
+ label?: string;
1016
+ formatValue?: (v: number) => string;
1017
+ disabled?: boolean;
1018
+ }
1019
+
1020
+ const SliderComfortable = forwardRef<HTMLDivElement, SliderComfortableProps>(
1021
+ (
1022
+ {
1023
+ value,
1024
+ onChange,
1025
+ min = 0,
1026
+ max = 100,
1027
+ step = 1,
1028
+ variant = "pips",
1029
+ label,
1030
+ formatValue = String,
1031
+ disabled = false,
1032
+ className,
1033
+ style,
1034
+ ...props
1035
+ },
1036
+ ref,
1037
+ ) => {
1038
+ const containerRef = useRef<HTMLDivElement>(null);
1039
+ const dragging = useRef(false);
1040
+ const handleDragging = useRef(false);
1041
+ const [isHovered, setIsHovered] = useState(false);
1042
+ const [isPressed, setIsPressed] = useState(false);
1043
+ const [isFocused, setIsFocused] = useState(false);
1044
+ const [hoverPreview, setHoverPreview] = useState<{
1045
+ left: number;
1046
+ width: number;
1047
+ snappedValue: number;
1048
+ cursorX: number;
1049
+ } | null>(null);
1050
+ const [showHoverTooltip, setShowHoverTooltip] = useState(false);
1051
+ const hoverDelayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1052
+ const shape = useShape();
1053
+
1054
+ // Show hover tooltip after 100ms delay
1055
+ useEffect(() => {
1056
+ if (isHovered) {
1057
+ hoverDelayRef.current = setTimeout(() => setShowHoverTooltip(true), 100);
1058
+ } else {
1059
+ if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current);
1060
+ setShowHoverTooltip(false);
1061
+ }
1062
+ return () => {
1063
+ if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current);
1064
+ };
1065
+ }, [isHovered]);
1066
+
1067
+ const mergedRef = useCallback(
1068
+ (el: HTMLDivElement | null) => {
1069
+ containerRef.current = el;
1070
+ if (typeof ref === "function") (ref as React.RefCallback<HTMLDivElement>)(el);
1071
+ else if (ref) (ref as React.RefObject<HTMLDivElement | null>).current = el;
1072
+ },
1073
+ [ref],
1074
+ );
1075
+
1076
+ const pipSteps = useMemo(
1077
+ () => Array.from({ length: Math.round((max - min) / step) + 1 }, (_, i) => min + i * step),
1078
+ [min, max, step],
1079
+ );
1080
+ const pipCount = pipSteps.length;
1081
+
1082
+ // Fill motion value
1083
+ const fillPercent = useMotionValue(
1084
+ max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min))),
1085
+ );
1086
+ // Small offset when value is at min so the handle line stays visible
1087
+ const zeroTarget = variant === "pips" ? 8 : 17;
1088
+ const zeroOffset = useMotionValue(value === min ? zeroTarget : 0);
1089
+
1090
+ const fillWidthStyle = useTransform(fillPercent, (p) => `${p * 100}%`);
1091
+ const handleLeftStyle = useTransform(
1092
+ [fillPercent, zeroOffset] as MotionValue<number>[],
1093
+ ([p, zo]) => `calc(${(p as number) * 100}% - 8px + ${zo as number}px)`,
1094
+ );
1095
+ const handleLineLeftStyle = useTransform(
1096
+ [fillPercent, zeroOffset] as MotionValue<number>[],
1097
+ ([p, zo]) => `calc(${(p as number) * 100}% - 9px + ${zo as number}px)`,
1098
+ );
1099
+ // Pips-specific: offset by px-3 (12px) padding so fill edge aligns with active pip center
1100
+ const pipsFillWidthStyle = useTransform(
1101
+ [fillPercent, zeroOffset] as MotionValue<number>[],
1102
+ ([p, zo]) =>
1103
+ `calc(${(p as number) * 100}% + ${20 - 20 * (p as number) - (zo as number) * 2.5}px)`,
1104
+ );
1105
+ const pipsHandleLineLeftStyle = useTransform(
1106
+ fillPercent,
1107
+ (p) => `calc(${p * 100}% + ${11 - 24 * p}px)`,
1108
+ );
1109
+ const pipsMaskStyle = useTransform(
1110
+ [fillPercent, zeroOffset] as MotionValue<number>[],
1111
+ ([p, zo]) => {
1112
+ const offset = 20 - 20 * (p as number) - (zo as number) * 2.5;
1113
+ return `linear-gradient(to right, transparent calc(${(p as number) * 100}% + ${offset}px), black calc(${(p as number) * 100}% + ${offset + 2}px))`;
1114
+ },
1115
+ );
1116
+
1117
+ // --- Hover preview computation ---
1118
+ const computeHoverPreview = useCallback(
1119
+ (clientX: number) => {
1120
+ const el = containerRef.current;
1121
+ if (!el) return;
1122
+ const rect = el.getBoundingClientRect();
1123
+ // Use clientWidth (padding box) — CSS % and absolute left/width are relative to it
1124
+ const w = el.clientWidth;
1125
+ if (w <= 0 || rect.width <= 0) return;
1126
+ // Normalize cursor to layout space so it matches `w` (layout, padding
1127
+ // box). offsetWidth is the layout border-box; the difference vs `w` is
1128
+ // the horizontal border contribution split across both sides.
1129
+ const scale = rect.width / el.offsetWidth;
1130
+ const borderLeftLayout = (el.offsetWidth - w) / 2;
1131
+ const visualX = clientX - rect.left;
1132
+ const layoutX = visualX / scale - borderLeftLayout;
1133
+ const clamped = Math.max(0, Math.min(w, layoutX));
1134
+
1135
+ // Snap to nearest step value
1136
+ let snappedVal: number;
1137
+ if (variant === "pips") {
1138
+ if (pipCount <= 1) return;
1139
+ const index = Math.max(
1140
+ 0,
1141
+ Math.min(pipCount - 1, Math.round((clamped / w) * (pipCount - 1))),
1142
+ );
1143
+ snappedVal = pipSteps[index];
1144
+ } else {
1145
+ const raw = min + (clamped / w) * (max - min);
1146
+ snappedVal = Math.max(min, Math.min(max, Math.round((raw - min) / step) * step + min));
1147
+ }
1148
+ const snappedPercent = max === min ? 0 : (snappedVal - min) / (max - min);
1149
+ const snappedX = snappedPercent * w;
1150
+
1151
+ // Current handle position — for pips, match the visual fill edge offset
1152
+ const currentPercent = fillPercent.get();
1153
+ let handleX: number;
1154
+ if (variant === "pips") {
1155
+ const zo = zeroOffset.get();
1156
+ handleX = currentPercent * w + (20 - 20 * currentPercent - zo * 2.5);
1157
+ } else {
1158
+ handleX = currentPercent * w;
1159
+ }
1160
+
1161
+ // Extend hover bar to container edges at extremes so there's no gap
1162
+ const edgeX = snappedVal === min ? 0 : snappedVal === max ? w : snappedX;
1163
+ const left = Math.min(handleX, edgeX);
1164
+ const width = Math.abs(edgeX - handleX);
1165
+ setHoverPreview({ left, width, snappedValue: snappedVal, cursorX: snappedX });
1166
+ },
1167
+ [variant, pipSteps, pipCount, min, max, step, fillPercent, zeroOffset],
1168
+ );
1169
+
1170
+ // Sync fill on programmatic value change
1171
+ useEffect(() => {
1172
+ if (dragging.current || handleDragging.current) return;
1173
+ const percent = max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min)));
1174
+ animate(fillPercent, percent, spring.fast);
1175
+ animate(zeroOffset, value === min ? zeroTarget : 0, spring.fast);
1176
+ }, [value, min, max, variant, fillPercent, zeroOffset, zeroTarget]);
1177
+
1178
+ const getValueFromX = useCallback(
1179
+ (clientX: number) => {
1180
+ const rect = containerRef.current?.getBoundingClientRect();
1181
+ if (!rect) return min;
1182
+ const x = clientX - rect.left;
1183
+ const clamped = Math.max(0, Math.min(rect.width, x));
1184
+ if (variant === "pips") {
1185
+ if (pipCount <= 1) return min;
1186
+ const index = Math.max(
1187
+ 0,
1188
+ Math.min(pipCount - 1, Math.round((clamped / rect.width) * (pipCount - 1))),
1189
+ );
1190
+ return pipSteps[index];
1191
+ } else {
1192
+ const raw = min + (clamped / rect.width) * (max - min);
1193
+ const snapped = Math.round((raw - min) / step) * step + min;
1194
+ return Math.max(min, Math.min(max, snapped));
1195
+ }
1196
+ },
1197
+ [variant, pipSteps, pipCount, min, max, step],
1198
+ );
1199
+
1200
+ const handlePointerDown = useCallback(
1201
+ (e: React.PointerEvent<HTMLDivElement>) => {
1202
+ if (disabled) return;
1203
+ if (e.pointerType === "mouse" && e.button !== 0) return;
1204
+ e.preventDefault();
1205
+ dragging.current = true;
1206
+ setIsPressed(true);
1207
+ const newVal = getValueFromX(e.clientX);
1208
+ onChange(newVal);
1209
+ const newPercent = Math.max(0, Math.min(1, (newVal - min) / (max - min)));
1210
+ animate(fillPercent, newPercent, spring.fast);
1211
+ animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast);
1212
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
1213
+ },
1214
+ [disabled, getValueFromX, onChange, fillPercent, zeroOffset, zeroTarget, min, max],
1215
+ );
1216
+
1217
+ const handlePointerMove = useCallback(
1218
+ (e: React.PointerEvent<HTMLDivElement>) => {
1219
+ if (!dragging.current) return;
1220
+ const newVal = getValueFromX(e.clientX);
1221
+ onChange(newVal);
1222
+ const newPercent = Math.max(0, Math.min(1, (newVal - min) / (max - min)));
1223
+ if (variant === "scrubber") {
1224
+ fillPercent.set(newPercent);
1225
+ } else {
1226
+ animate(fillPercent, newPercent, spring.fast);
1227
+ }
1228
+ animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast);
1229
+ },
1230
+ [getValueFromX, onChange, variant, fillPercent, zeroOffset, zeroTarget, min, max],
1231
+ );
1232
+
1233
+ const handlePointerUp = useCallback(() => {
1234
+ dragging.current = false;
1235
+ setIsPressed(false);
1236
+ setHoverPreview(null);
1237
+ }, []);
1238
+
1239
+ // Resize handle drag handlers (direct cursor position)
1240
+ const handleResizePointerDown = useCallback(
1241
+ (e: React.PointerEvent<HTMLDivElement>) => {
1242
+ if (disabled) return;
1243
+ if (e.pointerType === "mouse" && e.button !== 0) return;
1244
+ e.preventDefault();
1245
+ e.stopPropagation();
1246
+ handleDragging.current = true;
1247
+ setIsPressed(true);
1248
+ const newVal = getValueFromX(e.clientX);
1249
+ onChange(newVal);
1250
+ fillPercent.set(Math.max(0, Math.min(1, (newVal - min) / (max - min))));
1251
+ animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast);
1252
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
1253
+ },
1254
+ [disabled, getValueFromX, onChange, fillPercent, zeroOffset, zeroTarget, min, max],
1255
+ );
1256
+
1257
+ const handleResizePointerMove = useCallback(
1258
+ (e: React.PointerEvent<HTMLDivElement>) => {
1259
+ if (!handleDragging.current) return;
1260
+ const newVal = getValueFromX(e.clientX);
1261
+ onChange(newVal);
1262
+ fillPercent.set(Math.max(0, Math.min(1, (newVal - min) / (max - min))));
1263
+ animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast);
1264
+ },
1265
+ [getValueFromX, onChange, fillPercent, zeroOffset, zeroTarget, min, max],
1266
+ );
1267
+
1268
+ const handleResizePointerUp = useCallback(() => {
1269
+ handleDragging.current = false;
1270
+ setIsPressed(false);
1271
+ setHoverPreview(null);
1272
+ }, []);
1273
+
1274
+ const handleRadixChange = useCallback(
1275
+ (newValues: number[]) => {
1276
+ onChange(newValues[0]);
1277
+ },
1278
+ [onChange],
1279
+ );
1280
+
1281
+ const isActive = isHovered || isFocused;
1282
+
1283
+ return (
1284
+ <div
1285
+ className="relative w-full touch-none"
1286
+ onPointerEnter={() => {
1287
+ if (!disabled) setIsHovered(true);
1288
+ }}
1289
+ onPointerLeave={() => {
1290
+ if (!disabled) {
1291
+ setIsHovered(false);
1292
+ setHoverPreview(null);
1293
+ }
1294
+ }}
1295
+ onMouseMove={(e) => {
1296
+ if (disabled || dragging.current || handleDragging.current) return;
1297
+ computeHoverPreview(e.clientX);
1298
+ }}
1299
+ >
1300
+ {/* Extended hit area — 8px beyond each edge */}
1301
+ <div
1302
+ className="absolute cursor-ew-resize"
1303
+ style={{ left: -8, right: -8, top: 0, bottom: 0 }}
1304
+ onPointerDown={handlePointerDown}
1305
+ onPointerMove={handlePointerMove}
1306
+ onPointerUp={handlePointerUp}
1307
+ />
1308
+ {/* Hover value tooltip — outside overflow-hidden container */}
1309
+ <AnimatePresence>
1310
+ {hoverPreview && showHoverTooltip && !isPressed && (
1311
+ <motion.div
1312
+ key="hover-tooltip"
1313
+ className="absolute -translate-x-1/2 pointer-events-none z-20"
1314
+ initial={{ opacity: 0, y: 4 }}
1315
+ animate={{ opacity: 1, y: 0 }}
1316
+ exit={{ opacity: 0, y: 4, transition: spring.fast.exit }}
1317
+ transition={spring.fast}
1318
+ style={{
1319
+ left: hoverPreview.cursorX,
1320
+ top: -30,
1321
+ }}
1322
+ >
1323
+ <span
1324
+ className={cn(
1325
+ "text-[12px] text-background tabular-nums whitespace-nowrap bg-foreground px-2 py-1",
1326
+ shape.bg,
1327
+ )}
1328
+ style={{ fontVariationSettings: fontWeights.medium }}
1329
+ >
1330
+ {formatValue(hoverPreview.snappedValue)}
1331
+ </span>
1332
+ </motion.div>
1333
+ )}
1334
+ </AnimatePresence>
1335
+
1336
+ <motion.div
1337
+ ref={mergedRef}
1338
+ className={cn(
1339
+ "relative w-full h-8 select-none touch-none border border-border overflow-hidden outline-offset-2",
1340
+ variant === "scrubber"
1341
+ ? "flex items-center gap-3 px-4 cursor-ew-resize"
1342
+ : "cursor-ew-resize",
1343
+ shape.bg,
1344
+ disabled && "opacity-50 pointer-events-none",
1345
+ className,
1346
+ )}
1347
+ initial={false}
1348
+ animate={{
1349
+ outline: isFocused ? "1px solid #6B97FF" : "1px solid transparent",
1350
+ }}
1351
+ transition={spring.fast}
1352
+ style={style as any}
1353
+ onPointerDown={handlePointerDown}
1354
+ onPointerMove={handlePointerMove}
1355
+ onPointerUp={handlePointerUp}
1356
+ {...props}
1357
+ >
1358
+ {/* Invisible Radix for keyboard nav + a11y */}
1359
+ <SliderPrimitive.Root
1360
+ value={[value]}
1361
+ onValueChange={handleRadixChange}
1362
+ min={min}
1363
+ max={max}
1364
+ step={step}
1365
+ disabled={disabled}
1366
+ className="absolute inset-0 opacity-0 pointer-events-none [&_*]:pointer-events-none"
1367
+ >
1368
+ <SliderPrimitive.Track className="w-full h-full">
1369
+ <SliderPrimitive.Range />
1370
+ </SliderPrimitive.Track>
1371
+ <SliderPrimitive.Thumb
1372
+ className="block outline-none"
1373
+ onFocus={(e) => {
1374
+ if (e.currentTarget.matches(":focus-visible")) setIsFocused(true);
1375
+ }}
1376
+ onBlur={() => setIsFocused(false)}
1377
+ />
1378
+ </SliderPrimitive.Root>
1379
+
1380
+ {/* Hover preview */}
1381
+ <motion.div
1382
+ className="absolute inset-y-0 pointer-events-none z-[3]"
1383
+ initial={false}
1384
+ animate={{
1385
+ opacity: hoverPreview && !isPressed ? 1 : 0,
1386
+ }}
1387
+ transition={{ opacity: { duration: 0.15 } }}
1388
+ style={{
1389
+ left: hoverPreview ? hoverPreview.left : 0,
1390
+ width: hoverPreview ? hoverPreview.width : 0,
1391
+ backgroundColor: "color-mix(in srgb, var(--color-accent) 40%, transparent)",
1392
+ }}
1393
+ />
1394
+
1395
+ {/* Pips: dots layer — z-[1] */}
1396
+ {variant === "pips" && (
1397
+ <motion.div
1398
+ className="absolute inset-0 flex justify-between items-center px-3 pointer-events-none z-[1]"
1399
+ style={{ WebkitMaskImage: pipsMaskStyle, maskImage: pipsMaskStyle }}
1400
+ >
1401
+ {pipSteps.map((pipValue) => {
1402
+ const isActivePip = pipValue === value;
1403
+ return (
1404
+ <div
1405
+ key={pipValue}
1406
+ className="relative flex items-center justify-center"
1407
+ style={{ width: PIP_SIZE, height: PIP_SIZE }}
1408
+ >
1409
+ <motion.div
1410
+ className="rounded-full"
1411
+ initial={false}
1412
+ animate={{
1413
+ backgroundColor: isActivePip
1414
+ ? "var(--foreground)"
1415
+ : "var(--muted-foreground)",
1416
+ opacity: isActivePip ? 1 : 0.3,
1417
+ }}
1418
+ transition={spring.fast}
1419
+ style={{ width: PIP_SIZE, height: PIP_SIZE }}
1420
+ />
1421
+ </div>
1422
+ );
1423
+ })}
1424
+ </motion.div>
1425
+ )}
1426
+
1427
+ {/* Pips: label + value BG layer — z-[2] (occludes dots behind text) */}
1428
+ {variant === "pips" && (
1429
+ <div
1430
+ className="absolute inset-0 flex items-center px-2 z-[2] pointer-events-none"
1431
+ aria-hidden
1432
+ >
1433
+ {label && (
1434
+ <span className="text-[13px] px-2 bg-background text-transparent select-none">
1435
+ {"\u00a0"}
1436
+ </span>
1437
+ )}
1438
+ <span
1439
+ className="text-[13px] tabular-nums ml-auto px-2 bg-background text-transparent select-none"
1440
+ style={{ minWidth: `${String(formatValue(max)).length}ch` }}
1441
+ >
1442
+ {"\u00a0"}
1443
+ </span>
1444
+ </div>
1445
+ )}
1446
+
1447
+ {/* Pips: fill — z-[3] */}
1448
+ {variant === "pips" && (
1449
+ <motion.div
1450
+ className="absolute left-0 top-0 bottom-0 pointer-events-none z-[3]"
1451
+ style={{
1452
+ width: pipsFillWidthStyle,
1453
+ backgroundColor: "var(--active)",
1454
+ }}
1455
+ />
1456
+ )}
1457
+
1458
+ {/* Pips: handle line — z-[3] */}
1459
+ {variant === "pips" && (
1460
+ <motion.div
1461
+ className="absolute rounded-full pointer-events-none z-[3]"
1462
+ initial={false}
1463
+ animate={{
1464
+ top: isActive ? 7 : 8,
1465
+ bottom: isActive ? 7 : 8,
1466
+ backgroundColor: isFocused
1467
+ ? "var(--foreground)"
1468
+ : isHovered
1469
+ ? "color-mix(in srgb, var(--foreground) 50%, transparent)"
1470
+ : "color-mix(in srgb, var(--foreground) 25%, transparent)",
1471
+ }}
1472
+ transition={spring.fast}
1473
+ style={{
1474
+ left: pipsHandleLineLeftStyle,
1475
+ width: 2,
1476
+ }}
1477
+ />
1478
+ )}
1479
+
1480
+ {/* Pips: label + value text layer — z-[4] */}
1481
+ {variant === "pips" && (
1482
+ <div className="absolute inset-0 flex items-center px-2 z-[4] pointer-events-none">
1483
+ {label && (
1484
+ <motion.span
1485
+ className="text-[13px] px-2"
1486
+ initial={false}
1487
+ animate={{ color: isActive ? "var(--foreground)" : "var(--muted-foreground)" }}
1488
+ transition={spring.fast}
1489
+ >
1490
+ {label}
1491
+ </motion.span>
1492
+ )}
1493
+ <motion.span
1494
+ className="text-[13px] tabular-nums ml-auto px-2"
1495
+ initial={false}
1496
+ animate={{ color: isActive ? "var(--foreground)" : "var(--muted-foreground)" }}
1497
+ transition={spring.fast}
1498
+ style={{ minWidth: `${String(formatValue(max)).length}ch`, textAlign: "right" }}
1499
+ >
1500
+ {formatValue(value)}
1501
+ </motion.span>
1502
+ </div>
1503
+ )}
1504
+
1505
+ {/* Scrubber: fill */}
1506
+ {variant === "scrubber" && (
1507
+ <motion.div
1508
+ className="absolute left-0 top-0 bottom-0 pointer-events-none"
1509
+ style={{
1510
+ width: fillWidthStyle,
1511
+ backgroundColor: "var(--active)",
1512
+ }}
1513
+ />
1514
+ )}
1515
+
1516
+ {/* Scrubber: handle line */}
1517
+ {variant === "scrubber" && (
1518
+ <motion.div
1519
+ className="absolute rounded-full pointer-events-none z-10"
1520
+ initial={false}
1521
+ animate={{
1522
+ top: isActive ? 7 : 8,
1523
+ bottom: isActive ? 7 : 8,
1524
+ backgroundColor: isFocused
1525
+ ? "var(--foreground)"
1526
+ : isHovered
1527
+ ? "color-mix(in srgb, var(--foreground) 50%, transparent)"
1528
+ : "color-mix(in srgb, var(--foreground) 25%, transparent)",
1529
+ }}
1530
+ transition={spring.fast}
1531
+ style={{
1532
+ left: handleLineLeftStyle,
1533
+ width: 2,
1534
+ }}
1535
+ />
1536
+ )}
1537
+
1538
+ {/* Scrubber: label */}
1539
+ {variant === "scrubber" && label && (
1540
+ <motion.span
1541
+ className="text-[13px] shrink-0 z-10"
1542
+ initial={false}
1543
+ animate={{ color: isActive ? "var(--foreground)" : "var(--muted-foreground)" }}
1544
+ transition={spring.fast}
1545
+ >
1546
+ {label}
1547
+ </motion.span>
1548
+ )}
1549
+
1550
+ {/* Scrubber: flex-1 spacer + value */}
1551
+ {variant === "scrubber" && (
1552
+ <>
1553
+ <div className="flex-1" />
1554
+ <motion.span
1555
+ className="text-[13px] shrink-0 tabular-nums text-right z-10"
1556
+ initial={false}
1557
+ animate={{ color: isActive ? "var(--foreground)" : "var(--muted-foreground)" }}
1558
+ transition={spring.fast}
1559
+ style={{ minWidth: `${String(formatValue(max)).length}ch` }}
1560
+ >
1561
+ {formatValue(value)}
1562
+ </motion.span>
1563
+ </>
1564
+ )}
1565
+
1566
+ {/* Resize handle (scrubber only) */}
1567
+ {variant === "scrubber" && (
1568
+ <motion.div
1569
+ className="absolute top-0 bottom-0 w-2 cursor-ew-resize z-20"
1570
+ style={{ left: handleLeftStyle }}
1571
+ onPointerDown={handleResizePointerDown}
1572
+ onPointerMove={handleResizePointerMove}
1573
+ onPointerUp={handleResizePointerUp}
1574
+ />
1575
+ )}
1576
+ </motion.div>
1577
+ </div>
1578
+ );
1579
+ },
1580
+ );
1581
+
1582
+ SliderComfortable.displayName = "SliderComfortable";
1583
+
1584
+ const SliderControl = Slider;
1585
+
1586
+ export { Slider, SliderControl, SliderComfortable };
1587
+ export type { SliderProps, SliderValue, ValuePosition, SliderComfortableProps };