import type { JSX, ValidComponent } from "@solidjs/web";
import { dynamic, isServer } from "@solidjs/web";
import { omit, onCleanup, untrack } from "solid-js";
import { useDirection } from "../../direction-provider/direction-provider-context.ts";
import {
createChangeEventDetails,
createGenericEventDetails,
REASONS,
} from "../../internals/change-event-details";
import { composeRefs, userRef } from "../../internals/compose-refs";
import { activeElement } from "../../internals/dom/active-element.ts";
import { contains } from "../../internals/dom/contains.ts";
import { getTarget } from "../../internals/dom/get-target.ts";
import { ownerDocument } from "../../internals/dom/owner-document.ts";
import { ownerWindow } from "../../internals/dom/owner-window.ts";
import { mergeRootProps } from "../../internals/merge-root-props";
import { resolveClassStyle } from "../../internals/resolve-class-style";
import { clamp } from "../../internals/slider-utils/clamp.ts";
import { getMidpoint } from "../../internals/slider-utils/get-midpoint.ts";
import { resolveThumbCollision } from "../../internals/slider-utils/resolve-thumb-collision.ts";
import { roundValueToStep } from "../../internals/slider-utils/round-value-to-step.ts";
import { validateMinimumDistance } from "../../internals/slider-utils/validate-minimum-distance.ts";
import type { BaseUIComponentProps } from "../../internals/types";
import { useAnimationFrame } from "../../internals/use-animation-frame.ts";
import {
type SliderRootState,
useSliderRootContext,
} from "../slider-root-context.ts";
import { sliderDataAttrs } from "../utils/state-attrs.ts";
const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2;
interface Coords {
x: number;
y: number;
}
interface FingerState {
value: number | number[];
thumbIndex: number;
didSwap: boolean;
}
function getControlOffset(
styles: CSSStyleDeclaration | null,
vertical: boolean,
): { start: number; end: number } {
if (!styles) return { start: 0, end: 0 };
function parseSize(value: string | null | undefined): number {
const parsed = value != null ? Number.parseFloat(value) : 0;
return Number.isNaN(parsed) ? 0 : parsed;
}
const start = vertical ? "Top" : "InlineStart";
const end = vertical ? "Bottom" : "InlineEnd";
// Indexing via bracket notation because React's port reads the same
// camelCase border/padding keys off CSSStyleDeclaration.
return {
start:
parseSize(
styles[`border${start}Width` as keyof CSSStyleDeclaration] as string,
) +
parseSize(
styles[`padding${start}` as keyof CSSStyleDeclaration] as string,
),
end:
parseSize(
styles[`border${end}Width` as keyof CSSStyleDeclaration] as string,
) +
parseSize(styles[`padding${end}` as keyof CSSStyleDeclaration] as string),
};
}
function getFingerCoords(
event: TouchEvent | PointerEvent,
touchIdRef: { current: number | null },
): Coords | null {
if (
touchIdRef.current != null &&
(event as TouchEvent).changedTouches != null
) {
const touchEvent = event as TouchEvent;
for (let i = 0; i < touchEvent.changedTouches.length; i += 1) {
const touch = touchEvent.changedTouches[i];
if (touch && touch.identifier === touchIdRef.current) {
return { x: touch.clientX, y: touch.clientY };
}
}
return null;
}
const pointer = event as PointerEvent;
return { x: pointer.clientX, y: pointer.clientY };
}
export interface SliderControlState extends SliderRootState {}
export interface SliderControlProps
extends BaseUIComponentProps<"div", SliderControlState> {
/** Tag/component to render. Defaults to `"div"`. */
as?: ValidComponent;
}
/**
* The clickable, interactive part of the slider. Renders a `
`.
*
* Owns the pointer/touch-drag state machine:
*
* - `pointerdown` on the track picks the closest thumb, computes the finger
* position in value space, and begins listening for `pointermove` and
* `pointerup` on the ambient document.
* - `pointermove` applies collision resolution (push / swap / none) and
* commits a `drag` change.
* - `pointerup` commits the final value and releases listeners.
* - Touch events follow the same pipeline via `touchstart` / `touchmove` /
* `touchend`, keyed by the active touch identifier.
*
* Pointer-drag integration behavior is limited in happy-dom; covered in unit
* tests where possible and scheduled for browser-level coverage later.
*/
export function SliderControl(props: SliderControlProps): JSX.Element {
const context = useSliderRootContext();
const direction = useDirection();
const focusFrame = useAnimationFrame();
const controlRef: { current: HTMLElement | null } = { current: null };
const touchIdRef: { current: number | null } = { current: null };
const moveCountRef = { current: 0 };
// Seed the latest-values cache with a one-shot snapshot — the
// pointer-handler effects below subscribe to subsequent `context.values()`
// changes and refresh this ref. `untrack` keeps the snapshot read out of
// the component-body strict-read shell.
const latestValuesRef: { current: readonly number[] } = {
current: untrack(() => context.values()),
};
// Computed style of the control, captured once on mount. Used by
// `getControlOffset` to subtract border + padding from the usable drag
// width. Grabbed via `ownerWindow` for shadow-DOM / iframe safety.
const stylesRef: { current: CSSStyleDeclaration | null } = { current: null };
// Half the thumb's main-axis size, maintained by ``'s
// ResizeObserver when `thumbAlignment !== 'center'`. In edge mode the
// usable drag region shrinks by `2 * insetThumbOffset` so the thumb's
// edge (not its center) aligns with the control endpoints.
const insetThumbOffsetRef = { current: 0 };
function updatePressedThumb(nextIndex: number): void {
if (context.pressedThumbIndexRef.current !== nextIndex) {
context.pressedThumbIndexRef.current = nextIndex;
}
const thumbElement = context.thumbRefs.current[nextIndex];
if (!thumbElement) {
context.pressedThumbCenterOffsetRef.current = null;
context.pressedInputRef.current = null;
return;
}
context.pressedInputRef.current =
thumbElement.querySelector('input[type="range"]');
}
function getFingerState(fingerCoords: Coords): FingerState | null {
const control = controlRef.current;
if (!control) return null;
const vertical = context.orientation() === "vertical";
const rect = control.getBoundingClientRect();
const { width, height, bottom, left, right } = rect;
const controlOffset = getControlOffset(stylesRef.current, vertical);
const insetThumbOffset = insetThumbOffsetRef.current;
const controlSize =
(vertical ? height : width) -
controlOffset.start -
controlOffset.end -
insetThumbOffset * 2;
const thumbCenterOffset = context.pressedThumbCenterOffsetRef.current ?? 0;
const fingerX = fingerCoords.x - thumbCenterOffset;
const fingerY = fingerCoords.y - thumbCenterOffset;
const rtl = direction() === "rtl";
const valueSize = vertical
? bottom - fingerY - controlOffset.end
: (rtl ? right - fingerX : fingerX - left) - controlOffset.start;
const valueRescaled = clamp(
(valueSize - insetThumbOffset) / (controlSize || 1),
0,
1,
);
const min = context.min();
const max = context.max();
const step = context.step();
let newValue = (max - min) * valueRescaled + min;
newValue = roundValueToStep(newValue, step, min);
newValue = clamp(newValue, min, max);
const values = context.values();
const range = values.length > 1;
if (!range) {
return { value: newValue, thumbIndex: 0, didSwap: false };
}
const thumbIndex = context.pressedThumbIndexRef.current;
if (thumbIndex < 0) return null;
const collisionResult = resolveThumbCollision({
behavior: context.thumbCollisionBehavior(),
values,
currentValues: latestValuesRef.current,
initialValues: context.pressedValuesRef.current,
pressedIndex: thumbIndex,
nextValue: newValue,
min,
max,
step,
minStepsBetweenValues: context.minStepsBetweenValues(),
});
if (
context.thumbCollisionBehavior() === "swap" &&
collisionResult.didSwap
) {
updatePressedThumb(collisionResult.thumbIndex);
} else {
context.pressedThumbIndexRef.current = collisionResult.thumbIndex;
}
return collisionResult;
}
function startPressing(fingerCoords: Coords): void {
const values = context.values();
context.pressedValuesRef.current =
values.length > 1 ? values.slice() : null;
latestValuesRef.current = values;
const pressedThumbIndex = context.pressedThumbIndexRef.current;
let closestThumbIndex = pressedThumbIndex;
if (!(pressedThumbIndex > -1 && pressedThumbIndex < values.length)) {
const vertical = context.orientation() === "vertical";
const axis: "x" | "y" = vertical ? "y" : "x";
let minDistance: number | undefined;
closestThumbIndex = -1;
for (let i = 0; i < context.thumbRefs.current.length; i += 1) {
const thumbEl = context.thumbRefs.current[i];
if (thumbEl instanceof Element) {
const midpoint = getMidpoint(thumbEl as HTMLElement);
const distance = Math.abs(fingerCoords[axis] - midpoint[axis]);
if (minDistance === undefined || distance <= minDistance) {
closestThumbIndex = i;
minDistance = distance;
}
}
}
}
if (closestThumbIndex > -1 && closestThumbIndex !== pressedThumbIndex) {
updatePressedThumb(closestThumbIndex);
}
if (context.inset()) {
const thumbEl = context.thumbRefs.current[closestThumbIndex];
if (thumbEl instanceof Element) {
const vertical = context.orientation() === "vertical";
const thumbRect = thumbEl.getBoundingClientRect();
insetThumbOffsetRef.current =
(vertical ? thumbRect.height : thumbRect.width) / 2;
}
}
}
function focusThumb(thumbIndex: number): void {
const thumb = context.thumbRefs.current[thumbIndex];
const input = thumb?.querySelector('input[type="range"]');
input?.focus({ preventScroll: true });
}
function handleTouchMove(nativeEvent: TouchEvent | PointerEvent): void {
const fingerCoords = getFingerCoords(nativeEvent, touchIdRef);
if (fingerCoords == null) return;
moveCountRef.current += 1;
if (
nativeEvent.type === "pointermove" &&
(nativeEvent as PointerEvent).buttons === 0
) {
handleTouchEnd(nativeEvent);
return;
}
const finger = getFingerState(fingerCoords);
if (finger == null) return;
if (
validateMinimumDistance(
finger.value,
context.step(),
context.minStepsBetweenValues(),
)
) {
if (
!context.dragging() &&
moveCountRef.current > INTENTIONAL_DRAG_COUNT_THRESHOLD
) {
context.setDragging(true);
}
const details = createChangeEventDetails(
REASONS.drag,
nativeEvent,
undefined,
{ activeThumbIndex: finger.thumbIndex },
);
context.setValue(finger.value, details);
latestValuesRef.current = Array.isArray(finger.value)
? (finger.value as number[])
: [finger.value];
if (finger.didSwap) focusThumb(finger.thumbIndex);
}
}
function handleTouchEnd(nativeEvent: TouchEvent | PointerEvent): void {
context.setActive(-1);
context.setDragging(false);
context.pressedInputRef.current = null;
context.pressedThumbCenterOffsetRef.current = null;
const fingerCoords = getFingerCoords(nativeEvent, touchIdRef);
const finger = fingerCoords != null ? getFingerState(fingerCoords) : null;
if (finger != null) {
const commitReason = context.lastChangeReasonRef.current;
context.onValueCommitted(
context.lastChangedValueRef.current ?? finger.value,
createGenericEventDetails(commitReason, nativeEvent),
);
}
if ("pointerType" in nativeEvent) {
const pointerId = (nativeEvent as PointerEvent).pointerId;
if (controlRef.current?.hasPointerCapture?.(pointerId)) {
controlRef.current.releasePointerCapture(pointerId);
}
}
context.pressedThumbIndexRef.current = -1;
touchIdRef.current = null;
context.pressedValuesRef.current = null;
stopListening();
}
function stopListening(): void {
// Reached on the server via the setup `onCleanup` when a render scope is
// disposed (e.g. during `renderToString`). No listeners were ever
// bound server-side, and `ownerDocument(null)` would fall through to the
// absent `document` global — so there is nothing to detach.
if (isServer) return;
const doc = ownerDocument(controlRef.current);
doc.removeEventListener("pointermove", handleTouchMove);
doc.removeEventListener("pointerup", handleTouchEnd);
doc.removeEventListener("touchmove", handleTouchMove);
doc.removeEventListener("touchend", handleTouchEnd);
context.pressedValuesRef.current = null;
}
function handleTouchStart(nativeEvent: TouchEvent): void {
if (context.disabled()) return;
const touch = nativeEvent.changedTouches[0];
if (touch != null) touchIdRef.current = touch.identifier;
const fingerCoords = getFingerCoords(nativeEvent, touchIdRef);
if (fingerCoords != null) {
startPressing(fingerCoords);
const finger = getFingerState(fingerCoords);
if (finger == null) return;
focusThumb(finger.thumbIndex);
const details = createChangeEventDetails(
REASONS.trackPress,
nativeEvent,
undefined,
{ activeThumbIndex: finger.thumbIndex },
);
context.setValue(finger.value, details);
latestValuesRef.current = Array.isArray(finger.value)
? (finger.value as number[])
: [finger.value];
if (finger.didSwap) focusThumb(finger.thumbIndex);
}
moveCountRef.current = 0;
const doc = ownerDocument(controlRef.current);
doc.addEventListener("touchmove", handleTouchMove, { passive: true });
doc.addEventListener("touchend", handleTouchEnd, { passive: true });
}
function bindTouchStart(el: HTMLElement): () => void {
el.addEventListener("touchstart", handleTouchStart, { passive: true });
return () => {
el.removeEventListener("touchstart", handleTouchStart);
};
}
onCleanup(() => {
stopListening();
focusFrame.cancel();
});
let unbindTouch: (() => void) | null = null;
const localRef = (el: HTMLElement | null): void => {
if (unbindTouch) {
unbindTouch();
unbindTouch = null;
}
controlRef.current = el;
context.registerFieldControlRef(el);
if (el) {
// React captures `getComputedStyle` once on mount (the live
// CSSStyleDeclaration re-reads on every property access). We mirror
// that here — `ownerWindow` keeps realm checks correct for iframe /
// shadow DOM hosts.
if (stylesRef.current == null) {
stylesRef.current = ownerWindow(el).getComputedStyle(el);
}
unbindTouch = bindTouchStart(el);
}
};
// Resolve `class`/`style` here so function values (stateful styling) are
// invoked with the slider state — otherwise a `class={(state) => …}` is
// spread to the DOM raw and never applied (no class attribute at all),
// collapsing the control's layout.
const userClassStyle = resolveClassStyle(props, () => context.state());
const userProps = omit(props, "as", "class", "style", "attrs");
const internalSlot = {
// SSR hook for the deferred prehydration script. React emits
// this data attr only when `renderBeforeHydration` is true so
// the script can `querySelectorAll` matching controls. We seed
// `""` so Solid 2's DOM spread keeps the key in `ownKeys` even
// when the flag is off (see
// `test/internals/merge-props-undefined-attr.test.tsx`).
get "data-quoin-slider-control"() {
return context.renderBeforeHydration() ? "" : "";
},
onPointerDown(event: PointerEvent) {
const control = controlRef.current;
const target = getTarget(event);
if (
!control ||
context.disabled() ||
event.defaultPrevented ||
!(target instanceof Element) ||
event.button !== 0
) {
return;
}
const fingerCoords = getFingerCoords(event, touchIdRef);
if (fingerCoords != null) {
startPressing(fingerCoords);
const finger = getFingerState(fingerCoords);
if (finger == null) return;
const pressedOnFocusedThumb = contains(
context.thumbRefs.current[finger.thumbIndex],
activeElement(ownerDocument(control)),
);
if (pressedOnFocusedThumb) {
event.preventDefault();
} else {
focusFrame.request(() => {
focusThumb(finger.thumbIndex);
});
}
context.setDragging(true);
const pressedOnAnyThumb =
context.pressedThumbCenterOffsetRef.current != null;
if (!pressedOnAnyThumb) {
const details = createChangeEventDetails(
REASONS.trackPress,
event,
undefined,
{ activeThumbIndex: finger.thumbIndex },
);
context.setValue(finger.value, details);
latestValuesRef.current = Array.isArray(finger.value)
? (finger.value as number[])
: [finger.value];
if (finger.didSwap) focusThumb(finger.thumbIndex);
}
}
if (event.pointerId) {
control.setPointerCapture(event.pointerId);
}
moveCountRef.current = 0;
const doc = ownerDocument(controlRef.current);
doc.addEventListener("pointermove", handleTouchMove, {
passive: true,
});
doc.addEventListener("pointerup", handleTouchEnd, { once: true });
},
};
let merged: Record = mergeRootProps(
internalSlot,
sliderDataAttrs(context.state),
);
merged = mergeRootProps(merged, userClassStyle);
merged = mergeRootProps(merged, userProps);
const Tag = dynamic(() => props.as ?? "div");
return ;
}
export namespace SliderControl {
export type State = SliderControlState;
export type Props = SliderControlProps;
}