一个好的组件应当是一个用户可用的组件。

在生活和工作中非常常见的一个需求就是在某个区间范围内选择一个合法值或合法区间。例如外卖软件中按送达时间筛选、购物软件中选择一个价格范围筛选商品等场景。

一个 Slider 组件

在原生 HTML 中,有原生滑块元素<input type="range" />可供使用,然而原生的 range input 有诸多问题:

  • 总是只有一个拖动柄(thumb),因而不能支持区间选择;
  • 很难进行样式化。开发者通常需要::-webkit-slider-thumb、::-moz-range-thumb等选择器来 target 目标元素,难以保障跨浏览器的外观一致性;
  • 行为难以改变/覆盖。当想要在原生元素的基础上开发一个组件时,这一点几乎是无法可解的,比如所有的组件库都会完全放弃<select>,转而实现完全由 div 构成的客制化Select。

基于相同的原因,虽然本文试图完全不依赖原生元素实现一个 Slider。这个案例将会使用 Vue 3.5.x 开发 Slider:使用 TypeScript、组合式 API 语法并遵循 Vue SFC 实践。

目标与前提

此例的目的在于探索、设计和实现 Slider 组件,因此牵涉过多的前置技术决策是不明智的。我们在此假定一些前提并设立一个可达的目标。

什么 Slider 才是好的 Slider?

好的组件首先是可用的组件。Slider 应当对所有用户可用——视力障碍的用户可以通过屏幕阅读器了解当前选择的值或区间、不方便使用鼠标的用户可以通过键盘聚焦后使用光标键移动滑块。而 Slider 也应当与原生的表单无缝集成,用户选中的值或区间可以通过表单操作进行校验与提交。

功能完备。Slider 应当具备原生 range input 不具备的区间选取功能,而且像前面所述的那样支持使用键盘操作;同时支持 RTL 书写的语言和反转(inverted)模式:一个反转的 Slider 使用相反方向的光标键位进行控制。此外,Slider 还应当能够像原生的 range input 元素那样指定最小、最大值和步进(step)值,在值改变和用户操作时进行钳制并确保值的合法。

高度可定制化。Slider 可能被用于任何 Vue 项目,适配任何风格并集成到任何已有的设施中。这意味着 Slider 应该可以在保留行为的同时被渲染为任意元素,且不带有任何预设的样式。

受控与非受控状态。同时支持使用v-model进行绑定的受控态和非受控态。

综合以上所有要求,需要实现的是一个 headless 的 Slider 组件,遵循 ARIA Authoring Practices Guide(APG)中关于 Slider 组件的模式。

解剖 Primitive

正如大多数 headless 组件库的决策一样,为了尽可能被消费于任何场景,组件需要被拆解成若干 primitive,并且通过组合的方式形成完整的 UI 部分。对于 Slider 组件,它可以被拆分为:

  • SliderRoot:根组件,承载大部分被提升的状态,如当前选定的值、disabled、readonly等。这些状态中很大一部分通过 Vue 的依赖注入和 props 传递给更下层的 primitive。
  • SliderTrack:表征滑块滑动时的轨道。
  • SliderThumb:滑块滑动时的操纵柄/滑块头。
  • SliderRange:表征滑块中被选取的部分/范围。

在 Vue 中,组合这几个 Primitive 形成完整的 Slider:

<SliderRoot>
  <SliderTrack>
    <SliderRange />
  </SliderTrack>
  <SliderThumb />
  <!-- 额外的 thumb,用于支持区间选择时分别调整上、下界 -->
  <SliderThumb />
</SliderRoot>

<Primitive />

我们希望 Slider 可以被渲染为任何元素,无论是div或是一个消费者开发的 vue 组件。利用 vue 的<component />内置组件和is prop 可以实现这一点;但也必须提供某种机制,允许将传入到构成 Slider 的某个 primitive 中的属性与其本身自带的属性进行合并,并安全地渲染到最终输出的 HTML 节点上。

因而,假设一个<Primitive />组件已经被实现,它看上去大致是这样的:

