跳至正文
组件

Value Slider数值滑块

带标签与读数的数值滑块,支持区间、缓冲进度、刻度磁吸、数值格式化与拖出两端的弹性过冲。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/value-slider.json
会连带安装 本站条目 grid-layout、flow-diagram。已存在的文件会询问是否覆盖。

用法

components/volume-slider.tsx
import { ValueSlider } from "@/components/ui/value-slider/value-slider"

export function VolumeSlider() {
  return <ValueSlider label="音量" defaultValue={40} />
}

不带状态时用 defaultValue 给初始值(不传就落在 min),组件自己管;需要读值就加 value 与 onValueChange 走受控。轨道本身即控件:点轨道跳转、按住手柄拖动,键盘也能操作。

标签与读数

labelPlacement="inside"(默认)时 label 渲染在轨道左端内部,右侧读数是格式化后的当前值,两者都是 aria-hidden,真正的语义挂在手柄上。手柄靠近文字时自动变淡,文字不会被挡住。showValue={false} 只留标签,format 或 formatValue 决定读数长什么样。

<ValueSlider
  label="亮度"
  defaultValue={64}
  format={{ style: "unit", unit: "percent" }}
/>

labelPlacement="outside" 把标签与读数移到轨道上方一行:左侧标签、右侧读数,轨道里不放文字,手柄常驻可见也不再让位。区间这类要同时看清两个手柄的场景用它更合适。variant="slim" 的轨道放不下文字,固定按这一档处理,labelPlacement 在它下面不生效。

<ValueSlider
  label="价格"
  labelPlacement="outside"
  min={0}
  max={2000}
  step={50}
  defaultValue={[200, 1200]}
/>

弹性过冲

往轨道外继续拖时,轨道按饱和的橡皮筋曲线被拉伸:越往外越难拉,最大位移是 elasticity 像素(默认 12),松手时用弹簧回到原位,两端的图标跟着轻轻一推。键盘在手柄已经位于端点时继续按同一个方向,也会拉一下再弹回来,让人知道已经到头。elasticity={0} 完全关掉。

拉伸只做在轨道内部的视觉层上,Slider.Control 自己不缩放,所以把指针拖出右端再拉回轨道中间,值仍然连续变化,不会跳。换边(从一端外面直接拖到另一端外面)时缩放原点固定在中心,只改平移量,同样不会跳。

<ValueSlider label="音量" defaultValue={40} elasticity={24} />

刻度与磁吸

ticks 传数字表示把范围等分成几段,画 n - 1 条内部刻度线;传数组表示在指定值处画线。它同时是 snapToTicks 的目标:指针松手时(拖动或点击轨道)如果与最近刻度相差不到整个范围的 3%,值会吸过去;键盘逐步移动不吸附,所以方向键可以从刻度上走开,不会被反复拉回同一个点。刻度线用逻辑属性定位,RTL 下跟着手柄与填充一起从右边排布。

刻度建议落在 step 上。不在 step 上的刻度会被对齐到最近的 step 再提交,避免出现 3650 这种步长之外的数值;区间模式吸附后还要保证两个值顺序不变、且至少相隔 minStepsBetweenValues 个 step,不满足就放弃那次吸附。

<ValueSlider
  label="色温"
  min={2700}
  max={6500}
  step={100}
  largeStep={500}
  defaultValue={4500}
  ticks={4}
  snapToTicks
  formatValue={(value) => `${value} K`}
/>

缓冲进度

buffer 是「已缓冲到的值」——比如视频已经加载到第 N 秒。它会在已选填充下面再画一段从 min 到 buffer 的次级填充,颜色比已选填充淡一档,用来区分「已加载」与「已播放」。只有单值模式生效,区间模式会忽略它。

<ValueSlider
  aria-label="播放进度"
  min={0}
  max={214}
  defaultValue={62}
  buffer={102}
/>

格式化

format 是 Intl.NumberFormat 的选项,locale 默认 zh-CN,读数的小数位默认按 step 推导(0.5 显示一位)。formatValue 优先于 format,返回的字符串同时用于右侧读数与手柄的 aria-valuetext,适合加单位、正负号或把数字换成词语。

<ValueSlider
  label="曝光"
  min={-100}
  max={100}
  defaultValue={0}
  formatValue={(value) => (value > 0 ? `+${value}` : String(value))}
/>

直接输入

读数平时是文字,editable 让它变成入口:点一下读数,或在手柄上按回车,原地换成一个输入框,读数变成可编辑的虚线。回车或失焦提交,Esc 放弃;输入里的千分位、单位之类会被忽略,只取数字部分,再夹到 min–max 之间并对齐到 step。回车提交后焦点还给手柄,键盘用户可以接着用方向键微调。

<ValueSlider
  label="宽度"
  min={480}
  max={3840}
  step={40}
  editable
  defaultValue={1920}
  formatValue={(value) => `${value}px`}
/>

一次输入当作一次提交(触发 onValueCommitted),不放刻度音效,也不经过中间的过渡值。区间模式下不可输入(输入框只有一个,说不清改哪个手柄),disabled 时也不可输入。

modified 是给控制面板用的标记:传 true 时标签与读数从次要色提到前景色,根元素带上 data-modified。它不改变值,只是「这一项已改动」的视觉与钩子,通常写成 modified={value !== defaultValue}。

<ValueSlider
  label="高度"
  editable
  modified={height !== 1080}
  value={height}
  onValueChange={setHeight}
/>

区间

值传数组即为区间:数组长度决定手柄数量,读数显示为「起点 – 终点」。数组在内部按升序处理,label 是字符串时手柄名称自动加上「起点」「终点」。两个手柄相遇时的行为由 Base UI 的 thumbCollisionBehavior 决定(默认 push),最小间隔由 minStepsBetweenValues 控制。

<ValueSlider
  label="价格"
  min={0}
  max={2000}
  step={50}
  defaultValue={[200, 1200]}
  format={{ style: "currency", currency: "CNY", maximumFractionDigits: 0 }}
/>

音效

sound 打开音效:值变化时(拖动、点击轨道、键盘)播放刻度音,碰到两端播放碰边音。音效默认关闭,浏览器要求先有用户操作才能出声,所以音频在第一次交互时才初始化。

import { useValueSliderMuted } from "@/components/ui/value-slider/value-slider"

const [muted, setMuted] = useValueSliderMuted()

<ValueSlider label="音量" defaultValue={40} sound />
<button type="button" onClick={() => setMuted(!muted)}>
  {muted ? "打开音效" : "静音"}
</button>

静音偏好与站内其他带音效的组件共用(localStorage 的 travinsy:sound:muted):同一页面里一处静音,其他组件的静音按钮与音效立刻跟上;换到另一个标签页也会同步。

示例

每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整过冲强度、尺寸、高度档、色调、刻度和磁吸,并开关读数与音效;未改动的项显示示例自己的取值。换尺寸档时,把滑块放进固定高度插槽的示例画框不会跳;换成 slim 时标签与读数会从轨道里移到上方、额外占一行,画框可能随之变高。

轨道高度

variant 是三档高度,外观都是同一种方条(细边框、淡色分层填充、竖线手柄)。默认的 bar 最高,标签与读数画在轨道里;compact 是中等高度,文字仍在轨道里、字号收到 xs,适合工具面板里一排排的参数;slim 是矮轨,标签与读数移到轨道上方,竖线手柄比轨道高出一截。下面这张画笔卡片里,笔刷大小与不透明度用 variant="compact",平滑度用 variant="slim" 并带上刻度(ticks={4})。

正在加载…

音量与音效

sound 打开拖动时的刻度音效,startIcon 随值在三个喇叭图标之间切换,旁边的静音入口用 useValueSliderMuted 读写同一份偏好。

正在加载…

价格区间

数组值给两个手柄,labelPlacement="outside" 让两个手柄常驻可见,format 用 Intl 的货币格式;上方的柱子落在区间内时取主题色,范围外的退成灰色。

正在加载…

图片调色

三条滑块实时改 CSS filter 的 brightness、contrast、saturate,曝光用 ticks={[0]} 加 snapToTicks 让 0 点磁吸,formatValue 补上正负号。

正在加载…

播放进度

variant="slim" + size="sm" 收成播放器里的细进度条,没有可见 label 时用 aria-label 提供名称;读数写成「已播 / 总长」显示在轨道上方,buffer 画出比当前进度多 40 秒的已缓冲段。

正在加载…

心情评分

min={1}、max={5}、step={1} 配 ticks={4} 得到五档,formatValue 把数字换成词语,读屏读到的也是词语。

正在加载…

直接输入

导出尺寸卡片,三条滑块都开了 editable:点读数就能敲数字,回车确认、Esc 放弃。改过的项用 modified 把标签挑到前景色,一眼看出哪几项此时不是默认值。

正在加载…

API

ValueSlider

默认 min={0}、max={100}、step={1}、largeStep={10}、minStepsBetweenValues={0}、showValue、elasticity={12}、size="default"、variant="bar"、labelPlacement="inside"、locale="zh-CN";snapToTicks、sound、editable、modified、disabled 默认关闭,tone 不传时轨道用前景色,buffer 不传时不画缓冲段。

轨道高度由 size 与 variant 共同决定:bar 是 sm 28px、default 32px、lg 36px;compact 是 18px、22px、24px;slim 是 6px、8px、10px,竖线手柄依次高 14px、16px、18px,命中区至少 20px 高,矮轨也好按。读数小数位由 step 推导,format / formatValue 可以覆盖。

