Skip to content

替身的最后一刀

背景:cs-row 拖拽交互过度设计、颜色滑块仍有原生 input 残留、无障碍缺失。

过程:移除拖拽 + 颜色滑块原生 input 灭绝 + 进度条美化 + ARIA 无障碍完善。


外交官走到门口了。

行李收拾好了。审计报告交了。设置页的五幕演完了。他甚至已经和 AI 同行者道了别。

然后他停住了。

"怎么了?"AI 问。

"第二十三章,"外交官说,"我说过一句话。"

"哪句?"

"'可访问性以后再说。'"

AI 沉默了一会儿。"……然后呢?"

"现在是'以后'了。"


一、替身的尾巴

故事要从第四卷说起。

那时候,<input type="range"> 是联邦的租客——住在这里,但不归联邦管。WebView2 一升级,它就可能换一副面孔。联邦的自意识忍了很久,终于用一套自定义的紧凑滑块 cs-row 把它替掉了。

替身继承了原生的位置,继承了原生的职责——35 个滑块,从材质到相机,从天空到 Bloom,全部换了新装。

但替身也继承了一样东西:拖拽

<input type="range"> 的核心交互是拖——按住拇指,左右移动,值跟着变。cs-row 替掉了它的外壳,却把这套交互原封不动搬了过来。mousedownmousemovemouseup,外加 requestAnimationFrame 做 rAF 节流,四十行代码,像一条甩不掉的尾巴。

"替掉一个能用的东西,比修复一个坏掉的东西更需要理由,"外交官翻着第四卷的旧笔记,"当时是这么写的。"

"然后呢?"

"然后替身站稳了。35 个滑块都认它。它不再是替身了——它是联邦唯一的滑块。"

"那它还留着拖拽干什么?"

外交官没回答。他打开了 ui-helpers.ts。


四十行拖拽代码长这样——

typescript
let dragging = false;
let rafId = 0;
let pendingX = 0;
let dragRect: DOMRect | null = null;

function onDragMove(e: MouseEvent): void {
    if (!dragging) return;
    e.preventDefault();
    pendingX = e.clientX;
    if (rafId) return;
    rafId = requestAnimationFrame(() => {
        rafId = 0;
        if (dragRect) setValueFromClientX(pendingX, dragRect);
    });
}

function onDragEnd(): void {
    dragging = false;
    dragRect = null;
    document.removeEventListener('mousemove', onDragMove);
    document.removeEventListener('mouseup', onDragEnd);
    if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
    _cbOnDragEnd?.(currentValue);
}

bar.addEventListener('mousedown', (e) => {
    e.preventDefault();
    bar.focus();
    dragRect = bar.getBoundingClientRect();
    dragging = true;
    setValueFromClientX(e.clientX, dragRect);
    document.addEventListener('mousemove', onDragMove);
    document.addEventListener('mouseup', onDragEnd);
});

五个全局变量,两个 document 级监听器,一个 rAF 句柄。每次拖拽开始,往 document 上挂两个监听器;拖拽结束,摘下来。如果用户在拖拽中途切了标签页,或者 rAF 没来得及执行——内存就漏了。

"这不是联邦的滑块,"外交官说,"这是原生的幽灵。cs-row 替掉了 <input type="range"> 的脸,却替不掉它的手。"

"所以——砍?"

"砍。"


二、三刀

外交官在清单上写了三行字,每一行都是一刀。

第一刀:砍掉拖拽

addSliderRowmousedown 事件改为 click。按住不放的逻辑全部删除——dragging 变量、onDragMove 函数、onDragEnd 函数、rafIdpendingXdragRect,六个变量两个函数,一个不留。

"改成什么?"

"点击即跳转,"外交官说,"用户点进度条的哪个位置,值就跳到哪个位置。不需要按住,不需要拖。"

"那精细调整呢?以前可以慢慢拖到 0.73……"

"键盘,"外交官指了指,"方向键,步进 0.01。Shift 加速到 0.1。Home 回到最小,End 跳到最大。"