<template>
  <Slot v-if="asChild">
    <slot />
  </Slot>
  <component :is="as" v-else>
    <slot />
  </component>
</template>

其中,as prop 负责将组件渲染为任何一个传入的元素,而asChild直接将传入的子组件进行渲染,其中<Slot />组件负责将传入的属性、props 和事件回调与 primitive 自身定义的进行合并。

使用时,假设传入:

<Primitive as="span" data-key="value">
  text inside
</Primitive>

<Primitive asChild data-key="value">
  <span>text inside</span>
</Primitive>

两种情况下均会渲染为:

<span data-key="value">text inside</span>

同时支持受控与非受控模式

假定有以下的组合式函数useModel,接收props、数据状态名name和组件的emit作为参数。该组合式函数针对每一个传入的数据状态name,维护一个内部的响应式值internalValue。当用户通过v-model:name的方式使用受控状态时,通过emit('update:name')发射改变状态的事件。当用户未在props中定义该数据状态(props[name]为undefined)时,即用户以非受控状态消费组件:此时将维护的内部值作为 source of truth。

function useModel<Props extends object, Name extends keyof Props, Value = Props[Name]>(
  props: Props,
  name: Name,
  // `NoInfer`: `emit` is often an overloaded `defineEmits` signature; letting
  // it infer `Value` can pick the wrong overload (e.g. `update:placeholder`).
  emit: (event: `update:${Name & string}`, value: NoInfer<Value>) => void,
  options: UseModelOptions<NoInfer<Value>> = {},
): Ref<Value> {
  const internalValue = ref(options.defaultValue) as Ref<Value>

  return computed({
    get() {
      // `undefined` marks the prop as not provided, i.e. uncontrolled usage.
      return props[name] !== undefined ? (props[name] as Value) : internalValue.value
    },
    set(value) {
      internalValue.value = value
      emit(`update:${String(name)}` as `update:${Name & string}`, value)
    },
  })
}

使用时:

const modelValue = useModel(props, 'modelValue', emit, {
  defaultValue: 'something'
})

// 非受控状态下,返回`useModel`针对`modelValue`维护的内部值
console.log(modelValue.value)
// 受控状态下,`emit('update:modelValue')`改变了传入的数据
modelValue.value = 'another thing'

设计 Slider

Slider 的状态

Slider 应当包含的状态分为三大类:原生 range input 具备的min、max和step,用于支持表单的disabled、readonly、name和required,以及我们新开发的 vue 组件应当额外支持的状态:

  • modelValue和defaultValue:根据设计决策,Slider 应当支持值选择和区间选择。为了简化设计复杂性,统一采用number[]作为值的结构。
  • minStepsBetweenThumbs:在区间选择模式下,两个 thumb 之间最少应当间隔多少个 step。此处应当填写一个整数,配合step使用来指定用户至少选择的区间长度。
  • orientation、dir和inverted:用于支持关于方向的功能特性。orientation可选值为horizontal和vertical,对应原生的 range input 中writing-mode: vertical的状态。dir用于支持 RTL 的语言并对应反转 Slider 填充方向与键盘操作。inverted提供一个组件级的反转功能。

在此基础上,我们定义SliderRoot组件的 props 类型:

export type SliderOrientation = 'horizontal' | 'vertical'
export type SliderDir = 'ltr' | 'rtl'

export interface SliderRootProps {
  // used by `<Primitive />`
  as?: string | Component
  // used by `<Primitive />`
  asChild?: boolean
  modelValue?: number[]
  defaultValue?: number[]
  min?: number
  max?: number
  step?: number
  minStepsBetweenThumbs?: number
  orientation?: SliderOrientation
  inverted?: boolean
  dir?: SliderDir
  disabled?: boolean
  readonly?: boolean
  required?: boolean
  name?: string
}

Slider 和其 primitives 的行为

Slider 应当具备一些行为,除去那些已经在 APG 中提及的,还包含一些出于简化实现复杂度、保证正确性和用户体验等原因需要约定的行为。