除上表外,其余属性直接交给 Base UI 的 Slider.Root:form(关联到页面上的某个表单)、id、minStepsBetweenValues(区间两个手柄之间至少隔几个 step)、thumbCollisionBehavior(push / swap / none,默认 push)。orientation、thumbAlignment 之类会影响横向布局的属性由组件接管,没有对外暴露——组件固定用 thumbAlignment="edge",手柄落在两端时贴着轨道内侧对齐,不会探出边框半截。

ValueSliderTuningContext

const ValueSliderTuningContext: React.Context<ValueSliderTuning>

可选的覆盖层:子树里所有 ValueSlider 的 elasticity、size、variant、tone、ticks、snapToTicks、showValue、sound 会被 Context 里的值替换,onReport 回报组件自己的实际取值。不发这个 Context 时组件行为不变,正常使用不需要它。

useValueSliderMuted

function useValueSliderMuted(): readonly [
  muted: boolean,
  setMuted: (muted: boolean) => void,
]

读写音效的静音偏好,存在 localStorage 的 travinsy:sound:muted 里,与站内其他带音效的组件共用同一份偏好:同一页面的所有消费者共享(写入后广播 travinsy:sound-muted-change 事件,不依赖只有跨标签页才触发的 storage),也在标签页之间同步;服务端与水合阶段按未静音渲染。

valueSliderVariants

cva 生成的根元素样式函数,有 size 与 variant 两个变体;写自己的触发器或包装层时可以复用它保持一致。轨道高度与手柄直径是通过根元素上的 CSS 变量(--value-slider-track-height、--value-slider-thumb-size)传给内部轨道与手柄的。

const valueSliderVariants: (props?: {
  size?: ValueSliderSize
  variant?: ValueSliderVariant
}) => string

类型

type ValueSliderValue = number | readonly number[]

type ValueSliderSize = "sm" | "default" | "lg"

type ValueSliderVariant = "bar" | "compact" | "slim"

type ValueSliderLabelPlacement = "inside" | "outside"

type ValueSliderProps<Value extends ValueSliderValue = number> = {
  value?: Value
  defaultValue?: Value
  min?: number
  max?: number
  step?: number
  largeStep?: number
  minStepsBetweenValues?: number
  disabled?: boolean
  name?: string
  onValueChange?: (value: Value) => void
  onValueCommitted?: (value: Value) => void
  format?: Intl.NumberFormatOptions
  locale?: Intl.LocalesArgument
  label?: React.ReactNode
  labelPlacement?: ValueSliderLabelPlacement
  "aria-label"?: string
  buffer?: number
  formatValue?: (value: number) => string
  showValue?: boolean
  startIcon?: React.ReactNode
  endIcon?: React.ReactNode
  ticks?: number | readonly number[]
  snapToTicks?: boolean
  elasticity?: number
  size?: ValueSliderSize
  variant?: ValueSliderVariant
  tone?: FlowTone
  editable?: boolean
  modified?: boolean
  sound?: boolean
  className?: string
  style?: React.CSSProperties
}

type ValueSliderTuning = Partial<
  Pick<
    ValueSliderProps,
    | "elasticity"
    | "size"
    | "variant"
    | "ticks"
    | "snapToTicks"
    | "showValue"
    | "sound"
  >
> & {
  /** `false` 表示去掉色调 */
  tone?: FlowTone | false
  onReport?: (report: ValueSliderReport) => void
}

type ValueSliderReport = {
  elasticity: number
  size: ValueSliderSize
  variant: ValueSliderVariant
  tone: FlowTone | undefined
  /** 示例用显式刻度数组时是 undefined,表示「没有段数可报」 */
  ticks: number | undefined
  snapToTicks: boolean
  showValue: boolean
  sound: boolean
}

示例用显式刻度数组时,调整面板的刻度滑块显示为默认的 0;一旦拖动刻度滑块,就换成等分刻度(刻度数组无法用段数表达)。

data-slot

元素data-slot
根元素value-slider
轨道上方的文字value-slider-labels
交互区value-slider-control
轨道外框value-slider-rail
轨道value-slider-track
缓冲填充value-slider-buffer
已选填充value-slider-indicator
刻度线value-slider-tick
标签value-slider-label
读数value-slider-value
输入框value-slider-input
手柄value-slider-thumb
左端图标value-slider-start-icon
右端图标value-slider-end-icon

value-slider-labels 只在 labelPlacement="outside"(以及 variant="slim")时出现,label 与读数都放在它里面;inside 时这两个 data-slot 落在轨道内。value-slider-input 只在 editable 且正在输入时出现,临时替掉同一位置的读数。根元素在过冲时带 data-overshoot(start 往左、end 往右),禁用时带 data-disabled,拖动中带 data-dragging,手柄压住文字时带 data-near="true",modified 为真时带 data-modified,并且始终带 data-variant(bar / compact / slim)。

可访问性

  • 手柄是原生输入元素,键盘操作:← → ↓ ↑ 加减一个 step,Shift + 方向键与 PageUp / PageDown 按 largeStep,Home / End 到两端;区间时 Home / End 不会越过另一个手柄。RTL 布局下左右方向键的增减是反的(方向取自 Base UI 的 DirectionProvider,需要在应用里包一层 <DirectionProvider direction="rtl">,同时给页面设 dir="rtl")。
  • 读屏名称优先取 aria-label;没有时用字符串 label(区间自动补「起点」「终点」);两者都没有时退回「数值」,所以同一个表单里有多个滑块时务必给 label 或 aria-label,否则几个手柄会重名。
  • aria-valuetext 是格式化后的读数,所以 format / formatValue 的写法会直接决定读屏怎么念。
  • 焦点环只在键盘聚焦时出现(:focus-visible),鼠标点击轨道不会留下焦点框。Control 上有一圈整体焦点环,聚焦的那个手柄还会自己出环,区间模式下能看出当前在调哪个手柄,并从 4px 加粗到 6px。
  • 系统开启「减少动态效果」时不播放过冲与弹簧回弹,轨道不会被拉伸。
  • editable 时读数是一个可点的文字入口:鼠标直接点,键盘在手柄上按 Enter 进入输入(回车被拦下,不会提交外层表单)。输入框自带名称(输入 + 手柄名称),回车提交后焦点回到手柄,Esc 放弃并同样还回焦点;失焦提交时不抢焦点。
  • 缓冲填充是 aria-hidden 的装饰,读屏只报当前值。
  • 音效默认关闭。打开后请同时提供静音入口(useValueSliderMuted),音效只是点缀,不承载信息;静音偏好存在 localStorage 的 travinsy:sound:muted,与站内其他带音效的组件共用。

注意事项

  • 组件在服务端就输出完整结构(手柄按 left% 定位、填充有宽度),不需要预水合脚本,禁用 JS 时也能看到当前值。
  • 拖动期间的过冲动画由 MotionValue 直接改 transform,不触发 React 渲染;数值变化本身仍会渲染读数,所以一次拖动里读数会跟着刷新。
  • 用作表单字段时传 name,值会随表单提交;受控模式下记得同时处理 onValueChange,否则拖动后值会弹回。需要挂到页面另一处的表单上时再传 form。
  • format 对象、formatValue 函数与 ticks 数组建议定义在组件外,避免每次渲染都重建格式化器或刻度表。
  • 组件用一次 ResizeObserver 观测轨道与文字宽度,服务端输出的是完整静态结构;首帧还没量到宽度时,落在两端 15% 以内的手柄先按「压在文字上」处理,量到之后按真实文字宽度判断。slim 的文字在轨道外,不做让位。
  • tone 的颜色变量由 flow-diagram 条目提供,安装时会一并拉取;手动安装需要自己补上 --flow-* 变量。

源码

components/ui/value-slider/value-slider.tsx
"use client"

import * as React from "react"
import { useDirection } from "@base-ui/react/direction-provider"
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
import { cva } from "class-variance-authority"
import { motion } from "motion/react"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"

import { playEdge, playTick, useValueSliderMuted } from "./sound"
import type {
  ValueSliderProps,
  ValueSliderTuning,
  ValueSliderValue,
} from "./types"
import { useElasticPull } from "./use-elastic-pull"
import { useHandleDodge } from "./use-handle-dodge"
import {
  ValueSliderReadout,
  ValueSliderThumb,
  ValueSliderTrack,
  type ValueSliderEditor,
} from "./value-slider-parts"
import {
  clamp01,
  crossedTick,
  resolveTicks,
  sameValue,
  snapValuesToTicks,
  stepDecimals,
  toNumberList,
  valueToPercent,
} from "./utils"

/** 刻度音效的最短间隔(毫秒),拖动时值变化很快,太密会糊成噪声。 */
const TICK_INTERVAL = 35
/** 碰边音效的最短间隔(毫秒),磁吸与过冲可能同时报同一次碰边。 */
const EDGE_INTERVAL = 250
/** 磁吸判定:与最近刻度的距离不超过整个范围的这个比例时吸过去。 */
const SNAP_RATIO = 0.03

const valueSliderVariants = cva("flex w-full flex-col select-none", {
  variants: {
    size: {
      sm: "gap-1 text-xs [--value-slider-height:1.75rem]",
      default: "gap-1 text-sm [--value-slider-height:2rem]",
      lg: "gap-1 text-sm [--value-slider-height:2.25rem]",
    },
    variant: {
      // 三种形态都是方条,只是高度不同:bar 沿用尺寸档的高度,compact / slim 在 compoundVariants 里按尺寸细分
      bar: "[--value-slider-track-height:var(--value-slider-height)]",
      // 中等高度:轨道里仍放标签与读数,字号统一收到 xs
      compact: "text-xs",
      // 矮轨:文字移到上方,手柄比轨道高出一截
      slim: "gap-1.5",
    },
  },
  compoundVariants: [
    {
      variant: "compact",
      size: "sm",
      class: "[--value-slider-track-height:1.125rem]",
    },
    {
      variant: "compact",
      size: "default",
      class: "[--value-slider-track-height:1.375rem]",
    },
    {
      variant: "compact",
      size: "lg",
      class: "[--value-slider-track-height:1.5rem]",
    },
    {
      variant: "slim",
      size: "sm",
      class:
        "[--value-slider-track-height:0.375rem] [--value-slider-thumb-height:0.875rem]",
    },
    {
      variant: "slim",
      size: "default",
      class:
        "[--value-slider-track-height:0.5rem] [--value-slider-thumb-height:1rem]",
    },
    {
      variant: "slim",
      size: "lg",
      class:
        "[--value-slider-track-height:0.625rem] [--value-slider-thumb-height:1.125rem]",
    },
  ],
  defaultVariants: {
    size: "default",
    variant: "bar",
  },
})

