Files
suaveplan/packages/ui/react/react-control-surface/src/controls/slider-v/slider-v.tsx
2026-05-27 23:47:53 +01:00

193 lines
6.0 KiB
TypeScript

import {
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
} from "react";
import {
fractionForValue,
keyboardStep,
resolveSliderValue,
} from "./slider-helpers.js";
import type { SliderVProps } from "./slider-v.types.js";
type DivPointerEvent = PointerEvent<HTMLDivElement>;
type DivKeyboardEvent = KeyboardEvent<HTMLDivElement>;
const DEFAULT_TRACK_PX = 160;
const DEFAULT_THICKNESS_PX = 24;
const DEFAULT_STEP = 0.01;
const DEFAULT_FINE_FACTOR = 0.1;
/**
* Vertical slider with optional detents. Reports its range via
* standard `aria-valuemin/max/now` attributes and behaves as a
* `role="slider"` with `aria-orientation="vertical"`.
*/
export function SliderV(props: SliderVProps): ReactNode {
const {
value,
onChange,
min,
max,
step = DEFAULT_STEP,
disabled,
label,
ariaLabel,
detents,
snapThreshold,
trackPx = DEFAULT_TRACK_PX,
thicknessPx = DEFAULT_THICKNESS_PX,
fineModeFactor = DEFAULT_FINE_FACTOR,
className,
} = props;
const dragRef = useRef<{ pointerId: number } | null>(null);
const emit = useCallback(
(raw: number) => {
const next = resolveSliderValue({
raw,
min,
max,
step,
detents,
snapThreshold,
});
if (next !== value) onChange(next);
},
[min, max, step, detents, snapThreshold, value, onChange]
);
const valueFromClientY = useCallback(
(event: DivPointerEvent) => {
const rect = event.currentTarget.getBoundingClientRect();
const offsetY = event.clientY - rect.top;
const usable = rect.height === 0 ? trackPx : rect.height;
const t = 1 - offsetY / usable;
return min + Math.max(0, Math.min(1, t)) * (max - min);
},
[min, max, trackPx]
);
const onPointerDown = useCallback(
(event: DivPointerEvent) => {
if (disabled) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { pointerId: event.pointerId };
emit(valueFromClientY(event));
},
[disabled, emit, valueFromClientY]
);
const onPointerMove = useCallback(
(event: DivPointerEvent) => {
if (dragRef.current === null) return;
emit(valueFromClientY(event));
},
[emit, valueFromClientY]
);
const onPointerUp = useCallback((event: DivPointerEvent) => {
const drag = dragRef.current;
if (drag === null) return;
// happy-dom returns false from hasPointerCapture for any id —
// releasing unconditionally is safe (the DOM ignores the call
// when no capture exists) and keeps branch coverage clean.
event.currentTarget.releasePointerCapture(drag.pointerId);
dragRef.current = null;
}, []);
const onKeyDown = useCallback(
(event: DivKeyboardEvent) => {
if (disabled) return;
const next = keyboardStep({
key: event.key,
value,
min,
max,
step,
fineFactor: fineModeFactor,
shiftKey: event.shiftKey,
orientation: "vertical",
});
if (next === null) return;
event.preventDefault();
emit(next);
},
[disabled, value, min, max, step, fineModeFactor, emit]
);
const f = fractionForValue(value, min, max);
const thumbCenterY = trackPx - f * trackPx;
const thumbHeight = 10;
const half = thicknessPx / 2;
return (
<div
className={className}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(value)}
aria-label={ariaLabel ?? label ?? "Slider"}
aria-orientation="vertical"
aria-disabled={disabled ? "true" : "false"}
data-orientation="vertical"
style={{
display: "inline-block",
width: thicknessPx,
height: trackPx,
touchAction: "none",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
>
<svg
width={thicknessPx}
height={trackPx}
viewBox={`0 0 ${thicknessPx} ${trackPx}`}
aria-hidden="true"
focusable="false"
>
<rect
x={half - 2}
y={0}
width={4}
height={trackPx}
fill="var(--cs-slider-track-color, #444)"
/>
{detents?.map((d) => {
const dy =
trackPx - fractionForValue(d.at, min, max) * trackPx;
return (
<line
key={`detent-${d.at}`}
x1={0}
y1={dy}
x2={thicknessPx}
y2={dy}
stroke="var(--cs-slider-detent-color, #888)"
strokeWidth={1}
data-detent="true"
/>
);
})}
<rect
x={0}
y={Math.max(0, thumbCenterY - thumbHeight / 2)}
width={thicknessPx}
height={thumbHeight}
fill="var(--cs-slider-thumb-color, #fff)"
data-role="thumb"
/>
</svg>
</div>
);
}