首先就是与原生表单的集成,我们将此要求作为 Slider 的首要目标之一。这就意味着 Slider 组件内必须包含原生可提交元素(input、textarea、button、select中的一个),且包含一个name属性。由于 Slider 需要支持值选择和区间选择,因此可为每一个 thumb 包含一个视觉上隐藏的<input type="number">来承担表单的职责。Number input 不仅能实现表单数据的收集,还天然具有min/max/step等与 Slider 相同的 props,便于进行值的规范化和校验。

其次是关于区间选择模式下两个 thumb。不妨约定 thumbs 永远不能交换顺序:用于下界选择的 thumb 永远不会移动到用于上界选择的 thumb 的另一侧。同时,当用户点击滑轨时,应当移动距离点击位置最近的 thumb,而非当前聚焦的。这一设计有助于简化 Slider 实现逻辑,并且使用户侧更容易预料到组件的行为。

最后,由于决定开发的是一个 headless 组件,我们不会对组件的样式和外观进行任何约定。但是,由于消费者进行样式化时难免依赖组件内部的状态,对于需要暴露的状态,不妨通过内联 CSS 变量的形式暴露给消费者。

Slider 数据状态的实现

开箱即用:零 prop 配置

在前文中,SliderRootProps将所有 props 定义为可选值,意味着用户总是能在零配置的情况下使用 Slider。为了做到这一点,需要为从三个角度予以考虑:

  • 为部分 props 设置一个默认值。例如min、max和step。
  • 为其中一些数据状态设置兜底策略。例如,组件的dir总是先以 props 中的配置为最高优先,并在未指定时回退到document.documentElement.dir。
  • 纠正可能出错的 props 设置,或至少提供异常信息避免静默失败。例如程序检查用户提供的 props 中,min的值大于max则将max的值作为min。

基于以上考虑,可以实现

const DEFAULT_MIN = 0
const DEFAULT_MAX = 100
const DEFAULT_STEP = 1

const props = withDefaults(defineProps<SliderRootProps>(), {
  as: 'div',
  asChild: false,
  min: DEFAULT_MIN,
  max: DEFAULT_MAX,
  step: DEFAULT_STEP,
  minStepsBetweenThumbs: 0,
  orientation: 'horizontal',
  inverted: false,
  disabled: false,
  readonly: false,
  required: false,
})
const emit = defineEmits<SliderRootEmits>()
defineSlots<{
  default: (props: SliderRootSlotProps) => any
}>()

const defaultValue = computed<number[]>(() => props.defaultValue ?? [props.min])
const modelValue = useModel<SliderRootProps, 'modelValue', number[]>(props, 'modelValue', emit, {
  defaultValue: defaultValue.value,
})
const dir = computed<SliderDir>(
  () =>
    props.dir ??
    (typeof document !== 'undefined' && document.documentElement.dir === 'rtl' ? 'rtl' : 'ltr'),
)
const min = computed(() => Math.min(props.min, props.max))
const max = computed(() => Math.max(props.min, props.max))
// `gap`作为内部状态使用,基于`minStepsBetweenThumbs`和`step`计算区间选择模式下
// 两个 thumb 之间应当保持至少多远的距离(gap)
const gap = computed(() => props.minStepsBetweenThumbs * props.step)

值的处理

根据先前的设计决策,在 Slider 组件中,无论是值的选择还是区间选择,都使用number[]数组作为值的类型。针对值的合法性,必须进行校验和修正。

  • 当进行区间选择时,应当时刻保证两个 thumb 所对应的值,即number[]数组的两个成员之间始终有序。
  • 当选择值时,需要让值 snap 到最近的「刻度」。当step被设置时,刻度为step的倍数。
  • 当选择值时,需要让值始终位于min和max之间。

首先创建snapToStep函数,使传入的value始终位于刻度上。

当step设置时,可选的刻度包括:min、min + step、min + 2 * step、min + 3 * step等。对于给定的未处理的生值value,必须意识到整个 Slider 的值域并是从min而非 0 开始,因而通过(value - min) / step可以计算出value经过了几(可能是个小数)个step,再 round 到最近的整数就可以得到距离value最近的刻度。此时再由最近的刻度反向计算合法值,有:

const snapped = min + Math.round((value - min) / step) * step

为了规避 JS 计算中可能出现的0 + 3 * 0.1 = 0.30000000000000004的情况,也为了使最终输出的值的小数位数对齐step和min的设置,应当获取step和min中小数位数最高者作为基准,将最终的值通过toFixed调整为与基准同小数位数。

function decimalCount(value: number) {
  const str = String(value)
  const dot = str.indexOf('.')
  return dot === -1 ? 0 : str.length - dot - 1
}

function snapToStep(value: number, min: number, step: number) {
  const snapped = min + Math.round((value - min) / step) * step
  return Number(snapped.toFixed(Math.max(decimalCount(min), decimalCount(step))))
}

可想而知,最终我们将会在计算属性中调用snapToStep在值改变的时候自动处理。

Thumb 注册

Slider 的大部分状态都被提升到作为根组件的<SliderRoot />中,关于 thumb 的状态也是一样。

试想:当用户拖拽某个 thumb 时或者点击滑轨时,都应当设置 Root 中modelValue的值;而对于区间选择模式下的 thumb,还必须要知道应当调整modelValue数组的哪一个成员,即对应调整哪一个 thumb 指向的值。此外,在 Slider 和其 primitives 的行为 中有约定,每一个 thumb 都应当渲染一个视觉上不可见的<input type="number">参与表单行为。因此,在<SliderRoot />里,应当维护一个thumbs数组,该数组记录 Slider 中<SliderThumb />的数量(1 或 2 个)、顺序及其内部 Number Input 元素的 ref 索引。

首先在<SliderRoot />内定义用于记录 Thumb 的数据结构:

interface SliderRegisteredThumb {
  /** 渲染的 Thumb 元素;在挂载前为`undefined` */
  el: Ref<HTMLElement | undefined>
  /** 隐藏的 Number Input;当没有设置`name`或在挂在前取`null` */
  input: Ref<HTMLInputElement | null>
}

const thumbs = shallowRef<SliderRegisteredThumb[]>([])

当有<SliderThumb /> primitive 挂载时,应当向thumbs数组中推入元素;反之,当<SliderThumb />自 DOM 中卸载,也应当从thumbs数组中移除。简单起见,两个方法都传入SliderRegisteredThumb类型作为参数:

function registerThumb(entry: SliderRegisteredThumb) {
  thumbs.value = [...thumbs.value, entry]
}

function unregisterThumb(entry: SliderRegisteredThumb) {
  thumbs.value = thumbs.value.filter((t) => t !== entry)
}

这两个函数会被通过依赖注入提供给<SliderThumb /> primitive,并且在对应的生命周期中调用。

最后,还需要考虑 thumbs 的顺序问题。Slider 组件的设计决定中要求modelValue数组、thumbs 的 DOM 和值的相对大小三个顺序均相同,从而避免冲突导致的 BUG。在此处,可以用过计算属性对就绪后的 DOM 元素进行顺序的判断,避免因v-if或Suspense导致的乱序挂载。

const orderedThumbs = computed(() => {
  const list = thumbs.value
  // 初次渲染时,后一个 Thumb 还未挂载。此时回退到注册顺序,即`thumbs.value`
  if (list.some((t) => !t.el.value)) return list
  return [...list].sort((a, b) =>
    a.el.value!.compareDocumentPosition(b.el.value!) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
  )
})

值的设置

根据 Slider 的预期行为和设计决定,以下场景可能发生值的变动:

  • 用户拖动 Thumb 时;
  • 用户点击滑轨时;
  • 用户通过键盘按键触发了 Thumb 的移动时。

这涉及到<SliderThumb />和<SliderTrack />,设置值的函数会通过上下文注入到需要的 primitives 中。

由于 Slider 可能包含一个或两个 Thumb,因此定义如下所示的函数,需要传入的参数包括要设置的 thumb 次序(对应modelValue中值的下标)和需要设置的值:

