/**
* 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}
);
}