feat(slider): 添加仅可拖动滑块的自定义音量滑块组件

- 新增 ThumbOnlyRangeSlider 组件,禁用轨道点击,仅支持滑块拖动
- 避免触摸时点击轨道导致音量跳变问题
- 在 Home 页面替换旧的音量输入滑块为新组件
- 移除旧音量滑块相关多余的引用和函数
- 支持拖动开始、结束和释放回调以优化操作逻辑
- 优化了 BrandDrawer 组件的样式以支持不同单位的视口高度显示
This commit is contained in:
eafonyang
2026-09-15 17:09:02 +08:00
parent 0bab1bb968
commit eb00a417f7
3 changed files with 175 additions and 65 deletions
@@ -0,0 +1,162 @@
import { cn } from "@/lib/utils";
import { useRef, type CSSProperties } from "react";
/** 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 thumbCenterXPx(trackWidth: number, min: number, max: number, value: number): number {
const span = max - min;
const ratio = span > 0 ? (value - min) / span : 0;
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 = [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
] as const;
export interface ThumbOnlyRangeSliderProps {
value: number;
min: number;
max: number;
disabled?: boolean;
className?: string;
style?: CSSProperties;
onChange: (value: number) => void;
/** Fires when the user releases the thumb after a drag or keyboard adjust. */
onRelease?: (value: number) => void;
onDragStart?: () => void;
onDragEnd?: () => void;
}
/**
* 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,
min,
max,
disabled = false,
className,
style,
onChange,
onRelease,
onDragStart,
onDragEnd,
}: ThumbOnlyRangeSliderProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const dragRef = useRef<{ pointerId: number; grabOffsetPx: number } | null>(null);
const valueRef = useRef(value);
valueRef.current = 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 (
<div
className={cn(
"relative z-10 flex h-6 w-full items-center",
disabled ? "cursor-default" : "cursor-pointer",
className,
)}
style={{ touchAction: "none", ...style }}
onPointerDown={(e) => {
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();
dragRef.current = { pointerId: e.pointerId, grabOffsetPx: pressX - centerX };
onDragStart?.();
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
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) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== e.pointerId) return;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
finishDrag(e.pointerId);
}}
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>
);
}