function setThumbValue(index: number, rawValue: number) {}

在设置值时,需要考虑一个重点在于过滤不合法的赋值操作。不合法的赋值操作包括:

  • 超过允许的上下界的值。其中上下界取决于另一个 thumb 所处的位置和 props 中设定的min、max;
  • 传入小于 0 或大于已注册的 thumbs 数量的index。即,对于值的选择只有一个 thumb,modelValue数组也只有一个元素,传入index为 1 是不合法的;
  • 当 Slider 被标记为disabled或readonly时。

根据上述分析,首先需要编写一个函数用于计算对于一个 thumb 而言,允许的上下界范围。

function getThumbBounds(index: number): { min: number; max: number } {
  // 经过处理后的 modelValue 值
  const values = canonicalValues.value
  if (index < 0 || index >= values.length) return { min: min.value, max: max.value }
  // 如果 index > 0,即获取第二个 thumb 的下界,那么下界应当是第一个 thumb 的值加上 gap
  // 其中 gap 在前文计算过,代表两个 thumb 之间允许的最短距离
  const lower = index > 0 ? values[index - 1]! + gap.value : min.value
  // 计算上界时同理
  const upper = index < values.length - 1 ? values[index + 1]! - gap.value : max.value
  // 以一个 Record 形式返回上下界
  return {
    min: Math.max(min.value, lower),
    max: Math.min(max.value, upper)
  }
}

然后,实现setThumbValue:

function setThumbValue(index: number, rawValue: number) {
  if (props.disabled || props.readonly) return
  const values = [...canonicalValues.value]
  if (index < 0 || index >= values.length) return
  const bounds = getThumbBounds(index)
  const next = clamp(snapToStep(rawValue, min.value, props.step), bounds.min, bounds.max)
  if (values[index] === next) return
  values[index] = next
  modelValue.value = values
}

几何计算

由于 Slider 不预设任何样式,因此消费者需要自行对 primitives 进行样式化。其中,<SliderThumb />和<SliderRange />的样式很大程度上需要取决于百分比,这样消费者才能设置 thumb 的位置。为此,需要提供一个计算 thumb 几何位置关系的函数。

function valueToPercent(value: number): number {
  const range = max.value - min.value
  const percent = range === 0 ? 0 : ((value - min.value) / range) * 100
  return props.inverted ? 100 - percent : percent
}

valueToPercent将通过上下文注入的方式提供给需要的 primitives。

处理事件

Slider 中需要处理的用户事件主要包括:

  1. <SliderThumb />的拖动;
  2. <SliderTrack />的点击;再点击后直接拖动将会触发 1;
  3. <SliderTrack />的键盘按键;
  4. 表单事件。

实现拖动逻辑

Thumb 的拖动可能发生在任何一个 thumb 上。定义一个统一的startDrag方法,接收拖动对象的 thumb 下标index和指针事件event作为参数。

在拖动过程中,需要完成的任务包括:

  • 将拖动时鼠标的目标 thumb 下标进行记录。在需要时,可以通过查询orderedThumbs和draggingIndex获取正在被拖动的元素;
  • 定义移动onMove和结束移动onEnd方法,并初始化事件监听器;
  • 在指针拖动时,通过前文实现的setThumbValue函数设置目标 thumb 元素的值。

其中,在通过setThumbValue设置值前,还需要知道拖动过程中 thumb 指向的值。这必须通过识别 thumb 在滑轨<SliderTrack />上的像素位置进行计算。在SliderRoot中,定义一个指向SliderTrack元素的引用,然后定义getValueFromPoint方法计算传入的坐标值对应的 Slider 模型值。

计算的方法相对简单,直接将指针事件中携带的指针相对于可视区域的clientX和clientY作为指针当前坐标点传入,然后通过getBoundingClientRect获取滑轨元素相对于屏幕可视区域的几何位置信息,包括距离可视区域左侧、底部的像素数和其宽度与高度。根据这两者即可计算得出 Slider Thumb 相对于滑轨的比例位置,从而计算得到 Slider 的值。

