import { arena, createSignal, getBoundingBox, onLayout, onSettled } from "@solidrt/core" import type { LayoutProps, PointerEvent } from "@solidrt/core" import { theme } from "./theme" import { densityScale } from "./density" import type { StyleProps } from "./types" export interface SliderProps { // Controlled value. If omitted, the slider is uncontrolled. value?: number defaultValue?: number min?: number max?: number // Snap increment. Omit for continuous. step?: number onChange?: (value: number) => void disabled?: boolean layout?: LayoutProps style?: StyleProps } const HEIGHT = 24 const GROOVE = 4 const THUMB = 20 let clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi : x) // A horizontal slider. The groove fills up to the thumb; dragging or pressing // the track sets the value from the pointer x. Moves arrive on the frozen down // path, so a drag keeps updating when the pointer drifts off the track. // Controlled via value/onChange, or uncontrolled via defaultValue. export function Slider(props: SliderProps) { let min = () => props.min ?? 0 let max = () => props.max ?? 100 let [internal, setInternal] = createSignal(props.defaultValue ?? props.min ?? 0) let value = () => (props.value !== undefined ? props.value : internal()) let track: { id: number } | undefined let active: number | null = null let height = () => Math.round(HEIGHT * densityScale()) let thumb = () => Math.round(THUMB * densityScale()) let pct = () => clamp(((value() - min()) / (max() - min())) * 100, 0, 100) // Measured groove width in pixels. The fill and thumb are driven off this // rather than a percentage `width`/`left`, so dragging repaints (d-rect `w`, // thumb `x` transform) instead of reflowing taffy every move. Refreshed each // layout so it tracks resizes. let groove: { id: number } | undefined let [grooveWidth, setGrooveWidth] = createSignal(0) onLayout(() => { if (groove) setGrooveWidth(getBoundingBox(groove)?.width ?? 0) }) let fillPx = () => (pct() / 100) * grooveWidth() let commit = (v: number) => { if (props.value === undefined) setInternal(v) props.onChange?.(v) } let setFromClientX = (clientX: number) => { if (!track) return let box = getBoundingBox(track) if (!box || box.width === 0) return let f = clamp((clientX - box.x) / box.width, 0, 1) let raw = min() + f * (max() - min()) if (props.step) raw = Math.round(raw / props.step) * props.step commit(clamp(raw, min(), max())) } let endDrag = () => { if (active != null) { arena.release(active, owner) active = null } } let owner = { cancel: endDrag } // An unmount mid-drag must not leave a resolved claim behind. onSettled(() => endDrag) let handleDown = (e: PointerEvent) => { if (props.disabled || active != null) return // A down on the track is unambiguously a slider drag: resolve the arena // outright so an ancestor scroller's pan cannot take the pointer over. arena.steal(e.pointerId, owner) active = e.pointerId setFromClientX(e.clientX) } let handleMove = (e: PointerEvent) => { if (active !== e.pointerId) return setFromClientX(e.clientX) } let handleUp = (e: PointerEvent) => { if (active === e.pointerId) endDrag() } return ( (track = n)} repaintBoundary flexDirection="row" alignItems="center" height={height()} width={200} {...props.layout} x={props.style?.x} y={props.style?.y} scale={props.style?.scale} rotate={props.style?.rotate} opacity={props.style?.opacity} pointerEvents={props.disabled ? "none" : "auto"} onPointerDown={handleDown} onPointerMove={handleMove} onPointerUp={handleUp} > (groove = n)} position="relative" flex={1} height={GROOVE}> ) }