/** * Slider - a range slider (track + fill + draggable knob), single or dual. * * A custom control (not ) so it themes cleanly with inline * styles in both light and dark; accessibility is provided explicitly via * role="slider" + aria-valuemin/max/now and full keyboard support (arrows step, * PageUp/Down step x10, Home/End jump to the ends). Dragging a knob or clicking * anywhere on the track sets the value (pointer events, so touch works too). * * Single mode: `value` a number, one knob. `range` mode: `value` a [min, max] * pair, two knobs that drag independently with the fill drawn between them; the * low knob can't pass the high one and vice versa. Both are controlled * (`value` + `onChange`) or uncontrolled (`defaultValue`). * * Colours from the theme, radii from tokens; both themes. */ import { useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent, PointerEvent, ReactNode } from "react"; import { FS, FW, R } from "@rukin/tokens"; import { useTheme } from "./theme"; import type { Theme } from "./theme"; type SliderBaseProps = { min?: number; max?: number; step?: number; disabled?: boolean; /** Show the current value(s) to the right of the track. */ showValue?: boolean; /** Format a shown value (default: the number). */ formatValue?: (value: number) => string; /** For screenreaders. */ ariaLabel?: string; style?: CSSProperties; }; type SingleSliderProps = SliderBaseProps & { range?: false; value?: number; defaultValue?: number; onChange?: (value: number) => void; }; type RangeSliderProps = SliderBaseProps & { range: true; value?: [number, number]; defaultValue?: [number, number]; onChange?: (value: [number, number]) => void; }; export type SliderProps = SingleSliderProps | RangeSliderProps; const clampStep = (v: number, min: number, max: number, step: number): number => { const clamped = Math.max(min, Math.min(max, v)); const snapped = Math.round((clamped - min) / step) * step + min; // guard float drift, then re-clamp const fixed = Math.round(snapped * 1e6) / 1e6; return Math.max(min, Math.min(max, fixed)); }; export function Slider(props: SliderProps) { const { min = 0, max = 100, step = 1, disabled, showValue = false, formatValue, ariaLabel, style, } = props; const { C } = useTheme(); const trackRef = useRef(null); const range = props.range === true; // --- single-mode state const [singleInternal, setSingleInternal] = useState(() => clampStep(props.range === true ? min : props.defaultValue ?? 0, min, max, step), ); // --- range-mode state const [rangeInternal, setRangeInternal] = useState<[number, number]>(() => { const dv = props.range === true ? props.defaultValue ?? [min, max] : [min, max]; const d0 = dv[0] ?? min; const d1 = dv[1] ?? max; return [clampStep(d0, min, max, step), clampStep(d1, min, max, step)]; }); // which range thumb a drag is manipulating (0 = low, 1 = high) const dragThumb = useRef<0 | 1>(0); const pctOf = (v: number) => (max > min ? ((v - min) / (max - min)) * 100 : 0); const shownOf = (v: number) => (formatValue ? formatValue(v) : String(v)); const fromClientX = (clientX: number): number => { const el = trackRef.current; if (!el) return min; const rect = el.getBoundingClientRect(); const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0; return min + Math.max(0, Math.min(1, ratio)) * (max - min); }; /* ---------------------------------------------------------- single mode */ if (!range) { const controlled = props.value !== undefined; const val = clampStep(controlled ? props.value! : singleInternal, min, max, step); const pct = pctOf(val); const set = (v: number) => { const next = clampStep(v, min, max, step); if (next === val) return; if (!controlled) setSingleInternal(next); props.onChange?.(next); }; const onPointerDown = (e: PointerEvent) => { if (disabled) return; e.preventDefault(); (e.currentTarget as HTMLDivElement).setPointerCapture?.(e.pointerId); set(fromClientX(e.clientX)); }; const onPointerMove = (e: PointerEvent) => { if (disabled || e.buttons !== 1) return; set(fromClientX(e.clientX)); }; const onKeyDown = (e: KeyboardEvent) => { if (disabled) return; const big = step * 10; const map: Record = { ArrowRight: val + step, ArrowUp: val + step, ArrowLeft: val - step, ArrowDown: val - step, PageUp: val + big, PageDown: val - big, Home: min, End: max, }; if (e.key in map) { e.preventDefault(); set(map[e.key]!); } }; return (
{showValue && {shownOf(val)}} ); } /* ----------------------------------------------------------- range mode */ const controlled = props.value !== undefined; const raw = controlled ? props.value! : rangeInternal; const lo = clampStep(Math.min(raw[0], raw[1]), min, max, step); const hi = clampStep(Math.max(raw[0], raw[1]), min, max, step); const loPct = pctOf(lo); const hiPct = pctOf(hi); const setThumb = (which: 0 | 1, v: number) => { const next = clampStep(v, min, max, step); let nlo = lo; let nhi = hi; if (which === 0) nlo = Math.min(next, hi); else nhi = Math.max(next, lo); if (nlo === lo && nhi === hi) return; const pair: [number, number] = [nlo, nhi]; if (!controlled) setRangeInternal(pair); props.onChange?.(pair); }; const nearestThumb = (v: number): 0 | 1 => { // pick the closer knob; ties go to whichever side the value falls on const dLo = Math.abs(v - lo); const dHi = Math.abs(v - hi); if (dLo < dHi) return 0; if (dHi < dLo) return 1; return v < lo ? 0 : 1; }; const onPointerDown = (e: PointerEvent) => { if (disabled) return; e.preventDefault(); (e.currentTarget as HTMLDivElement).setPointerCapture?.(e.pointerId); const v = fromClientX(e.clientX); dragThumb.current = nearestThumb(v); setThumb(dragThumb.current, v); }; const onPointerMove = (e: PointerEvent) => { if (disabled || e.buttons !== 1) return; setThumb(dragThumb.current, fromClientX(e.clientX)); }; const keyHandler = (which: 0 | 1) => (e: KeyboardEvent) => { if (disabled) return; const cur = which === 0 ? lo : hi; const big = step * 10; const map: Record = { ArrowRight: cur + step, ArrowUp: cur + step, ArrowLeft: cur - step, ArrowDown: cur - step, PageUp: cur + big, PageDown: cur - big, Home: min, End: max, }; if (e.key in map) { e.preventDefault(); setThumb(which, map[e.key]!); } }; return (
{showValue && ( {shownOf(lo)} - {shownOf(hi)} )} ); } /* --------------------------------------------------------------- subparts */ type ThemeC = Theme["C"]; function Frame({ disabled, style, children }: { disabled?: boolean; style?: CSSProperties; children: ReactNode }) { return (
{children}
); } const trackStyle = (disabled?: boolean): CSSProperties => ({ position: "relative", flex: 1, height: 20, display: "flex", alignItems: "center", cursor: disabled ? "not-allowed" : "pointer", touchAction: "none", }); function Rail({ C }: { C: ThemeC }) { return (
); } function Knob({ C, pct, disabled, ariaLabel, valuemin, valuemax, valuenow, onKeyDown, }: { C: ThemeC; pct: number; disabled?: boolean; ariaLabel?: string; valuemin: number; valuemax: number; valuenow: number; onKeyDown: (e: KeyboardEvent) => void; }) { return (
); } function ValueLabel({ C, children }: { C: ThemeC; children: ReactNode }) { return ( {children} ); }