Appearance
替身的最后一刀
背景:cs-row 拖拽交互过度设计、颜色滑块仍有原生 input 残留、无障碍缺失。
过程:移除拖拽 + 颜色滑块原生 input 灭绝 + 进度条美化 + ARIA 无障碍完善。
外交官走到门口了。
行李收拾好了。审计报告交了。设置页的五幕演完了。他甚至已经和 AI 同行者道了别。
然后他停住了。
"怎么了?"AI 问。
"第二十三章,"外交官说,"我说过一句话。"
"哪句?"
"'可访问性以后再说。'"
AI 沉默了一会儿。"……然后呢?"
"现在是'以后'了。"
一、替身的尾巴
故事要从第四卷说起。
那时候,<input type="range"> 是联邦的租客——住在这里,但不归联邦管。WebView2 一升级,它就可能换一副面孔。联邦的自意识忍了很久,终于用一套自定义的紧凑滑块 cs-row 把它替掉了。
替身继承了原生的位置,继承了原生的职责——35 个滑块,从材质到相机,从天空到 Bloom,全部换了新装。
但替身也继承了一样东西:拖拽。
<input type="range"> 的核心交互是拖——按住拇指,左右移动,值跟着变。cs-row 替掉了它的外壳,却把这套交互原封不动搬了过来。mousedown、mousemove、mouseup,外加 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"> 的脸,却替不掉它的手。"
"所以——砍?"
"砍。"
二、三刀
外交官在清单上写了三行字,每一行都是一刀。
第一刀:砍掉拖拽
addSliderRow 的 mousedown 事件改为 click。按住不放的逻辑全部删除——dragging 变量、onDragMove 函数、onDragEnd 函数、rafId、pendingX、dragRect,六个变量两个函数,一个不留。
"改成什么?"
"点击即跳转,"外交官说,"用户点进度条的哪个位置,值就跳到哪个位置。不需要按住,不需要拖。"
"那精细调整呢?以前可以慢慢拖到 0.73……"
"键盘,"外交官指了指,"方向键,步进 0.01。Shift 加速到 0.1。Home 回到最小,End 跳到最大。"
"这不是第二十三章答应过的吗?"
"对,"外交官说,"第二十三章说'可访问性以后再说'。这就是'以后'。"
他在 bar 上加了 tabIndex = 0——滑块可以被 Tab 键聚焦。加了 role="slider"——屏幕阅读器知道这是一个滑块。加了 aria-valuenow、aria-valuemin、aria-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。每个通道都有 tabIndex、role="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-checked、aria-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) 起家的出身。换了名字,就忘了来路。"
他拿起行李,走出门。
门口的走廊上,刻着第二十三章的那句话——
"可访问性以后再说。"
外交官弯下腰,用手指把"以后"两个字擦掉了。
现在那句话变成了——
"可访问性,说完了。"
教训:替身砍掉了原生的尾巴,才真正成为自己。统一者自己被统一,是联邦永恒的减法——每一轮统一都不是终点,只是下一轮统一的起点。活着的东西,总会长出需要修剪的部分。