/**
 * 可选的调整上下文:子树里所有 ValueSlider 的对应属性被这里的值覆盖。
 * 用于调试面板一类的场景;不包这一层时组件行为不变。
 */
const ValueSliderTuningContext = React.createContext<ValueSliderTuning>({})

/** 带标签与读数的数值滑块,拖出两端时有橡皮筋过冲与弹簧回弹。 */
function ValueSlider<Value extends ValueSliderValue = number>(
  allProps: ValueSliderProps<Value>,
) {
  const { onReport, ...overrides } = React.useContext(ValueSliderTuningContext)
  const {
    value: valueProp,
    defaultValue,
    onValueChange,
    onValueCommitted,
    min = 0,
    max = 100,
    step = 1,
    largeStep = 10,
    minStepsBetweenValues = 0,
    disabled = false,
    name,
    format,
    locale,
    label,
    "aria-label": ariaLabel,
    labelPlacement = "inside",
    buffer,
    formatValue,
    showValue: showValueProp = true,
    startIcon,
    endIcon,
    ticks: ticksProp,
    snapToTicks: snapToTicksProp = false,
    elasticity: elasticityProp = 12,
    size: sizeProp = "default",
    variant: variantProp = "bar",
    tone: toneProp,
    sound: soundProp = false,
    editable = false,
    modified = false,
    className,
    style,
    ...props
  } = allProps

  // 回报示例自己的取值(覆盖前),面板打开时显示的就是示例现在的样子
  React.useEffect(() => {
    onReport?.({
      elasticity: elasticityProp,
      size: sizeProp,
      variant: variantProp,
      tone: toneProp,
      // 显式刻度数组没有「段数」可言,报 undefined 让面板显示默认值
      ticks: typeof ticksProp === "number" ? ticksProp : undefined,
      snapToTicks: snapToTicksProp,
      showValue: showValueProp,
      sound: soundProp,
    })
  }, [
    onReport,
    elasticityProp,
    sizeProp,
    variantProp,
    toneProp,
    ticksProp,
    snapToTicksProp,
    showValueProp,
    soundProp,
  ])

  const elasticity = Math.max(0, overrides.elasticity ?? elasticityProp)
  const size = overrides.size ?? sizeProp
  const variant = overrides.variant ?? variantProp
  const ticks = overrides.ticks ?? ticksProp
  const snapToTicks = overrides.snapToTicks ?? snapToTicksProp
  const showValue = overrides.showValue ?? showValueProp
  const sound = overrides.sound ?? soundProp
  const tone =
    overrides.tone === false ? undefined : (overrides.tone ?? toneProp)
  const hasTone = tone !== undefined
  // slim 的轨道只有几像素高,文字放不进去,一律摆到上方,等同 labelPlacement="outside"
  const slim = variant === "slim"
  const outside = slim || labelPlacement === "outside"

  // 读数、碰边判断都基于当前值,所以值统一收在这里,再以受控方式交给 Base UI
  const [value, setValue] = useControllableState<Value>({
    prop: valueProp,
    defaultProp: (defaultValue ?? min) as Value,
    onChange: onValueChange,
  })
  const values = React.useMemo(() => toNumberList(value), [value])
  const isRange = values.length > 1

  const direction = useDirection()
  const [muted] = useValueSliderMuted()
  const soundOn = sound && !muted
  const soundOnRef = React.useRef(soundOn)
  React.useEffect(() => {
    soundOnRef.current = soundOn
  })

  const decimals = stepDecimals(step)
  const formatNumber = React.useMemo(() => {
    if (formatValue) return formatValue
    const formatter = new Intl.NumberFormat(locale ?? "zh-CN", {
      minimumFractionDigits: 0,
      maximumFractionDigits: decimals,
      ...format,
    })
    return (item: number) => formatter.format(item)
  }, [formatValue, locale, format, decimals])

  const tickList = React.useMemo(
    () => resolveTicks(ticks, min, max),
    [ticks, min, max],
  )
  const innerTicks = React.useMemo(
    () => tickList.filter((tick) => tick > min && tick < max),
    [tickList, min, max],
  )

  /* ---------- 音效 ---------- */

  const lastTickRef = React.useRef(0)
  const lastEdgeRef = React.useRef(0)
  const previousValuesRef = React.useRef<number[] | null>(null)

  const playEdgeOnce = React.useCallback(() => {
    const now = performance.now()
    if (now - lastEdgeRef.current < EDGE_INTERVAL) return
    lastEdgeRef.current = now
    if (soundOnRef.current) playEdge()
  }, [])

  const handleValueChange = (next: Value) => {
    const list = toNumberList(next)
    const previous = previousValuesRef.current
    previousValuesRef.current = list
    setValue(next)

    // 音效跟着变化最大的那个手柄走
    let index = 0
    let from = previous?.[0]
    if (previous && previous.length === list.length) {
      let largest = -1
      list.forEach((item, itemIndex) => {
        const delta = Math.abs(item - (previous[itemIndex] ?? item))
        if (delta > largest) {
          largest = delta
          index = itemIndex
          from = previous[itemIndex]
        }
      })
    }
    const changed = list[index] ?? min
    if (changed <= min || changed >= max) playEdgeOnce()
    if (!soundOn) return
    const crossed = from !== undefined && crossedTick(from, changed, tickList)
    const stepped =
      from === undefined || Math.abs(changed - from) >= step - 1e-9
    if (tickList.length > 0 ? crossed : stepped) {
      const now = performance.now()
      if (now - lastTickRef.current >= TICK_INTERVAL) {
        lastTickRef.current = now
        playTick(clamp01((changed - min) / Math.max(1e-6, max - min)))
      }
    }
  }

  /**
   * 只有指针交互松开时才磁吸。
   * Base UI 对每一次键盘变更也会调 `onValueCommitted`(reason 为 `"keyboard"`),
   * 在那里吸附会让值被反复拉回最近的刻度,方向键就只能在刻度上原地挣扎。
   */
  const handleValueCommitted = (
    next: Value,
    details: SliderPrimitive.Root.CommitEventDetails,
  ) => {
    let committed = next
    const fromPointer =
      details.reason === "drag" || details.reason === "track-press"
    if (snapToTicks && fromPointer && tickList.length > 0) {
      const list = toNumberList(next)
      const snapped = snapValuesToTicks(list, {
        ticks: tickList,
        min,
        max,
        step,
        minGapSteps: minStepsBetweenValues,
        tolerance: (max - min) * SNAP_RATIO,
      })
      if (!sameValue(snapped, list)) {
        const result = (snapped.length === 1 ? snapped[0] : snapped) as Value
        setValue(result)
        committed = result
      }
    }
    onValueCommitted?.(committed)
  }

  /* ---------- 过冲 ---------- */

  const elastic = useElasticPull({ elasticity, disabled, onEdge: playEdgeOnce })

  const handleThumbKeyDown = (
    event: React.KeyboardEvent<HTMLInputElement>,
    index: number,
  ) => {
    if (event.key === "Enter" && editor) {
      event.preventDefault()
      editor.start()
      return
    }
    const current = values[index] ?? min
    // RTL 时左右方向键的增减是反的;方向取自 Base UI 的 DirectionProvider,与它自己的按键映射一致
    const rtl = direction === "rtl"
    // 端点脉冲是物理方向的:RTL 时 min 在右边,所以两侧要互换
    const minSide = rtl ? "end" : "start"
    const maxSide = rtl ? "start" : "end"
    const towardStart =
      event.key === (rtl ? "ArrowRight" : "ArrowLeft") ||
      event.key === "ArrowDown" ||
      event.key === "PageDown" ||
      event.key === "Home"
    const towardEnd =
      event.key === (rtl ? "ArrowLeft" : "ArrowRight") ||
      event.key === "ArrowUp" ||
      event.key === "PageUp" ||
      event.key === "End"
    if (towardStart && current <= min) elastic.pulse(minSide)
    else if (towardEnd && current >= max) elastic.pulse(maxSide)
  }

  /* ---------- 手柄让开轨道内的文字 ---------- */

  const insideText = !outside && (label !== undefined || showValue)
  const { trackRef, labelRef, valueRef, isNear } = useHandleDodge(insideText)

  const thumbName = ariaLabel ?? (typeof label === "string" ? label : "数值")
  const readout = isRange
    ? `${formatNumber(values[0])} – ${formatNumber(values[values.length - 1])}`
    : formatNumber(values[0] ?? min)
  /* ---------- 读数手动输入 ---------- */

  const [editing, setEditing] = React.useState(false)
  const [draft, setDraft] = React.useState("")
  // 回车提交后输入框卸载,可能紧跟着一次失焦提交;用 ref 保证只提交一次
  const editingRef = React.useRef(false)
  const canEdit = editable && !isRange && !disabled

  const focusThumb = (from: HTMLElement) => {
    const root = from.closest<HTMLElement>('[data-slot="value-slider"]')
    // 等输入框卸载后再聚焦,否则焦点会落回正在消失的元素
    requestAnimationFrame(() =>
      root
        ?.querySelector<HTMLInputElement>(
          '[data-slot="value-slider-thumb"] input',
        )
        ?.focus(),
    )
  }

  const editor: ValueSliderEditor | undefined = canEdit
    ? {
        editing,
        draft,
        label: `输入${thumbName}`,
        start: () => {
          editingRef.current = true
          setDraft((values[0] ?? min).toFixed(decimals))
          setEditing(true)
        },
        change: setDraft,
        commit: (from, refocus) => {
          if (!editingRef.current) return
          editingRef.current = false
          setEditing(false)
          if (refocus) focusThumb(from)
          // 容忍千分位与单位:只取数字部分;解析不出数字时当作放弃
          const parsed = Number.parseFloat(draft.replace(/[^\d.+-]/g, ""))
          if (!Number.isFinite(parsed)) return
          const stepped = min + Math.round((parsed - min) / step) * step
          const next = Number(
            Math.min(max, Math.max(min, stepped)).toFixed(decimals),
          )
          if (next === values[0]) return
          // 手动输入是一次性跳到目标值,不放刻度音效,直接写值并当作一次提交
          previousValuesRef.current = [next]
          setValue(next as unknown as Value)
          onValueCommitted?.(next as unknown as Value)
        },
        cancel: (from) => {
          editingRef.current = false
          setEditing(false)
          focusThumb(from)
        },
      }
    : undefined

  const bufferPercent =
    buffer === undefined || isRange
      ? undefined
      : valueToPercent(buffer, min, max)

  return (
    <SliderPrimitive.Root
      {...props}
      value={value}
      onValueChange={(next) => handleValueChange(next as unknown as Value)}
      onValueCommitted={(next, details) =>
        handleValueCommitted(next as unknown as Value, details)
      }
      min={min}
      max={max}
      step={step}
      largeStep={largeStep}
      minStepsBetweenValues={minStepsBetweenValues}
      // 手柄在两端时贴着轨道内侧,不伸出边框(默认的 center 会探出半个手柄)
      thumbAlignment="edge"
      disabled={disabled}
      name={name}
      format={format}
      locale={locale}
      data-slot="value-slider"
      data-variant={variant}
      data-modified={modified ? "" : undefined}
      data-overshoot={elastic.side ?? undefined}
      data-disabled={disabled ? "" : undefined}
      className={cn(
        valueSliderVariants({ size, variant }),
        "group/value-slider data-[disabled]:opacity-50",
        className,
      )}
      style={{ ...flowToneStyle(tone), ...style }}
    >
      {outside ? (
        <div
          data-slot="value-slider-labels"
          className="flex items-baseline justify-between gap-2 px-0.5"
        >
          <span
            data-slot="value-slider-label"
            className="truncate font-medium text-muted-foreground transition-colors group-data-[modified]/value-slider:text-foreground"
          >
            {label}
          </span>
          {showValue ? (
            <ValueSliderReadout
              readout={readout}
              editor={editor}
              inside={false}
            />
          ) : null}
        </div>
      ) : null}
      <div className="flex w-full min-w-0 items-center gap-2">
        {startIcon ? (
          <motion.span
            data-slot="value-slider-start-icon"
            aria-hidden="true"
            className="flex shrink-0 items-center text-muted-foreground [&_svg:not([class*='size-'])]:size-4"
            style={{ x: elastic.startIconX, scale: elastic.startIconScale }}
          >
            {startIcon}
          </motion.span>
        ) : null}
        {/* Control 只负责把指针换算成数值,自己不缩放;缩放在它内部的视觉层上。
            slim 的轨道只有几像素高,给 Control 一个 20px 的命中区,视觉层在其中垂直居中 */}
        <SliderPrimitive.Control
          data-slot="value-slider-control"
          className={cn(
            "relative w-full min-w-0 cursor-pointer touch-none select-none in-data-[disabled]:cursor-not-allowed has-[[data-slot=value-slider-thumb]_input:focus-visible]:ring-3 has-[[data-slot=value-slider-thumb]_input:focus-visible]:ring-ring/50",
            slim && "flex min-h-5 items-center",
          )}
          onPointerDown={elastic.onPointerDown}
        >
          <motion.div
            data-slot="value-slider-rail"
            className="relative w-full origin-center"
            style={{
              x: elastic.x,
              scaleX: elastic.scaleX,
              scaleY: elastic.scaleY,
            }}
          >
            <ValueSliderTrack
              trackRef={trackRef}
              labelRef={labelRef}
              valueRef={valueRef}
              variant={variant}
              outside={outside}
              label={label}
              readout={readout}
              showValue={showValue}
              editor={editor}
              bufferPercent={bufferPercent}
              hasTone={hasTone}
              ticks={innerTicks}
              min={min}
              max={max}
            />
            {values.map((item, index) => (
              <ValueSliderThumb
                key={index}
                index={index}
                variant={variant}
                near={isNear(valueToPercent(item, min, max))}
                hasTone={hasTone}
                outside={outside}
                label={
                  isRange
                    ? `${thumbName}(${index === 0 ? "起点" : "终点"})`
                    : thumbName
                }
                valueText={formatNumber(item)}
                onKeyDown={(event) => handleThumbKeyDown(event, index)}
              />
            ))}
          </motion.div>
        </SliderPrimitive.Control>
        {endIcon ? (
          <motion.span
            data-slot="value-slider-end-icon"
            aria-hidden="true"
            className="flex shrink-0 items-center text-muted-foreground [&_svg:not([class*='size-'])]:size-4"
            style={{ x: elastic.endIconX, scale: elastic.endIconScale }}
          >
            {endIcon}
          </motion.span>
        ) : null}
      </div>
    </SliderPrimitive.Root>
  )
}

