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; }