193 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|