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 { cn } from "@/lib/utils";
import { useRef, type CSSProperties } from "react"; 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; 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 { function thumbCenterXPx(trackWidth: number, min: number, max: number, value: 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);
const span = max - min; const span = max - min;
const ratio = span > 0 ? (value - min) / span : 0; const ratio = span > 0 ? (value - min) / span : 0;
const thumbCenterX = rect.left + ratio * rect.width; const travel = Math.max(0, trackWidth - THUMB_HALF_WIDTH_PX * 2);
return Math.abs(clientX - thumbCenterX) <= THUMB_HIT_RADIUS_PX; 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 = [ const RELEASE_KEYS = [
@@ -52,6 +47,13 @@ export interface ThumbOnlyRangeSliderProps {
/** /**
* Range slider that ignores track clicks/taps — only the thumb can be dragged. * Range slider that ignores track clicks/taps — only the thumb can be dragged.
* Prevents accidental jumps to high volume when missing the thumb. * 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({ export default function ThumbOnlyRangeSlider({
value, value,
@@ -65,81 +67,96 @@ export default function ThumbOnlyRangeSlider({
onDragStart, onDragStart,
onDragEnd, onDragEnd,
}: ThumbOnlyRangeSliderProps) { }: 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) => { const finishDrag = (pointerId: number | null) => {
if (disabled) return; const drag = dragRef.current;
if (!(el instanceof HTMLInputElement)) return; if (!drag) return;
onRelease?.(Number(el.value)); if (pointerId !== null && drag.pointerId !== pointerId) return;
dragRef.current = null;
onRelease?.(valueRef.current);
onDragEnd?.(); onDragEnd?.();
}; };
return ( return (
<input <div
type="range" className={cn(
min={min} "relative z-10 flex h-6 w-full items-center",
max={max} disabled ? "cursor-default" : "cursor-pointer",
value={value} className,
disabled={disabled} )}
className={cn("cyan-slider relative z-10", className)}
style={{ touchAction: "none", ...style }} style={{ touchAction: "none", ...style }}
onChange={(e) => {
if (disabled || draggingRef.current) return;
onChange(Number(e.target.value));
}}
onPointerDown={(e) => { onPointerDown={(e) => {
if (disabled) return; if (disabled || dragRef.current) return;
const input = e.currentTarget; if (e.pointerType === "mouse" && e.button !== 0) return;
if (!isPointerOnThumb(input, e.clientX)) { const input = inputRef.current;
e.preventDefault(); if (!input) return;
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(); e.preventDefault();
draggingRef.current = true; dragRef.current = { pointerId: e.pointerId, grabOffsetPx: pressX - centerX };
onDragStart?.(); onDragStart?.();
input.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
onChange(valueFromClientX(input, e.clientX));
}} }}
onPointerMove={(e) => { onPointerMove={(e) => {
if (disabled || !draggingRef.current) return; const drag = dragRef.current;
onChange(valueFromClientX(e.currentTarget, e.clientX)); 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) => { onPointerUp={(e) => {
if (!draggingRef.current) return; const drag = dragRef.current;
draggingRef.current = false; if (!drag || drag.pointerId !== e.pointerId) return;
try { try {
e.currentTarget.releasePointerCapture(e.pointerId); e.currentTarget.releasePointerCapture(e.pointerId);
} catch { } catch {
// ignore // ignore
} }
emitRelease(e.currentTarget); finishDrag(e.pointerId);
}} }}
onPointerCancel={(e) => { onPointerCancel={() => finishDrag(null)}
if (!draggingRef.current) return; onLostPointerCapture={() => finishDrag(null)}
draggingRef.current = false; >
emitRelease(e.currentTarget); <input
}} ref={inputRef}
onLostPointerCapture={(e) => { type="range"
if (!draggingRef.current) return; min={min}
draggingRef.current = false; max={max}
emitRelease(e.currentTarget); value={value}
}} disabled={disabled}
onBlur={(e) => { className="cyan-slider block w-full"
if (draggingRef.current) { style={{ pointerEvents: "none", touchAction: "none" }}
draggingRef.current = false; onChange={(e) => {
emitRelease(e.currentTarget); // Only reachable via keyboard — the input no longer receives pointer input.
return; if (disabled || dragRef.current) return;
} onChange(Number(e.target.value));
if (!disabled) { }}
onRelease?.(Number(e.currentTarget.value)); onBlur={(e) => {
} if (dragRef.current) {
}} finishDrag(null);
onKeyUp={(e) => { return;
if (disabled) return; }
if (RELEASE_KEYS.includes(e.key as (typeof RELEASE_KEYS)[number])) { if (!disabled) {
emitRelease(e.currentTarget); 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} onClick={onClose}
/> />
<div <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()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex shrink-0 items-center justify-center pt-2 pb-1"> <div className="flex shrink-0 items-center justify-center pt-2 pb-1">