@hilum/ui 3.3.4 → 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.
- package/README.md +4 -0
- package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
- package/dist/chunk-RI6QJR2L.js.map +1 -0
- package/dist/index.d.ts +800 -122
- package/dist/index.js +10458 -1119
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +17 -0
- package/dist/tokens.d.ts +3 -0
- package/dist/tokens.js +1 -1
- package/llms.txt +18 -4
- package/package.json +19 -1
- package/src/components/accordion.tsx +746 -58
- package/src/components/alert-dialog.tsx +11 -5
- package/src/components/ask-user-questions.tsx +1366 -0
- package/src/components/badge.tsx +105 -24
- package/src/components/button.tsx +207 -38
- package/src/components/card.tsx +3 -2
- package/src/components/chat-message.tsx +116 -0
- package/src/components/checkbox-group.tsx +403 -0
- package/src/components/checkbox.tsx +6 -3
- package/src/components/color-input.tsx +3 -1
- package/src/components/color-picker.tsx +1992 -80
- package/src/components/dialog.tsx +141 -81
- package/src/components/dropdown-menu.tsx +21 -13
- package/src/components/dropdown.tsx +245 -0
- package/src/components/file-thumbnail.tsx +156 -0
- package/src/components/hover-card.tsx +2 -0
- package/src/components/input-copy.tsx +307 -0
- package/src/components/input-group.tsx +303 -108
- package/src/components/input-message.tsx +883 -0
- package/src/components/input-number.tsx +62 -21
- package/src/components/input.tsx +6 -3
- package/src/components/menu-item.tsx +156 -0
- package/src/components/mobile-drawer.tsx +50 -0
- package/src/components/nav-item.tsx +43 -0
- package/src/components/nav-menu.tsx +47 -0
- package/src/components/popover.tsx +3 -1
- package/src/components/radio-group.tsx +381 -35
- package/src/components/scroll-area.tsx +9 -2
- package/src/components/select.tsx +708 -121
- package/src/components/sheet.tsx +15 -6
- package/src/components/slider.tsx +1583 -20
- package/src/components/status-badge.tsx +2 -0
- package/src/components/switch.tsx +245 -28
- package/src/components/table.tsx +191 -77
- package/src/components/tabs-subtle.tsx +411 -0
- package/src/components/tabs.tsx +459 -49
- package/src/components/textarea.tsx +7 -3
- package/src/components/thinking-indicator.tsx +106 -0
- package/src/components/thinking-steps.tsx +382 -0
- package/src/components/tooltip.tsx +158 -33
- package/src/hooks/use-merge-split.tsx +359 -0
- package/src/hooks/use-proximity-hover.ts +208 -0
- package/src/index.ts +14 -0
- package/src/lib/elevated.tsx +52 -0
- package/src/lib/font-weight.ts +18 -0
- package/src/lib/icon-context.tsx +98 -0
- package/src/lib/icon-map.tsx +669 -0
- package/src/lib/interaction.ts +102 -0
- package/src/lib/scroll-fade.tsx +241 -0
- package/src/lib/shape-context.tsx +121 -0
- package/src/lib/springs.ts +30 -0
- package/src/lib/surface-classes.ts +43 -0
- package/src/lib/surface-context.tsx +17 -0
- package/src/tokens/tokens.ts +3 -0
- package/dist/chunk-MAHPAP65.js.map +0 -1
|
@@ -1,24 +1,1587 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 };
|