"这不是第二十三章答应过的吗?"

"对,"外交官说,"第二十三章说'可访问性以后再说'。这就是'以后'。"

他在 bar 上加了 tabIndex = 0——滑块可以被 Tab 键聚焦。加了 role="slider"——屏幕阅读器知道这是一个滑块。加了 aria-valuenowaria-valueminaria-valuemax——屏幕阅读器知道当前值是多少,范围是多少。

然后是键盘事件——

typescript
function handleKeyDown(e: KeyboardEvent): void {
    const shiftMult = e.shiftKey ? 10 : 1;
    let delta = step * shiftMult;
    if (step >= 1) delta = e.shiftKey ? 10 : 1;

    switch (e.key) {
        case 'ArrowLeft':
        case 'ArrowDown':
            e.preventDefault();
            const lower = Math.max(min, snapToStep(currentValue - delta));
            if (lower !== currentValue) { updateDisplay(lower); onChange(lower); }
            break;
        case 'ArrowRight':
        case 'ArrowUp':
            e.preventDefault();
            const upper = Math.min(max, snapToStep(currentValue + delta));
            if (upper !== currentValue) { updateDisplay(upper); onChange(upper); }
            break;
        case 'Home': e.preventDefault(); updateDisplay(min); onChange(min); break;
        case 'End': e.preventDefault(); updateDisplay(max); onChange(max); break;
    }
}

"左减右加,Shift 加速,Home/End 跳极值。"

"和原生 <input type="range"> 的键盘行为一样?"

"一模一样,"外交官说,"WAI-ARIA Authoring Practices Guide 规定的。我不是在发明——我是在遵守标准。"

"那拖拽呢?真的不需要了?"

"不需要了,"外交官说,"点击跳转覆盖粗调,键盘步进覆盖微调。拖拽是两者的中间地带——既不够快,也不够准。它存在的唯一理由是习惯。"

"习惯不是理由?"

"习惯是上一个时代的理由,"外交官说,"这个时代有键盘了。"


第二刀:颜色滑块的归顺

第二刀砍的是 addColorSliderRow

这是原生 <input type="range"> 在联邦的最后一块飞地。当 cs-row 在第四卷替掉了 35 个普通滑块时,颜色滑块没有被替——它有三个通道(R/G/B),每个通道一个 <input type="range">,三兄弟排成一排,住在自己的小天地里。

"第四卷的时候为什么不一起替?"

"因为颜色滑块不一样,"外交官说,"普通滑块是一个值,颜色滑块是三个值。每个通道有自己的颜色——R 是红,G 是绿,B 是蓝。原生 <input> 的 track 可以用 CSS 渐变染色,从黑到红、黑到绿、黑到蓝。cs-row 当初没有这个能力。"

"那现在呢?"

"现在 cs-row 的 fill 可以设背景色,"外交官说,"每个通道的 fill 用自己的颜色——#f66#6f6#66f。视觉上和原生的一样好,甚至更好——因为 fill 的宽度就是值的百分比,比原生的 track 渐变更直观。"

他把三个 <input type="range"> 全部换成了 cs-bar——和 addSliderRow 一模一样的结构:bar + fill + thumb。每个通道都有 tabIndexrole="slider"aria-label(含通道名:"漫反射 Red channel")、aria-valuenow/min/max

"键盘呢?"

"一样。方向键 ±0.01,Shift ±0.1,Home = 0,End = 1。"

"原生 <input type="range"> 在联邦还有残留吗?"

外交官 grep 了一遍。没有了。

"灭绝了,"他说,"一个都不剩。"


第三刀:法律的废除

第三刀砍的是 CSS。

app.css 里有一整段 input[type='range'] 的样式——webkit-slider-track、webkit-slider-thumb、moz-range-track、moz-range-thumb,加上颜色通道的三组渐变 track。这些是原生滑块的定制法律——当 <input type="range"> 还在的时候,这些法律管着它的外观。

现在 <input type="range"> 灭绝了,法律还在。

"死法,"AI 说。