// 指向 track 滑轨元素的引用,SliderTrack primitive 挂载后设置
const trackEl = ref<HTMLElement>()

function getValueFromPoint(clientX: number, clientY: number): number {
  const track = trackEl.value
  if (!track) return min.value
  const rect = track.getBoundingClientRect()
  let ratio: number
  if (props.orientation === 'vertical') {
    ratio = rect.height === 0 ? 0 : (rect.bottom - clientY) / rect.height
  } else {
    const fromLeft = rect.width === 0 ? 0 : (clientX - rect.left) / rect.width
    ratio = dir.value === 'rtl' ? 1 - fromLeft : fromLeft
  }
  if (props.inverted) ratio = 1 - ratio
  return min.value + ratio * (max.value - min.value)
}

以一个水平的 Slider 为例,首先计算坐标相对轨道起点的距离。

const fromLeft = (clientX - rect.left) / rect.width

其中,clientX - rect.left是指针到滑轨左边缘的像素距离。使用此值除以轨道宽度将其单位化为 0~1 的值。然后,进行方向映射。如果设置了dir="rtl"(即从右向左滑动选择),那么当前选中的部分应该被反向。以及,如果设置了props.inverted为真,则也要进行反向。

最后,用这个比例乘以最大值max与最小值min之间的插值,加上作为起始值的最小值即可得到指定坐标相对应在 Slider 上的值,即min.value + ratio * (max.value - min.value)。

然后,实现拖动函数如下:

// 用于标识当前被拖动的 thumb 下标
const draggingIndex = ref(-1)

function startDrag(index: number, event: PointerEvent) {
  // 根据下标获取事件对象 thumb
  const thumbEl = orderedThumbs.value[index]?.el.value
  if (!thumbEl) return
  draggingIndex.value = index
  // 将指针捕获,后续所有该 pointer 的事件都重定向到 thumb 元素
  thumbEl.setPointerCapture?.(event.pointerId)

  const onMove = (moveEvent: Event) => {
    const { clientX, clientY } = moveEvent as PointerEvent
    setThumbValue(index, getValueFromPoint(clientX, clientY))
  }
  const onEnd = (endEvent: Event) => {
    thumbEl.removeEventListener('pointermove', onMove)
    thumbEl.removeEventListener('pointerup', onEnd)
    thumbEl.removeEventListener('pointercancel', onEnd)
    thumbEl.releasePointerCapture?.((endEvent as PointerEvent).pointerId)
    draggingIndex.value = -1
    commitValue()
  }
  thumbEl.addEventListener('pointermove', onMove)
  thumbEl.addEventListener('pointerup', onEnd)
  thumbEl.addEventListener('pointercancel', onEnd)
}

响应对滑轨的点击事件

当用户点击滑轨时,预期的行为应当是立即移动距离点击位置较近的 thumb 到该位置;如果用户没有取消指针事件并继续试图拖动,那么应当立即调用上面实现的startDrag。响应滑轨点击事件的关键在于找出距离用户点击的位置较近的 thumb。

假设用户点击的位置是(clientX, clientY),那么可以先通过上文实现的getValueFromPoint找出该点对应的值,然后通过简单的绝对值计算比对先找出modelValue数组中距离较近的元素的下标。得益于 Slider 的设计,modelValue中元素的顺序与<SliderThumb />的顺序天然一致,因此可以获知哪一个 thumb 应该获取用户的焦点。

具体实现图如下:

// 根据传入的值,找出距离该值最近的 thumb 的下标
function nearestThumbIndex(rawValue: number): number {
  const values = canonicalValues.value
  let nearest = 0
  let best = Number.POSITIVE_INFINITY
  for (let i = 0; i < values.length; i++) {
    const distance = Math.abs(values[i]! - rawValue)
    if (distance < best) {
      best = distance
      nearest = i
    }
  }
  return nearest
}

