diff --git a/client/src/components/ThumbOnlyRangeSlider.tsx b/client/src/components/ThumbOnlyRangeSlider.tsx
index d6beabe..6297251 100644
--- a/client/src/components/ThumbOnlyRangeSlider.tsx
+++ b/client/src/components/ThumbOnlyRangeSlider.tsx
@@ -1,27 +1,22 @@
import { cn } from "@/lib/utils";
import { useRef, type CSSProperties } from "react";
-/** Matches .cyan-slider thumb (~17.6px); generous hit area for touch. */
+/** Generous hit area around the thumb for touch. */
const THUMB_HIT_RADIUS_PX = 24;
+/** Half of the .cyan-slider thumb (~17.6-18px wide); the thumb center never reaches the track ends. */
+const THUMB_HALF_WIDTH_PX = 9;
-function valueFromClientX(input: HTMLInputElement, clientX: number): number {
- const rect = input.getBoundingClientRect();
- const min = Number(input.min);
- const max = Number(input.max);
- if (rect.width <= 0) return Number(input.value);
- const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
- return Math.round(min + ratio * (max - min));
-}
-
-function isPointerOnThumb(input: HTMLInputElement, clientX: number): boolean {
- const rect = input.getBoundingClientRect();
- const min = Number(input.min);
- const max = Number(input.max);
- const value = Number(input.value);
+function thumbCenterXPx(trackWidth: number, min: number, max: number, value: number): number {
const span = max - min;
const ratio = span > 0 ? (value - min) / span : 0;
- const thumbCenterX = rect.left + ratio * rect.width;
- return Math.abs(clientX - thumbCenterX) <= THUMB_HIT_RADIUS_PX;
+ const travel = Math.max(0, trackWidth - THUMB_HALF_WIDTH_PX * 2);
+ return THUMB_HALF_WIDTH_PX + ratio * travel;
+}
+
+function valueFromOffsetXPx(trackWidth: number, min: number, max: number, offsetX: number): number {
+ const travel = Math.max(1, trackWidth - THUMB_HALF_WIDTH_PX * 2);
+ const ratio = Math.max(0, Math.min(1, (offsetX - THUMB_HALF_WIDTH_PX) / travel));
+ return Math.round(min + ratio * (max - min));
}
const RELEASE_KEYS = [
@@ -52,6 +47,13 @@ export interface ThumbOnlyRangeSliderProps {
/**
* Range slider that ignores track clicks/taps — only the thumb can be dragged.
* Prevents accidental jumps to high volume when missing the thumb.
+ *
+ * The native is rendered pointer-events:none and driven
+ * entirely by wrapper pointer events. On touch input the native tap-to-position
+ * behavior cannot be reliably canceled via preventDefault on pointerdown (React
+ * listens to touchstart passively at the root, and the touch-driven path differs
+ * from desktop mouse), so the only robust fix is to remove the native pointer
+ * interaction surface altogether. The input stays keyboard-focusable for a11y.
*/
export default function ThumbOnlyRangeSlider({
value,
@@ -65,81 +67,96 @@ export default function ThumbOnlyRangeSlider({
onDragStart,
onDragEnd,
}: ThumbOnlyRangeSliderProps) {
- const draggingRef = useRef(false);
+ const inputRef = useRef(null);
+ const dragRef = useRef<{ pointerId: number; grabOffsetPx: number } | null>(null);
+ const valueRef = useRef(value);
+ valueRef.current = value;
- const emitRelease = (el: EventTarget | null) => {
- if (disabled) return;
- if (!(el instanceof HTMLInputElement)) return;
- onRelease?.(Number(el.value));
+ const finishDrag = (pointerId: number | null) => {
+ const drag = dragRef.current;
+ if (!drag) return;
+ if (pointerId !== null && drag.pointerId !== pointerId) return;
+ dragRef.current = null;
+ onRelease?.(valueRef.current);
onDragEnd?.();
};
return (
- {
- if (disabled || draggingRef.current) return;
- onChange(Number(e.target.value));
- }}
onPointerDown={(e) => {
- if (disabled) return;
- const input = e.currentTarget;
- if (!isPointerOnThumb(input, e.clientX)) {
- e.preventDefault();
- return;
- }
+ if (disabled || dragRef.current) return;
+ if (e.pointerType === "mouse" && e.button !== 0) return;
+ const input = inputRef.current;
+ if (!input) return;
+ const rect = input.getBoundingClientRect();
+ const pressX = e.clientX - rect.left;
+ const centerX = thumbCenterXPx(rect.width, min, max, valueRef.current);
+ // Track press: ignore entirely — no value change, no drag.
+ if (Math.abs(pressX - centerX) > THUMB_HIT_RADIUS_PX) return;
e.preventDefault();
- draggingRef.current = true;
+ dragRef.current = { pointerId: e.pointerId, grabOffsetPx: pressX - centerX };
onDragStart?.();
- input.setPointerCapture(e.pointerId);
- onChange(valueFromClientX(input, e.clientX));
+ e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
- if (disabled || !draggingRef.current) return;
- onChange(valueFromClientX(e.currentTarget, e.clientX));
+ const drag = dragRef.current;
+ if (disabled || !drag || drag.pointerId !== e.pointerId) return;
+ const input = inputRef.current;
+ if (!input) return;
+ const rect = input.getBoundingClientRect();
+ const offsetX = e.clientX - rect.left - drag.grabOffsetPx;
+ onChange(valueFromOffsetXPx(rect.width, min, max, offsetX));
}}
onPointerUp={(e) => {
- if (!draggingRef.current) return;
- draggingRef.current = false;
+ const drag = dragRef.current;
+ if (!drag || drag.pointerId !== e.pointerId) return;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
- emitRelease(e.currentTarget);
+ finishDrag(e.pointerId);
}}
- onPointerCancel={(e) => {
- if (!draggingRef.current) return;
- draggingRef.current = false;
- emitRelease(e.currentTarget);
- }}
- onLostPointerCapture={(e) => {
- if (!draggingRef.current) return;
- draggingRef.current = false;
- emitRelease(e.currentTarget);
- }}
- onBlur={(e) => {
- if (draggingRef.current) {
- draggingRef.current = false;
- emitRelease(e.currentTarget);
- return;
- }
- if (!disabled) {
- onRelease?.(Number(e.currentTarget.value));
- }
- }}
- onKeyUp={(e) => {
- if (disabled) return;
- if (RELEASE_KEYS.includes(e.key as (typeof RELEASE_KEYS)[number])) {
- emitRelease(e.currentTarget);
- }
- }}
- />
+ onPointerCancel={() => finishDrag(null)}
+ onLostPointerCapture={() => finishDrag(null)}
+ >
+ {
+ // Only reachable via keyboard — the input no longer receives pointer input.
+ if (disabled || dragRef.current) return;
+ onChange(Number(e.target.value));
+ }}
+ onBlur={(e) => {
+ if (dragRef.current) {
+ finishDrag(null);
+ return;
+ }
+ if (!disabled) {
+ onRelease?.(Number(e.currentTarget.value));
+ }
+ }}
+ onKeyUp={(e) => {
+ if (disabled) return;
+ if (RELEASE_KEYS.includes(e.key as (typeof RELEASE_KEYS)[number])) {
+ onRelease?.(Number(e.currentTarget.value));
+ onDragEnd?.();
+ }
+ }}
+ />
+
);
}
diff --git a/client/src/pages/eq/components/BrandDrawer.tsx b/client/src/pages/eq/components/BrandDrawer.tsx
index aa04295..ba9b3c0 100644
--- a/client/src/pages/eq/components/BrandDrawer.tsx
+++ b/client/src/pages/eq/components/BrandDrawer.tsx
@@ -164,7 +164,7 @@ export function BrandDrawer({
onClick={onClose}
/>