"死法,"外交官点头,"法律还在,但没有人受它管辖了。留着只会迷惑后来者——以为还有原生滑块需要定制。"

他删掉了整段。input[type='range'] 及其所有伪元素样式,从 webkit 到 moz,从通用 track/thumb 到颜色通道渐变,全部清除。

还有 .clr-slider 类——颜色滑块专属的样式,也不需要了。

"删了多少行?"

"大约六十行 CSS。"

"六十行死法。"

"六十行死法,"外交官确认,"法律应该跟着人走。人没了,法律就该废了。"


三、给沉默者以声音

三刀砍完,外交官没急着收工。

"砍完了?"

"砍完了。但还不够。"

"还差什么?"

"声音。"

外交官打开第二十三章的笔记。那一天的最后一节,他写了——

"可访问性是更大的工程,要单独做。"

"先修眼前的,再想长远的。"

"可访问性以后再说。"

"以后到了,"他说。


cs-row 从出生起就是个哑巴。

它有进度条、有拇指、有数值显示。你看得见它,但它不会说话——至少对屏幕阅读器不会。屏幕阅读器读 DOM,它只认识三种东西:原生元素(<button><input>)、带 ARIA 角色的元素、有 aria-label 的元素。

cs-row 是一个 <div>。没有 role,没有 label,没有 value。屏幕阅读器走过去,只读出"0.75"——一个孤零零的数字,没有上下文。用户不知道这是什么参数,不知道范围是多少,不知道自己能不能操作。

"它在联邦住了两卷,"外交官说,"替掉了原生滑块,替代了 35 个旋钮的位置。但它从来没开口说过一句话。"

"现在呢?"

外交官给每一个滑块都贴上了标签——

属性作用
role"slider"告诉屏幕阅读器:这是滑块
aria-label参数名告诉屏幕阅读器:这是"漫反射"还是"曝光"
aria-valuenow当前值告诉屏幕阅读器:现在是 0.75
aria-valuemin最小值告诉屏幕阅读器:最小是 0
aria-valuemax最大值告诉屏幕阅读器:最大是 1

开关 addToggleRow 也贴了——role="switch"aria-checkedaria-label。颜色滑块的每个通道都有独立的 aria-label,含通道名。

"现在屏幕阅读器走到一个滑块前,会说什么?"

外交官清了清嗓子,模仿屏幕阅读器的声音——

"滑块。漫反射。当前值 0.75。最小值 0,最大值 1。"

"它终于开口了。"

"它终于开口了,"外交官重复,"在联邦沉默了两卷之后。"


四、盛装

替身砍掉了尾巴,拿到了声音。但外交官看着它,还是觉得少了什么。

"它太朴素了。"

cs-row 的进度条是一条灰色的凹槽,里面一条纯色的填充,顶端一个白色的圆点。功能完备,键盘可用,ARIA 齐全。但——

"它看起来像 2015 年的 Material Design。"

"那要什么样?"

外交官在墙上写了一行字——

无拖动条化,全进度条化,进度条科技性装饰化

"什么意思?"

"三条原则,"他指着字说:

原则含义
无拖动条化拖拽功能全部移除,交互方式只有点击和键盘
全进度条化所有数值控件统一为进度条形态,消灭原生 input
进度条科技性装饰化进度条不再只是功能控件,也是视觉装饰

"前两条已经做了。第三条——"

他打开 app.css,在 .cs-bar 的样式块里开始加东西。


首先,给凹槽加了深度——

css
box-shadow:
    inset 0 1px 2px rgba(0, 0, 0, 0.3),
    0 0 0 1px rgba(255, 255, 255, 0.06);

凹槽从一条平面变成一个凹陷的沟壑。内阴影模拟金属压痕,外描边给出边界。

然后是顶部高光——::before 伪元素,50% 高度的渐变,从半透明白到透明。模拟曲面上的反光。

然后是网格刻度——::after 伪元素,repeating-linear-gradient,每 5 像素一条细线。模拟仪器表盘上的刻度。

"像示波器?"