// 鼠标在滑轨上按下时触发的回调函数
function onTrackPointerDown(event: PointerEvent) {
  if (props.disabled || props.readonly) return
  // event.button = 0 时为鼠标主键。如果用户使用侧键、滚轮或其他辅助按键则予以忽略
  if (event.button && event.button !== 0) return
  // 如果用户点击的目标是一个 thumb,那么不应该触发此函数,而是触发 thumb 本身的`startDrag`
  const target = event.target as Node | null
  if (target && orderedThumbs.value.some((t) => t.el.value?.contains(target))) return

  event.preventDefault()
  const rawValue = getValueFromPoint(event.clientX, event.clientY)
  const index = nearestThumbIndex(rawValue)
  setThumbValue(index, rawValue)
  // 聚焦刚移动的 thumb
  orderedThumbs.value[index]?.el.value?.focus()
  // 触发该 thumb 的`startDrag`
  startDrag(index, event)
}

响应键盘按键

除了拖动事件外,SliderThumb 还需要响应特定键盘上按键的交互。所有可能对 Slider 的值造成影响的按键包括:

const ADJUSTMENT_KEYS = new Set([
  'ArrowUp',
  'ArrowDown',
  'ArrowLeft',
  'ArrowRight',
  'PageUp',
  'PageDown',
  'Home',
  'End',
])

键盘按键的事件处理相对简单,只需要考虑dir与inverted对按键造成的影响即可。在 RTL 或inverted时,按下左方向键都应当使值减少。核心示例代码如下:

// 设 PAGE_STEP_MULTIPLIER 为按下一次 PageUp/PageDown 键改变的 Slider 的步长
// delta 为键盘行为对 Slider 值的变化量
let delta: number | undefined

if (key === 'ArrowUp' || key === 'ArrowDown') {
  delta = key === 'ArrowUp' ? step.value : -step.value
  if (inverted.value) delta = -delta
} else if (key === 'ArrowRight' || key === 'ArrowLeft') {
  delta = key === 'ArrowRight' ? step.value : -step.value
  // RTL flips the horizontal axis only; vertical is direction-independent.
  if (orientation.value === 'horizontal' && dir.value === 'rtl') delta = -delta
  if (inverted.value) delta = -delta
} else if (key === 'PageUp' || key === 'PageDown') {
  delta =
    key === 'PageUp' ? step.value * PAGE_STEP_MULTIPLIER : -step.value * PAGE_STEP_MULTIPLIER
}

if (delta !== undefined) {
  event.preventDefault()
  setThumbValue(index.value, value.value + delta)
  return
}
if (key === 'Home' || key === 'End') {
  event.preventDefault()
  const { min: boundMin, max: boundMax } = getThumbBounds(index.value)
  setThumbValue(index.value, key === 'Home' ? boundMin : boundMax)
}

此外,需要注意的是,按键事件回调不像前面的事件一样被写在<SliderRoot />中,而是留在下一层的<SliderThumb />内,它所调用的setThumbValue或getThumbBounds等方法都是通过依赖注入从<SliderRoot />中获取的。因为事件天然只发给获得焦点的 thumb,不需要提升到根组件进行处理;而前文实现的startDrag是拖拽与滑轨点击所共有,带有组件级别的状态,因而必须提升。

表单事件

在前面提到过,想要 Slider 参与表单数据收集与提交,必须要渲染真实的可提交元素。除此之外,想要 Slider 正确响应表单事件、与原生表单无缝集成,以下几点也不可或缺:

  • 约束校验 API 代理。浏览器/消费方会调用checkValidity()、reportValidity()、setCustomValidity(),读取validity、validationMessage。Slider 必须把这些暴露出来并转发到隐藏input元素上。
  • 原生的表单重置form.reset()不触发input/change,因此 Slider 必须监听重置并回填初始值。
  • 无效状态的外露与清除。校验失败后要有data-invalid/aria-invalid供消费端进行样式化和辅助设备读屏,值修正后要手动清除。

在约束校验方面,Slider 的设计天生可以避免选择值/区间溢出、步长不匹配等问题。唯一可能让 Slider 无效的途径是setCustomValidity()。这可以极大简化实现难度和心智负担,无需设计者考虑各种各样可能失败的场景。

