Initial Commit
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user