export {
  ValueSlider,
  ValueSliderTuningContext,
  useValueSliderMuted,
  valueSliderVariants,
}
export type {
  ValueSliderLabelPlacement,
  ValueSliderProps,
  ValueSliderReport,
  ValueSliderSize,
  ValueSliderTuning,
  ValueSliderValue,
  ValueSliderVariant,
} from "./types"
components/ui/value-slider/value-slider-parts.tsx
"use client"

import * as React from "react"
import { Slider as SliderPrimitive } from "@base-ui/react/slider"

import { cn } from "@/lib/utils"

import type { ValueSliderVariant } from "./types"
import { valueToPercent } from "./utils"

type ValueSliderTrackProps = {
  /** 观测轨道宽度,用于手柄让位 */
  trackRef: React.RefObject<HTMLDivElement | null>
  labelRef: React.RefObject<HTMLSpanElement | null>
  valueRef: React.RefObject<HTMLSpanElement | null>
  variant: ValueSliderVariant
  /** 文字放在轨道上方时(`labelPlacement="outside"`,以及 `slim`)轨道里不再渲染它们 */
  outside: boolean
  label: React.ReactNode
  readout: string
  showValue: boolean
  /** 读数的手动输入;`undefined` 表示不可输入 */
  editor: ValueSliderEditor | undefined
  /** 缓冲填充的百分比;`undefined` 表示不画 */
  bufferPercent: number | undefined
  hasTone: boolean
  ticks: readonly number[]
  min: number
  max: number
}

/**
 * 轨道与它内部的视觉层:缓冲填充、已选填充、刻度线、轨道内的标签与读数。
 * 只负责画,交互全部交给上层的 `Slider.Control` 与手柄。
 * 三种形态共用同一套方条语言(细边框、小圆角、淡色分层填充),只是高度不同;
 * `slim` 只有几像素高,填充加深一档,否则淡色叠在淡色上看不出进度。
 */
function ValueSliderTrack({
  trackRef,
  labelRef,
  valueRef,
  variant,
  outside,
  label,
  readout,
  showValue,
  editor,
  bufferPercent,
  hasTone,
  ticks,
  min,
  max,
}: ValueSliderTrackProps) {
  const slim = variant === "slim"

  return (
    <SliderPrimitive.Track
      ref={trackRef}
      data-slot="value-slider-track"
      className={cn(
        "group/track relative h-(--value-slider-track-height) w-full overflow-hidden border border-edge bg-muted/60",
        slim ? "rounded-xs" : "rounded-md",
      )}
    >
      {/* 缓冲段画在已选填充下面:先出现的是「已加载」,盖上去的是「已播放」 */}
      {bufferPercent === undefined ? null : (
        <span
          data-slot="value-slider-buffer"
          aria-hidden="true"
          className={cn(
            "absolute inset-y-0 start-0",
            hasTone
              ? slim
                ? "bg-(--flow-tone)/20"
                : "bg-(--flow-tone)/10"
              : "bg-foreground/10 dark:bg-foreground/15",
          )}
          style={{ width: `${bufferPercent}%` }}
        />
      )}
      <SliderPrimitive.Indicator
        data-slot="value-slider-indicator"
        className={cn(
          "h-full",
          hasTone
            ? slim
              ? "bg-(--flow-tone)/45 dark:bg-(--flow-tone)/60"
              : "bg-(--flow-tone)/20 dark:bg-(--flow-tone)/35"
            : slim
              ? "bg-foreground/25 dark:bg-foreground/30"
              : "bg-foreground/10 dark:bg-foreground/15",
        )}
      />
      {ticks.map((tick) => (
        <span
          key={tick}
          data-slot="value-slider-tick"
          aria-hidden="true"
          className="pointer-events-none absolute top-1/2 h-2 w-px -translate-y-1/2 bg-foreground/15 transition-colors group-hover/track:bg-foreground/30 in-data-[dragging]:bg-foreground/30"
          // 逻辑属性定位:LTR 从左边、RTL 从右边量;自己减掉半个线宽,两个方向都是居中的
          style={{
            insetInlineStart: `calc(${valueToPercent(tick, min, max)}% - 0.5px)`,
          }}
        />
      ))}
      {label !== undefined && !outside ? (
        <span
          ref={labelRef}
          data-slot="value-slider-label"
          aria-hidden="true"
          className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 font-medium whitespace-nowrap text-muted-foreground transition-colors group-data-[modified]/value-slider:text-foreground"
        >
          {label}
        </span>
      ) : null}
      {showValue && !outside ? (
        <ValueSliderReadout
          valueRef={valueRef}
          readout={readout}
          editor={editor}
          inside
        />
      ) : null}
    </SliderPrimitive.Track>
  )
}

