refactor(ThumbOnlyRangeSlider): 优化滑块拖拽逻辑和事件处理

- 移除原有基于客户端X坐标计算值的函数,改用更精确的偏移计算
- 实现滑块中心位置和偏移值转换函数,确保滑块不会超出轨道边界
- 将原生输入禁用指针事件,由外层容器处理所有指针事件以提升触控体验
- 修复触摸设备上滑块跳跃问题,防止点击轨道时误触导致值突变
- 优化指针事件绑定和释放逻辑,支持多种交互状态切换
- 保持键盘无障碍可用,输入框仍可通过键盘改变值并触发事件

style(BrandDrawer): 修正抽屉高度适配并增加对动态视窗单位的支持

- 将固定高度单位58dvh替换为58vh,并增加max-h-full以限制最大高度
- 利用CSS supports媒体查询,针对支持1dvh单位的浏览器恢复58dvh高度
- 修复在不同设备和浏览器中抽屉显示高度不一致的问题
This commit is contained in:
eafonyang
2026-09-15 17:09:13 +08:00
parent 65209d9577
commit cc0e9a3334
2 changed files with 92 additions and 75 deletions
+91 -74
View File
@@ -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 <input type="range"> 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<HTMLInputElement | null>(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 (
<input
type="range"
min={min}
max={max}
value={value}
disabled={disabled}
className={cn("cyan-slider relative z-10", className)}
<div
className={cn(
"relative z-10 flex h-6 w-full items-center",
disabled ? "cursor-default" : "cursor-pointer",
className,
)}
style={{ touchAction: "none", ...style }}
onChange={(e) => {
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)}
>
<input
ref={inputRef}
type="range"
min={min}
max={max}
value={value}
disabled={disabled}
className="cyan-slider block w-full"
style={{ pointerEvents: "none", touchAction: "none" }}
onChange={(e) => {
// 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?.();
}
}}
/>
</div>
);
}
@@ -164,7 +164,7 @@ export function BrandDrawer({
onClick={onClose}
/>
<div
className="relative z-10 flex h-[58dvh] flex-shrink-0 flex-col overflow-hidden rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)]"
className="relative z-10 flex h-[58vh] max-h-full flex-shrink-0 flex-col overflow-hidden rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)] supports-[height:1dvh]:h-[58dvh]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex shrink-0 items-center justify-center pt-2 pb-1">