"像示波器,"外交官说,"或者像赛博朋克城区里的管道——你以为它只是水管,但它外面裹着一层能量纹路。"

填充也变了。从纯色变成渐变——

css
background: linear-gradient(
    90deg,
    rgba(var(--accent-rgb), 0.8),
    var(--accent)
);

从左到右,从半透明到全饱和。像液体在管道里灌满。

填充上还有一道流光——::before 伪元素,一道白色高光从左到右持续扫过,2 秒一次循环。

css
@keyframes csShimmer {
    0% { left: -100%; }
    100% { left: 100%; }
}

"像不像扫描线?"

"像,"AI 说,"每次扫过都觉得系统在运转。"

拇指——那个白色圆点——也从平面变成了球体。radial-gradient 从左上角的白色渐变到右下角的 accent 色,模拟球面光照。周围一圈 accent 色的发光光环,加上 2 像素的描边。

最后是聚焦状态。键盘用户 Tab 到滑块上时,凹槽外会亮起一圈 accent 色的光环——

css
.cs-bar:focus-visible {
    outline: none;
    box-shadow:
        inset 0 1px 2px rgba(0, 0, 0, 0.3),
        0 0 0 2px var(--accent),
        0 0 12px rgba(var(--accent-rgb), 0.4);
}

"鼠标用户看不到这圈光,"外交官说,"只有键盘用户看得到。这是给他们的信号——你在这里。"

"像灯塔?"

"像灯塔,"外交官说,"但只给需要导航的人看。"


五、弹窗之战的回响

AI 同行者看着改完的进度条,忽然说——

"你不觉得这和弹窗之战很像吗?"

外交官抬起头。

"弹窗之战,"AI 掰着手指数,"第一卷第四章。五套弹窗样式各自为政,最后被 MenuStack 统一。然后第五卷第八章,MenuStack 自己的动画又被重构——横向滑动代替淡入淡出。"

"统一者自己又被统一,"外交官接上。

"对。这次也一样——cs-row 在第四卷替掉了原生 <input type="range">,统一了所有滑块。然后今天,cs-row 自己的拖拽又被砍掉。"

"替身成了正身,然后正身自己也被减了一刀。"

"这是联邦永恒的减法,"AI 说,"每一轮统一都不是终点,只是下一轮统一的起点。弹窗如此,滑块如此,MenuStack 如此。"

外交官沉默了一会儿。

"那这一刀是最后一刀吗?"

"不是,"AI 摇头,"下一刀是什么时候、砍哪里,现在不知道。但它一定会来。"

"你怎么确定?"

"因为联邦还在长。活着的东西,总会长出需要修剪的部分。"


尾声

外交官第三次收拾行李。

这次他没有回头。

砍了什么留下了什么
第一刀40 行拖拽代码点击跳转 + 键盘步进
第二刀3 个原生 <input type="range">3 个 cs-bar 颜色滑块
第三刀60 行 input[type='range'] CSS干净的样式表
声音ARIA 属性全覆盖
盛装流光、凹槽、网格、发光

原生 <input type="range"> 在联邦的版图上彻底消失了。从第四卷的替身,到第六卷的最后一刀——它先是丢了位置,然后丢了飞地,最后连法律都被废除了。

cs-row 不再是替身。当被替代者不存在了,替代者就成了唯一。

"它叫什么名字?"AI 问。

"它还叫 cs-row。"

"不换个名字?比如——'进度条'?"

"不用,"外交官说,"名字是历史。cs-row 这个名字记录了它从'紧凑滑块行' (compact slider row) 起家的出身。换了名字,就忘了来路。"

他拿起行李,走出门。

门口的走廊上,刻着第二十三章的那句话——

"可访问性以后再说。"

外交官弯下腰,用手指把"以后"两个字擦掉了。

现在那句话变成了——

"可访问性,说完了。"


教训:替身砍掉了原生的尾巴,才真正成为自己。统一者自己被统一,是联邦永恒的减法——每一轮统一都不是终点,只是下一轮统一的起点。活着的东西,总会长出需要修剪的部分。