/** 读数手动输入的状态与动作,由根组件持有,读数在轨道里或轨道上方都用同一份。 */
type ValueSliderEditor = {
  editing: boolean
  draft: string
  /** 输入框的可访问名称 */
  label: string
  start: () => void
  change: (draft: string) => void
  /** 提交草稿;`refocus` 为真时把焦点还给手柄(回车提交),失焦提交时不抢焦点 */
  commit: (from: HTMLElement, refocus: boolean) => void
  cancel: (from: HTMLElement) => void
}

type ValueSliderReadoutProps = {
  valueRef?: React.RefObject<HTMLSpanElement | null>
  readout: string
  editor: ValueSliderEditor | undefined
  /** 画在轨道里(绝对定位在右端)还是轨道上方的标签行里 */
  inside: boolean
}

/**
 * 读数。可输入时点一下(或在手柄上按回车)换成输入框:回车或失焦提交,Esc 放弃。
 * 读数和输入框都拦下 `pointerdown`,否则点读数会被 `Slider.Control` 当成点轨道、把值跳过去。
 * 读数本身对读屏隐藏(数值由手柄的 `aria-valuetext` 朗读),键盘用户从手柄按回车进入输入。
 */
function ValueSliderReadout({
  valueRef,
  readout,
  editor,
  inside,
}: ValueSliderReadoutProps) {
  const stop = (event: React.PointerEvent) => event.stopPropagation()

  if (editor?.editing) {
    return (
      <input
        data-slot="value-slider-input"
        aria-label={editor.label}
        autoFocus
        inputMode="decimal"
        value={editor.draft}
        onChange={(event) => editor.change(event.target.value)}
        onPointerDown={stop}
        // 进入编辑时整段选中,直接敲数字就是替换
        onFocus={(event) => event.currentTarget.select()}
        onBlur={(event) => editor.commit(event.currentTarget, false)}
        onKeyDown={(event) => {
          // 别让方向键、回车冒泡到 Slider 上去改值
          event.stopPropagation()
          if (event.key === "Enter") {
            event.preventDefault()
            editor.commit(event.currentTarget, true)
          } else if (event.key === "Escape") {
            event.preventDefault()
            editor.cancel(event.currentTarget)
          }
        }}
        className={cn(
          // 嵌在读数原位的一小块:背景色底 + 色调细描边表示「正在编辑」,不再叠加粗焦点环
          "w-[8ch] rounded-sm bg-background px-1.5 text-end font-mono text-foreground tabular-nums shadow-xs ring-1 ring-[color-mix(in_oklab,var(--flow-tone,var(--foreground))_55%,transparent)] outline-none",
          inside
            ? "absolute top-1/2 right-1 h-[max(1rem,calc(100%-0.375rem))] -translate-y-1/2"
            : "h-5",
        )}
      />
    )
  }

  return (
    <span
      ref={valueRef}
      data-slot="value-slider-value"
      aria-hidden="true"
      onPointerDown={editor ? stop : undefined}
      onClick={editor?.start}
      className={cn(
        "font-mono whitespace-nowrap text-muted-foreground tabular-nums transition-colors group-hover/value-slider:text-foreground group-data-[modified]/value-slider:text-foreground in-data-[dragging]:text-foreground",
        inside && "absolute top-1/2 right-3 -translate-y-1/2",
        editor
          ? "pointer-events-auto cursor-text decoration-dotted underline-offset-2 hover:underline"
          : inside && "pointer-events-none",
      )}
    >
      {readout}
    </span>
  )
}

type ValueSliderThumbProps = {
  index: number
  variant: ValueSliderVariant
  /** 手柄压在标签或读数上:淡下去让文字可读 */
  near: boolean
  hasTone: boolean
  /** 文字在轨道外时手柄常驻实色,不需要让位 */
  outside: boolean
  label: string
  valueText: string
  onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
}

/**
 * 手柄:一根 4px 的竖线,放在轨道外面一层,端点处不会被轨道的 overflow 裁掉。
 * `bar` / `compact` 里竖线是轨道高度的一半;`slim` 的轨道太矮,竖线反过来比轨道高出一截
 * (`--value-slider-thumb-height`),像一根插在轨道上的标尺指针。
 * 内部是一个视觉隐藏的 `input`,键盘聚焦时由它来给手柄本身加出环(区间模式下 Control 上
 * 的整体焦点环指不出是哪个手柄)。焦点把手柄从 4px 加粗到 6px,并撑满不透明度。
 */
function ValueSliderThumb({
  index,
  variant,
  near,
  hasTone,
  outside,
  label,
  valueText,
  onKeyDown,
}: ValueSliderThumbProps) {
  return (
    <SliderPrimitive.Thumb
      index={index}
      data-slot="value-slider-thumb"
      data-near={near ? "true" : undefined}
      aria-label={label}
      aria-valuetext={valueText}
      className={cn(
        "w-1 rounded-full transition-[width,opacity]",
        variant === "slim" ? "h-(--value-slider-thumb-height)" : "h-1/2",
        "has-[:focus-visible]:w-1.5 has-[:focus-visible]:opacity-100 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50",
        hasTone ? "bg-(--flow-tone)" : "bg-foreground",
        outside
          ? "opacity-100"
          : "opacity-40 group-hover/value-slider:opacity-100 in-data-[dragging]:opacity-100 data-[near=true]:not-has-[:focus-visible]:opacity-20!",
      )}
      onKeyDown={onKeyDown}
    />
  )
}

export { ValueSliderReadout, ValueSliderThumb, ValueSliderTrack }
export type {
  ValueSliderEditor,
  ValueSliderReadoutProps,
  ValueSliderThumbProps,
  ValueSliderTrackProps,
}
components/ui/value-slider/types.ts
import type { Slider as SliderPrimitive } from "@base-ui/react/slider"
import type * as React from "react"

import type { FlowTone } from "@/components/ui/flow-diagram/tone"

type ValueSliderValue = number | readonly number[]
type ValueSliderSize = "sm" | "default" | "lg"
type ValueSliderLabelPlacement = "inside" | "outside"
type ValueSliderVariant = "bar" | "compact" | "slim"

type ValueSliderProps<Value extends ValueSliderValue = number> = Omit<
  SliderPrimitive.Root.Props<Value>,
  | "aria-label"
  | "children"
  | "className"
  | "defaultValue"
  | "disabled"
  | "format"
  | "largeStep"
  | "locale"
  | "max"
  | "min"
  | "minStepsBetweenValues"
  | "name"
  | "onValueChange"
  | "onValueCommitted"
  | "orientation"
  | "render"
  | "step"
  | "style"
  | "thumbAlignment"
  | "value"
> & {
  /** 受控值。传数组即为区间,数组长度决定手柄数量 */
  value?: Value
  /** 非受控时的初始值;不传时取 `min` */
  defaultValue?: Value
  /**
   * 最小值
   * @default 0
   */
  min?: number
  /**
   * 最大值
   * @default 100
   */
  max?: number
  /**
   * 步长,读数的默认小数位也随之变化
   * @default 1
   */
  step?: number
  /**
   * PageUp / PageDown 与 Shift + 方向键的步长
   * @default 10
   */
  largeStep?: number
  /**
   * 区间模式下两个手柄之间至少相隔几个 `step`,也参与磁吸后的合法性判断
   * @default 0
   */
  minStepsBetweenValues?: number
  /**
   * 是否禁用
   * @default false
   */
  disabled?: boolean
  /** 提交到表单时使用的字段名;不传时不生成表单字段 */
  name?: string
  /** 值变化时回调(拖动、键盘、点击轨道都会触发) */
  onValueChange?: (value: Value) => void
  /** 值提交时回调(松手、键盘操作结束) */
  onValueCommitted?: (value: Value) => void
  className?: string
  style?: React.CSSProperties
  /** 数字格式化选项,交给 `Intl.NumberFormat`;不传时按 `locale` 与 `step` 推导 */
  format?: Intl.NumberFormatOptions
  /**
   * 数字格式化的区域,`format` 与默认读数都用它
   * @default "zh-CN"
   */
  locale?: Intl.LocalesArgument
  /** 读屏名称,优先于 `label`;都不传时手柄名称是「数值」 */
  "aria-label"?: string
  /** 标签;是字符串时同时作为手柄的读屏名称 */
  label?: React.ReactNode
  /**
   * 标签与读数的位置:`inside` 放在轨道内两端,`outside` 放在轨道上方一行
   * @default "inside"
   */
  labelPlacement?: ValueSliderLabelPlacement
  /**
   * 已缓冲到的值(例如视频已加载到第 N 秒),在已选填充下面画一段次级填充;仅单值模式生效
   */
  buffer?: number
  /** 读数与 `aria-valuetext` 的格式化函数,优先于 `format` */
  formatValue?: (value: number) => string
  /**
   * 是否显示读数。区间时显示为 `起点 – 终点`
   * @default true
   */
  showValue?: boolean
  /** 轨道左端外的图标,随左侧过冲平移 */
  startIcon?: React.ReactNode
  /** 轨道右端外的图标,随右侧过冲平移 */
  endIcon?: React.ReactNode
  /**
   * 轨道内的刻度线。数字表示等分段数(画 n - 1 条内部刻度线),数组表示显式刻度值;
   * 同时决定 `snapToTicks` 的吸附目标。建议刻度落在 `step` 上,不在的会对齐到最近的 `step`
   */
  ticks?: number | readonly number[]
  /**
   * 指针松手时(拖动或点击轨道)若与最近刻度的距离不超过范围的 3%,吸到该刻度并触发 `onValueChange`;
   * 键盘逐步移动不吸附
   * @default false
   */
  snapToTicks?: boolean
  /**
   * 拖出轨道两端时的最大过冲像素,`0` 关闭弹性
   * @default 12
   */
  elasticity?: number
  /**
   * 尺寸档,与 `variant` 一起决定轨道高度:
   * `bar` 为 sm 28px / default 32px / lg 36px,`compact` 为 18 / 22 / 24px,`slim` 为 6 / 8 / 10px
   * @default "default"
   */
  size?: ValueSliderSize
  /**
   * 轨道高度档,三档都是同一种方条:`bar` 高轨,标签与读数在轨道里;
   * `compact` 中等高度,文字仍在轨道里、字号收到 xs;
   * `slim` 矮轨,标签与读数移到轨道上方(此时忽略 `labelPlacement`),竖线手柄比轨道高出一截
   * @default "bar"
   */
  variant?: ValueSliderVariant
  /**
   * 色调:填充取 `--flow-tone` 的淡色(`slim` 加深一档),手柄取实色;不传时用前景色
   */
  tone?: FlowTone
  /**
   * 读数可直接输入:点读数(或在手柄上按回车)换成输入框,回车 / 失焦提交,Esc 放弃。
   * 输入值会夹在 `min`–`max` 之间并对齐到 `step`;区间模式下不可输入
   * @default false
   */
  editable?: boolean
  /**
   * 标记为「已改动」:标签与读数改用前景色,根元素带 `data-modified`。
   * 控制面板里用来区分改过的参数与默认值
   * @default false
   */
  modified?: boolean
  /**
   * 拖动时是否有刻度与碰边的音效(尊重本地静音偏好)
   * @default false
   */
  sound?: boolean
}