其次必须要注意的是,一个 Slider 可能包含一个或两个<input>元素,但作为一个组件,Slider 的正确性必须被聚合到一个字段。对于checkValidity()和reportValidity(),对所有的<input>取every();对于setCustomValidity(),需要施加到全部的<input>上,即任意 input 无效等价于全部无效。

在开始编码之前,再次明确无效状态的状态机。清除无效的时机发生在值改变的时候,而无效可能发生在:

  1. 原生校验过程中。浏览器提交表单时,对每个无效控件派发invalid事件。该事件不冒泡,因此<SliderRoot />无法统一监听,各<input>自行通过@invalid处理并「通知」根组件。
  2. 调用setCustomValidity设置了自定义错误。

核心实现代码如下:

// 所有的`<input type="number">`元素
const inputs = computed(() =>
  orderedThumbs.value
    .map((item) => item.input.value)
    .filter((input): input is HTMLInputElement => input != null),
)
// 第一个`<input type="number">`元素
const firstInput = computed(() => inputs.value[0])

// 自定义错误
const customError = ref(false)
// 校验失败错误
const validationFailed = ref(false)
// 组件失效状态标记
const invalid = computed(() => customError.value || validationFailed.value)
// 通知根组件校验失败
function notifyValidationFailed() {
  validationFailed.value = true
}

function checkValidity() {
  const valid = inputs.value.every((el) => el.checkValidity())
  validationFailed.value = !valid
  return valid
}

function reportValidity() {
  const valid = inputs.value.every((el) => el.reportValidity())
  validationFailed.value = !valid
  return valid
}

function setCustomValidity(message: string) {
  for (const input of inputs.value) input.setCustomValidity(message)
  // 传入空意味着清除自定义失败
  customError.value = message !== ''
  // 清除自定义失败时,立即重新校验
  if (message === '') validationFailed.value = !inputs.value.every((el) => el.validity.valid)
}

当值改变的时候,立即重新评估,清除可能得失败状态:

watch(
  modelValue,
  () => {
    if (validationFailed.value) {
      // 由于任意 input 失效即意味着组件失效,因此读取一个 input 的状态代表整体即可
      validationFailed.value = !(firstInput.value?.validity.valid ?? true)
    }
  },
  { flush: 'post' },
)

最后,对于表单的重置事件,首先在挂载时找到距离最近的表单<form>元素,并为其添加监听器;当监听到该元素的reset事件时,将 Slider 的初始值回填。

// 记录以下初始值(如有),或回退到指定的默认值
const initialModelValue = props.modelValue ?? defaultValue.value

onMounted(() => {
  formEl = el.closest('form')
  formEl?.addEventListener('reset', onFormReset)
})

function onFormReset() {
  if (props.modelValue === undefined) {
    // props.modelValue 为 undefined 时是非受控态
    // 直接将 defaultValue 作为默认值
    modelValue.value = defaultValue.value
  } else {
    emit('update:modelValue', initialModelValue)
  }
}

回顾

在本文中,对 Slider 的实现只公开了部分核心代码,但剩余的代码也很容易在没有指导的情况下按照文中的设计决定和 APG 的最佳实践完成编写。

值得一提的是,在编写 Slider 的过程中包含了大量的 DOM 操作:无论是涉及 bounding rect 的几何换算,还是添加与移除监听器——在组件与底层设施的开发中,熟悉 DOM 操作始终是前端开发者绕不开的基本功。

Slider 的一些设计也巧妙规避了风险。例如当有多个事件都能触发 Slider 的值变更时,将变更全部收敛到单一函数,并对这个函数进行值的规范化。Slider 的复杂度不在任何单点技术——但事实却是我在 Slider 的实现上花费了大量精力和时间。在设计任何系统时,都应当在每一个决策点考虑「这件事的权威性由谁负责」,并尽可能收敛事实真相的来源。

最后,一个好的组件是可用的组件。希望每一个设计者都能更多地在组件、产品、设施中考虑 a11y,并确保用户可以无障碍地使用和享受我们的创造物。

000%