组件
控件建立在四档高度上——24、28、32、40。32 是按钮的默认档,40 是输入框族的默认档。
每个可交互元素支持同一套状态,且这套是封闭的:default、hover、active、focus-visible、disabled、loading、selected、error。没有别的状态。一个需要新状态的控件,是一个需要重新想清楚的控件。
按钮
破坏性操作用次级外壳配 error 文字,并加二次确认——绝不用红色实底。一个大红「删除」恰恰招来它本要防止的误点。按钮文案是动词:生成、导出、重新生成,不是「确定」不是「提交」。loading 保留宽度和文案,只把前置图标换成 spinner 并设 aria-busy。
输入框族:白底 + 描边
输入框族一律白底 + 1px stroke-input 描边。白底加描边把"可以往里写字"说得最清楚;灰底 background-input 因此空出来,专门留给取值胶囊和分段轨道。状态全靠描边表达:hover 提到 stroke-strong,focus 提到 brand-default,error 取 error。错误信息放在字段旁边。
取值胶囊:一种外形,四种宽度
统一落在外形上,宽度随内容走。四个胶囊的填充、描边、圆角、高度、对齐、字重完全一致; 宽度按内容分档。视觉锚点是右边缘,而它由布局对齐着——所以左边缘参差不齐不影响读取, 而一位数躺在三位数的框里会很显眼。每档余量控制在 13px 以内。
| 档 | 值 | 构成 | 用于 |
|---|---|---|---|
chip-w-sm | 48px | 10 + 两位数 17 + 10 | 纯整数,无单位 |
chip-w | 64px | 10 + 17 + 单位 26–34 | 两位数 + % 或 mm |
chip-w-lg | 80px | 10 + 三位数 25 + 共用格 32 | 带拖拽手柄 |
| 自定 | 88px+ | 按位数算 | 六位以上(如种子) |
统一的是外形,不是宽度。外形——白底、stroke-input 描边、8px 圆角、32 高、右对齐、500 字重、tabular-nums——必须完全一致;宽度按内容分档,每档余量控制在 13px 以内。
数值字段的横向拖拽
原生上下箭头一律不用:命中区小,和工具面板的语域不符。改成右端一个 24×24 的横向拖拽手柄。把光标移到下面的胶囊上试试——单位淡出、手柄淡入。
单位后缀和拖拽手柄占同一格。各留一格会在每个胶囊右侧多出 24px 常年空白(手柄不 hover 时看不见)。你正要拖的时候不需要看单位。 其余规则:位移 < 3px 判定为点击 → 聚焦并全选 · Shift 十倍步进 · setPointerCapture 拖出面板不丢 · RTL 下向左拖是增大 · 拿掉原生 spinner 就丢了键盘粗调,必须补 Shift + ↑/↓ = 十步 · 手柄 aria-hidden 且不进 Tab 序列 · 触摸设备手柄常驻 · 同一行已有滑块时不加手柄。
下拉:自造 listbox
原生 <select> 的弹出层无法换肤,在工具面板里会突然掉出一块系统 UI。所以自造,代价是把整份键盘与屏幕阅读器契约还回来,这不是可选项。选中态是 background-active 加一个对勾——光靠底色区分选中,在只有四项的列表里太弱,而且颜色不能单独承载含义。
| 契约项 | 要求 |
|---|---|
| 角色 | role="combobox" / listbox / option,配 aria-expanded、aria-selected、aria-activedescendant |
| 焦点模型 | ARIA 1.2 select-only combobox:焦点始终留在触发器上,不做焦点搬运(搬焦点更容易在关闭时把焦点丢在 body 上) |
| 关闭态键位 | ↑↓ / Enter / Space / Home / End / 任意字符 → 展开 |
| 展开态键位 | ↑↓ 移动 · Home/End 到头尾 · Enter/Space 选中并关闭 · Escape 关闭且不改值 · Tab 关闭 · 点击外部关闭 · 首字母跳转 |
| 焦点归还 | 关闭后把焦点还给触发器 |
| 浮层挂载 | 挂到 body 用 position:fixed——留在原地会被滚动容器和 overflow:hidden 裁掉。空间不够向上翻,夹在视口内。RTL 下与触发器右边缘对齐 |
| 无脚本回退 | HTML 里保留原生 <select>,样式与触发器一致 |
| 移动端代价 | 丢掉系统选择器,32px 选项在触屏偏小。手机场景多的话,窄断点下退回原生 |
滑块:三个数字是承重的
三个数字是承重的:轨道 6px、未填充段用 track-off(neutral-300)、 手柄 18px 配 1px stroke-strong 描边和 knob 阴影。 未填充段的 1.47:1 靠面积成立——同样的比值画成发丝线就看不见了,画成 6px 带子就看得见; 已填充与未填充之间是 12:1,位置一眼读出。手柄的描边同理:白圆落在浅轨道上,光靠阴影会化开, 而手柄是靠形状承载数值位置的东西。
开关
结构是一个真实的 <input type="checkbox">,轨道和滑块只是画在它之上的装饰。 每一层装饰都必须 pointer-events: none,点击要落到底下那个真实 input 上; 并把整个东西包进它的 <label>,让命中区覆盖文字。轨道高 24px 刚好等于最小命中区。
分段控件 与 分区标题
分段控件:灰轨道 + 3px 内边距 + 10px 圆角,活动段白色 + micro。它不是 Tab,不承载页面切换。 分区标题:整行是一个 <button aria-expanded>,标题文字在左、箭头在行尾——这样标题文字和字段名对齐成一条线,箭头和取值胶囊对齐成另一条线。箭头用上/下而不是左/右,因为上下不参与 RTL 镜像,省掉一条特例规则。只动 hidden 和一个 transform,没有高度动画。
说明文字要吝啬
说明文字要吝啬。能写进单位后缀的就不要写成一句话——20 % 好过"占单块边长的百分比"; 能从结果栏读到的就不要在配置栏重复。三级文本色适合一行提示,不适合成段。 理想状态是整个配置面板只剩两三条真正带约束的说明,比如上面这条——它给的是生产事实,不是把标签再说一遍。