type ValueSliderTuning = Partial<
  Pick<
    ValueSliderProps,
    | "elasticity"
    | "size"
    | "variant"
    | "ticks"
    | "snapToTicks"
    | "showValue"
    | "sound"
  >
> & {
  /** `false` 表示去掉色调 */
  tone?: FlowTone | false
  /** 回报示例自己的实际取值(未被覆盖前),面板据此显示当前值 */
  onReport?: (report: ValueSliderReport) => void
}

type ValueSliderReport = {
  elasticity: number
  size: ValueSliderSize
  variant: ValueSliderVariant
  tone: FlowTone | undefined
  /** 示例用显式刻度数组时回报 `undefined`,面板显示默认的 0 */
  ticks: number | undefined
  snapToTicks: boolean
  showValue: boolean
  sound: boolean
}

export type {
  ValueSliderLabelPlacement,
  ValueSliderProps,
  ValueSliderReport,
  ValueSliderSize,
  ValueSliderTuning,
  ValueSliderValue,
  ValueSliderVariant,
}
components/ui/value-slider/utils.ts
/** 把数值夹在 [0, 1] 之间。 */
function clamp01(value: number) {
  if (!Number.isFinite(value)) return 0
  return Math.min(1, Math.max(0, value))
}

/**
 * 橡皮筋拉伸量:指针越过轨道边界 `distance` 像素时的视觉拉伸。
 * 曲线是饱和的(越拉越难拉),结果永远小于 `max`,所以轨道不会被拉得没边。
 */
function rubberBand(distance: number, max: number) {
  if (!Number.isFinite(max) || max <= 0 || distance <= 0) return 0
  return max * (1 - 1 / (1 + (0.55 * distance) / max))
}

/** 值在轨道内的百分比(0–100)。 */
function valueToPercent(value: number, min: number, max: number) {
  const span = max - min
  if (!Number.isFinite(span) || span <= 0) return 0
  return Math.min(100, Math.max(0, ((value - min) / span) * 100))
}

/** step 的小数位数:1 → 0,0.5 → 1,0.05 → 2,用于读数与读屏文本。 */
function stepDecimals(step: number) {
  if (!Number.isFinite(step) || step <= 0) return 0
  const text = String(step)
  const exponential = text.split("e-")[1]
  if (exponential) return Math.min(6, Number(exponential))
  const dot = text.indexOf(".")
  return dot === -1 ? 0 : Math.min(6, text.length - dot - 1)
}

/** 单值 / 区间统一取成升序数组,区间时报文的顺序与 Base UI 一致。 */
function toNumberList(value: number | readonly number[]) {
  if (typeof value === "number") return [value]
  return [...value].sort((a, b) => a - b)
}

/**
 * 把 `ticks` 属性换算成刻度值数组(含两端)。
 * 数字表示等分段数,返回 n + 1 个值,其中 n - 1 个落在轨道内部;数组则原样排序、去重并夹在范围内。
 */
function resolveTicks(
  ticks: number | readonly number[] | undefined,
  min: number,
  max: number,
) {
  if (ticks === undefined) return []
  if (typeof ticks !== "number") {
    const list = ticks
      .filter((item) => Number.isFinite(item) && item >= min && item <= max)
      .sort((a, b) => a - b)
    return list.filter((item, index) => index === 0 || item !== list[index - 1])
  }
  const count = Math.floor(ticks)
  if (!Number.isFinite(count) || count < 2) return []
  return Array.from(
    { length: count + 1 },
    (_, index) => min + ((max - min) * index) / count,
  )
}

/** 离该值最近的刻度值;没有刻度时返回 undefined。 */
function nearestTick(value: number, ticks: readonly number[]) {
  let nearest: number | undefined
  let distance = Infinity
  for (const tick of ticks) {
    const next = Math.abs(tick - value)
    if (next < distance) {
      distance = next
      nearest = tick
    }
  }
  return nearest
}

/** 从 `from` 走到 `to` 的过程中是否越过了某个刻度(含 `to` 所在的刻度,不含 `from`)。 */
function crossedTick(from: number, to: number, ticks: readonly number[]) {
  const low = Math.min(from, to)
  const high = Math.max(from, to)
  return ticks.some((tick) => tick > low && tick <= high)
}

/**
 * 把值对齐到 `min + n * step` 并夹在 [min, max],值不是有限数时返回 `undefined`。
 *
 * 浮点误差靠「搬到整数域再算」消除:按 min / step / value 三者里最多的小数位放大成整数,
 * 算出整数序号后再除回去,所以 0.1 这种二进制表示不精确的 step 也不会跑出 0.30000000000000004。
 * 上界直接用 `max`(与 Base UI 一致:即使 `max` 不在步长网格上,键盘与指针也能取到它)。
 *
 * 例:min=0.05 step=0.1 tick=0.15 → 0.15;min=2700 step=100 tick=3650 → 3700。
 */
function alignToStep(value: number, min: number, max: number, step: number) {
  if (!Number.isFinite(value)) return undefined
  const clamped = Math.min(max, Math.max(min, value))
  if (!Number.isFinite(step) || step <= 0) return clamped
  const decimals = Math.max(
    stepDecimals(min),
    stepDecimals(step),
    stepDecimals(clamped),
  )
  const factor = 10 ** decimals
  const minInt = Math.round(min * factor)
  const stepInt = Math.max(1, Math.round(step * factor))
  const valueInt = Math.round(clamped * factor)
  const alignedInt = minInt + Math.round((valueInt - minInt) / stepInt) * stepInt
  // 除法得到的是该十进制值最接近的 double,等价于字面量,不需要再 toFixed
  const aligned = alignedInt / factor
  return Math.min(max, Math.max(min, aligned))
}

type SnapToTicksOptions = {
  /** 吸附目标(含两端的刻度值) */
  ticks: readonly number[]
  min: number
  max: number
  /** 吸附后的值要对齐到 `min + n * step`,否则会提交不在步长上的值 */
  step: number
  /** 区间模式下两个值之间至少相隔几个 step */
  minGapSteps?: number
  /** 与刻度的最大距离,绝对值(由调用方按值域比例算好) */
  tolerance: number
}

/**
 * 磁吸:凡是离刻度不超过 `tolerance` 的值,吸到该刻度并按 step 对齐后的位置。
 * 区间模式下吸附后的值必须保持顺序、并与相邻值至少相隔 `minGapSteps` 个 step,
 * 不满足就放弃这个值的吸附(宁可不动,也不要产生倒序或贴合的两个手柄)。
 */
function snapValuesToTicks(
  values: readonly number[],
  options: SnapToTicksOptions,
) {
  const { ticks, min, max, step, minGapSteps = 0, tolerance } = options
  const gap = Math.max(0, minGapSteps) * (Number.isFinite(step) ? step : 0)
  const result = [...values]
  for (let index = 0; index < values.length; index += 1) {
    const current = values[index]
    const nearest = nearestTick(current, ticks)
    if (nearest === undefined) continue
    if (Math.abs(nearest - current) > tolerance) continue
    const aligned = alignToStep(nearest, min, max, step)
    if (aligned === undefined) continue
    const before = result[index - 1]
    const after = result[index + 1]
    if (before !== undefined && aligned < before + gap) continue
    if (after !== undefined && aligned > after - gap) continue
    result[index] = aligned
  }
  return result
}

/** 单值与区间共用的相等判断,用于磁吸前后比较。 */
function sameValue(
  a: number | readonly number[],
  b: number | readonly number[],
) {
  if (typeof a === "number" || typeof b === "number") return a === b
  const left = a as readonly number[]
  const right = b as readonly number[]
  return (
    left.length === right.length &&
    left.every((item, index) => item === right[index])
  )
}

export {
  alignToStep,
  clamp01,
  crossedTick,
  nearestTick,
  resolveTicks,
  rubberBand,
  sameValue,
  snapValuesToTicks,
  stepDecimals,
  toNumberList,
  valueToPercent,
}
export type { SnapToTicksOptions }
components/ui/value-slider/sound.ts
"use client"

import * as React from "react"

/**
 * 滑块的音效:用 Web Audio 现场合成,不需要音频文件。
 * 刻度是一声很短的高频「嗒」,音高跟着值走;碰到轨道两端是一声低闷的「咚」。
 * AudioContext 在第一次真正发声(用户手势里)时才创建,满足浏览器的自动播放限制。
 */
let context: AudioContext | null = null

function audio() {
  if (typeof window === "undefined") return null
  if (!context) {
    const Ctor =
      window.AudioContext ??
      (window as unknown as { webkitAudioContext?: typeof AudioContext })
        .webkitAudioContext
    if (!Ctor) return null
    context = new Ctor()
  }
  if (context.state === "suspended") void context.resume()
  return context
}

const clamp = (value: number) => Math.min(1, Math.max(0, value))

/** 一段指数衰减的正弦:用来做「咚」的主体。 */
function tone(from: number, to: number, duration: number, volume: number) {
  const ctx = audio()
  if (!ctx) return
  const now = ctx.currentTime
  const osc = ctx.createOscillator()
  const gain = ctx.createGain()
  osc.type = "sine"
  osc.frequency.setValueAtTime(from, now)
  osc.frequency.exponentialRampToValueAtTime(to, now + duration)
  gain.gain.setValueAtTime(0.0001, now)
  gain.gain.exponentialRampToValueAtTime(volume, now + 0.005)
  gain.gain.exponentialRampToValueAtTime(0.0001, now + duration)
  osc.connect(gain).connect(ctx.destination)
  osc.start(now)
  osc.stop(now + duration + 0.02)
}

/** 一段带通滤过的噪声:构成「嗒」的颗粒感,长度只有十几毫秒。 */
function noise(frequency: number, duration: number, volume: number, q = 2.4) {
  const ctx = audio()
  if (!ctx) return
  const now = ctx.currentTime
  const length = Math.max(1, Math.ceil(ctx.sampleRate * duration))
  const buffer = ctx.createBuffer(1, length, ctx.sampleRate)
  const data = buffer.getChannelData(0)
  for (let i = 0; i < length; i++)
    data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2
  const source = ctx.createBufferSource()
  source.buffer = buffer
  const filter = ctx.createBiquadFilter()
  filter.type = "bandpass"
  filter.frequency.value = frequency
  filter.Q.value = q
  const gain = ctx.createGain()
  gain.gain.value = volume
  source.connect(filter).connect(gain).connect(ctx.destination)
  source.start(now)
}

/**
 * 一个刻度的滴答声。`pitch` 0–1 表示值在范围里的位置,越高越尖:
 * 高频带通噪声给「嗒」的触感,叠一声极短的正弦给它音高。
 */
function playTick(pitch = 0.5) {
  const t = clamp(pitch)
  noise(2400 + 2600 * t, 0.011, 0.05 + 0.04 * t, 3)
  tone(1100 + 1000 * t, 900 + 800 * t, 0.012, 0.028 + 0.012 * t)
}

/** 碰到轨道两端的一声低闷「咚」:低频下滑 + 一点点噪声让它不空。 */
function playEdge() {
  tone(168, 62, 0.15, 0.17)
  noise(420, 0.045, 0.06, 1.1)
}

/* ---------- 静音偏好:存在 localStorage,全站带音效的组件共用 ---------- */

const STORAGE_KEY = "travinsy:sound:muted"
/** 旧版本用过的键:读到新键没写过时按它迁移,保证升级后偏好不丢。 */
const LEGACY_KEYS = ["isometric-mark:muted"]
const WATCHED_KEYS = [STORAGE_KEY, ...LEGACY_KEYS]
/**
 * 同页广播:`storage` 事件只发给「其他」标签页,本页写 localStorage 不会触发它。
 * 带音效的组件各自有一份静音模块(互不 import),所以约定用同一个事件名互相通知。
 */
const MUTED_EVENT = "travinsy:sound-muted-change"
const listeners = new Set<() => void>()

function readMuted() {
  try {
    const current = window.localStorage.getItem(STORAGE_KEY)
    if (current !== null) return current === "1"
    return LEGACY_KEYS.some((key) => window.localStorage.getItem(key) === "1")
  } catch {
    return false
  }
}

function setMuted(muted: boolean) {
  try {
    window.localStorage.setItem(STORAGE_KEY, muted ? "1" : "0")
  } catch {
    // 隐私模式等存不了时只在本页生效
  }
  listeners.forEach((listener) => listener())
  // 通知同页其他带音效的组件
  window.dispatchEvent(new Event(MUTED_EVENT))
}

function subscribe(listener: () => void) {
  listeners.add(listener)
  const notify = () => listener()
  const onStorage = (event: StorageEvent) => {
    if (event.key === null || WATCHED_KEYS.includes(event.key)) notify()
  }
  window.addEventListener("storage", onStorage)
  window.addEventListener(MUTED_EVENT, notify)
  return () => {
    listeners.delete(listener)
    window.removeEventListener("storage", onStorage)
    window.removeEventListener(MUTED_EVENT, notify)
  }
}

/** 读取 / 切换音效静音。服务端与水合阶段按未静音渲染,挂载后再读本地偏好。 */
function useValueSliderMuted() {
  const muted = React.useSyncExternalStore(subscribe, readMuted, () => false)
  return [muted, setMuted] as const
}

export { playEdge, playTick, useValueSliderMuted }
components/ui/value-slider/use-elastic-pull.ts
"use client"

import * as React from "react"
import {
  animate,
  useMotionValue,
  useReducedMotion,
  useTransform,
  type AnimationPlaybackControls,
  type MotionValue,
} from "motion/react"

import { clamp01, rubberBand } from "./utils"

/** 打开 / 关闭过冲的弹簧:`visualDuration` 是回到原位的时间,`bounce` 给它一点回弹。 */
const RECOIL_SPRING = {
  type: "spring",
  visualDuration: 0.4,
  bounce: 0.45,
} as const
/** 轨道在过冲时的最大纵向压缩比例。 */
const SQUASH_RATIO = 0.12

type ElasticSide = "start" | "end"

type UseElasticPullOptions = {
  /** 拖出两端的最大过冲像素,`0` 关闭弹性 */
  elasticity: number
  /** 禁用时立刻收手:移除监听、停止弹簧、过冲归零 */
  disabled?: boolean
  /** 某一侧被拉开时回调,一次手势里每侧只报一次(用来放碰边音效) */
  onEdge?: (side: ElasticSide) => void
}

type ElasticPull = {
  /** 有符号的拉伸像素:负数往左、正数往右 */
  pull: MotionValue<number>
  /** 视觉层的水平位移,和 `scaleX` 配合成「拉住一端」的样子 */
  x: MotionValue<number>
  scaleX: MotionValue<number>
  scaleY: MotionValue<number>
  startIconX: MotionValue<number>
  endIconX: MotionValue<number>
  startIconScale: MotionValue<number>
  endIconScale: MotionValue<number>
  /** 当前拉向哪一端,用于根元素的 `data-overshoot` */
  side: ElasticSide | null
  /** 挂在 `Slider.Control` 上:只量 Control 的矩形,视觉层自己缩放 */
  onPointerDown: (event: React.PointerEvent<HTMLElement>) => void
  /** 键盘在手柄已经位于端点时继续往同方向走,拉一下再弹回来 */
  pulse: (side: ElasticSide) => void
}

/**
 * 橡皮筋过冲:指针拖出轨道后把轨道往那一侧拉长,松手用弹簧收回。
 * 全部走 MotionValue,拖动期间不触发 React 渲染;几何量自始至终只来自 Control 的矩形,
 * 所以缩放视觉层不会影响 Base UI 把指针换算成数值。
 */
function useElasticPull({
  elasticity,
  disabled = false,
  onEdge,
}: UseElasticPullOptions): ElasticPull {
  const reduced = useReducedMotion() ?? false
  const pull = useMotionValue(0)
  // 按下那一刻量到的轨道宽度,只用来把拉伸像素换算成缩放比例
  const width = useMotionValue(1)
  const scaleX = useTransform(
    () => 1 + Math.abs(pull.get()) / Math.max(1, width.get()),
  )
  const scaleY = useTransform(
    () =>
      1 -
      SQUASH_RATIO *
        clamp01(Math.abs(pull.get()) / Math.max(1, elasticity || 1)),
  )
  // 固定以中心为原点,再平移半个拉伸量:视觉上等于拉住一端,换边时不需要切换 transform-origin
  const x = useTransform(pull, (current) => current / 2)
  const startIconX = useTransform(pull, (current) => Math.min(0, current))
  const endIconX = useTransform(pull, (current) => Math.max(0, current))
  const startIconScale = useMotionValue(1)
  const endIconScale = useMotionValue(1)

  const [side, setSide] = React.useState<ElasticSide | null>(null)
  const sideRef = React.useRef<ElasticSide | null>(null)
  // 手势编号:每次按下 / 复位都 +1,弹簧完成时据此判断自己是不是已经被新手势取代
  const gestureRef = React.useRef(0)
  const springRef = React.useRef<AnimationPlaybackControls | null>(null)
  const detachRef = React.useRef<(() => void) | null>(null)
  // 窗口级监听里要读最新属性,用 ref 保存,免得每帧重建监听
  const optionsRef = React.useRef({ elasticity, reduced, onEdge })
  React.useEffect(() => {
    optionsRef.current = { elasticity, reduced, onEdge }
  })

  const setSideSafe = React.useCallback((next: ElasticSide | null) => {
    if (sideRef.current === next) return
    sideRef.current = next
    setSide(next)
  }, [])

  /** 停下正在跑的回弹、过冲归零,并让旧弹簧的完成回调失效。 */
  const reset = React.useCallback(() => {
    gestureRef.current += 1
    springRef.current?.stop()
    springRef.current = null
    pull.set(0)
    setSideSafe(null)
  }, [pull, setSideSafe])

  const recoil = React.useCallback(() => {
    const id = gestureRef.current
    springRef.current?.stop()
    springRef.current = animate(pull, 0, {
      ...RECOIL_SPRING,
      onComplete: () => {
        // 旧手势的弹簧到期时才回来:不许它动新手势的状态
        if (gestureRef.current !== id) return
        springRef.current = null
        setSideSafe(null)
      },
    })
  }, [pull, setSideSafe])

  const nudgeIcon = React.useCallback(
    (target: ElasticSide) => {
      if (reduced) return
      animate(target === "start" ? startIconScale : endIconScale, [1, 1.25, 1], {
        duration: 0.32,
        ease: "easeOut",
      })
    },
    [reduced, startIconScale, endIconScale],
  )

  const onPointerDown = React.useCallback(
    (event: React.PointerEvent<HTMLElement>) => {
      if (disabled || event.button !== 0) return
      // 并发指针(另一根手指还按着)时只保留最新这一组:先把上一组的 window 监听解掉,
      // 否则 detachRef 会被覆盖,旧手势的 move/release 再也解不掉
      detachRef.current?.()
      // 新的手势开始:上一段回弹(上一次拖动或键盘脉冲留下的)立刻作废
      reset()
      const state = optionsRef.current
      if (state.reduced || state.elasticity <= 0) return
      // 用按下这一刻的 Control 矩形量过冲;Control 自己不缩放,之后一直有效
      const rect = event.currentTarget.getBoundingClientRect()
      width.set(Math.max(1, rect.width))
      const pointerId = event.pointerId
      const nudged = { start: false, end: false }

      const detach = () => {
        window.removeEventListener("pointermove", move)
        window.removeEventListener("pointerup", release)
        window.removeEventListener("pointercancel", release)
        window.removeEventListener("lostpointercapture", release)
        window.removeEventListener("blur", release)
        if (detachRef.current === detach) detachRef.current = null
      }
      // 松手、被系统取消、指针捕获丢失、窗口失焦都走这里;重复调用是安全的
      const release = (native: Event) => {
        if ("pointerId" in native) {
          const stale = native as PointerEvent
          if (stale.pointerId !== pointerId) return
        }
        detach()
        if (pull.get() === 0) setSideSafe(null)
        else recoil()
      }
      const move = (native: PointerEvent) => {
        if (native.pointerId !== pointerId) return
        const latest = optionsRef.current
        if (latest.reduced || latest.elasticity <= 0) return
        const beyondEnd = native.clientX - rect.right
        const beyondStart = rect.left - native.clientX
        const next = beyondEnd > 0 ? "end" : beyondStart > 0 ? "start" : null
        if (next === null) {
          setSideSafe(null)
          if (pull.get() !== 0) pull.set(0)
          return
        }
        // 拖动时别选中旁边的文字
        native.preventDefault()
        const stretch = rubberBand(
          Math.max(beyondEnd, beyondStart),
          latest.elasticity,
        )
        pull.set(next === "end" ? stretch : -stretch)
        setSideSafe(next)
        if (!nudged[next]) {
          nudged[next] = true
          nudgeIcon(next)
          latest.onEdge?.(next)
        }
      }

      window.addEventListener("pointermove", move, { passive: false })
      window.addEventListener("pointerup", release)
      window.addEventListener("pointercancel", release)
      window.addEventListener("lostpointercapture", release)
      window.addEventListener("blur", release)
      detachRef.current = detach
    },
    [disabled, reset, width, nudgeIcon, pull, recoil, setSideSafe],
  )

  const pulse = React.useCallback(
    (target: ElasticSide) => {
      if (reduced || elasticity <= 0) return
      reset()
      setSideSafe(target)
      nudgeIcon(target)
      pull.set(target === "end" ? elasticity * 0.4 : elasticity * -0.4)
      recoil()
    },
    [
      reduced,
      elasticity,
      reset,
      setSideSafe,
      nudgeIcon,
      pull,
      recoil,
    ],
  )

  // 拖动途中被禁用:立刻收手,别让 window 监听和回弹继续跑
  React.useEffect(() => {
    if (!disabled) return
    reset()
    detachRef.current?.()
  }, [disabled, reset])

  React.useEffect(
    () => () => {
      gestureRef.current += 1
      springRef.current?.stop()
      detachRef.current?.()
    },
    [],
  )

  return {
    pull,
    x,
    scaleX,
    scaleY,
    startIconX,
    endIconX,
    startIconScale,
    endIconScale,
    side,
    onPointerDown,
    pulse,
  }
}

export { useElasticPull }
export type { ElasticPull, ElasticSide, UseElasticPullOptions }
components/ui/value-slider/use-handle-dodge.ts
"use client"

import * as React from "react"

/** 服务端渲染时没有布局,用 useEffect 代替 useLayoutEffect,避免 React 警告。 */
const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect

/** 手柄让位时给文字两边留的余量(像素)。 */
const PADDING = 16
/** 还没量到轨道宽度时,按这个比例判断「手柄压在端点附近」。 */
const FIRST_FRAME_RATIO = 15

type HandleDodge = {
  /** 挂在轨道上 */
  trackRef: React.RefObject<HTMLDivElement | null>
  /** 挂在轨道内的标签上 */
  labelRef: React.RefObject<HTMLSpanElement | null>
  /** 挂在轨道内的读数上 */
  valueRef: React.RefObject<HTMLSpanElement | null>
  /** 手柄在 `percent` 处时要不要让开文字 */
  isNear: (percent: number) => boolean
}

/**
 * 手柄让开轨道两端的标签与读数:量出文字宽度换算成百分比阈值。
 * 首帧(还没量到宽度)先按「靠近端点」处理,量完再按真实阈值,
 * 这样静态 HTML 与首屏也不会把手柄压在文字上。
 */
function useHandleDodge(enabled: boolean): HandleDodge {
  const trackRef = React.useRef<HTMLDivElement>(null)
  const labelRef = React.useRef<HTMLSpanElement>(null)
  const valueRef = React.useRef<HTMLSpanElement>(null)
  const [metrics, setMetrics] = React.useState({ track: 0, label: 0, value: 0 })

  useIsomorphicLayoutEffect(() => {
    if (!enabled) return
    const track = trackRef.current
    if (!track) return
    const measure = () =>
      setMetrics((previous) => {
        const next = {
          track: track.clientWidth,
          label: labelRef.current?.offsetWidth ?? 0,
          value: valueRef.current?.offsetWidth ?? 0,
        }
        return previous.track === next.track &&
          previous.label === next.label &&
          previous.value === next.value
          ? previous
          : next
      })
    // 先同步量一次(在浏览器绘制之前就能拿到真实宽度),再由 ResizeObserver 跟进
    measure()
    const observer = new ResizeObserver(measure)
    for (const element of [track, labelRef.current, valueRef.current])
      if (element) observer.observe(element)
    return () => observer.disconnect()
  }, [enabled])

  const ratio = (width: number) =>
    metrics.track > 0 ? ((width + PADDING) / metrics.track) * 100 : 0
  const startLimit = ratio(metrics.label)
  const endLimit = ratio(metrics.value)

  const isNear = (percent: number) => {
    if (!enabled) return false
    if (metrics.track === 0)
      return percent < FIRST_FRAME_RATIO || percent > 100 - FIRST_FRAME_RATIO
    return (
      (startLimit > 0 && percent <= startLimit) ||
      (endLimit > 0 && percent >= 100 - endLimit)
    )
  }

  return { trackRef, labelRef, valueRef, isNear }
}

export { useHandleDodge }
export type { HandleDodge }
hooks/use-controllable-state.ts
"use client"

import * as React from "react"

type UseControllableStateParams<T> = {
  /** 受控值;传入后组件不再维护内部状态 */
  prop?: T
  /** 非受控时的初始值 */
  defaultProp: T
  /** 值变化时回调(受控与非受控都会触发) */
  onChange?: (value: T) => void
}

/**
 * 同时支持受控与非受控的状态 hook。
 * 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
 */
function useControllableState<T>({
  prop,
  defaultProp,
  onChange,
}: UseControllableStateParams<T>) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
  const isControlled = prop !== undefined
  const value = isControlled ? prop : uncontrolled

  // 用 ref 保存最新的回调和值,保证 setValue 引用稳定
  const onChangeRef = React.useRef(onChange)
  const valueRef = React.useRef(value)
  React.useEffect(() => {
    onChangeRef.current = onChange
    valueRef.current = value
  })

  const setValue = React.useCallback(
    (next: React.SetStateAction<T>) => {
      const resolved =
        typeof next === "function"
          ? (next as (prev: T) => T)(valueRef.current)
          : next
      if (Object.is(resolved, valueRef.current)) return
      valueRef.current = resolved
      if (!isControlled) setUncontrolled(resolved)
      onChangeRef.current?.(resolved)
    },
    [isControlled],
  )

  return [value, setValue] as const
}

export { useControllableState }