点全选,已选的被清空了:Transfer 三态复选框的三个 bug

0 阅读7分钟

给 Transfer 补边界用例,我先把每个选择态手动点了一遍。点到第二次就出事了:先勾一项(表头进入半选),再点表头想「全选」——已勾的那一项被取消了,整张列表回到 0。

顺着这条线查下去,同一个组件里一共挖出三个选择态 bug,外加一个 ARIA 语义问题。全部修完、用例补到 14 个,全量 868 个测试通过,随 ruanno-ui@0.2.11 发布。

三态复选框,只有两个动作

表头那个复选框看着简单,实际有三个状态、两个动作。任何一个分支写错,用户看到的现象都是「点了没反应」或者「选了又没了」。

当前状态点击后应该发生什么
一个都没选选中全部可选项目
选了一部分(半选)补齐剩余可选项目
已全选取消全部选择

关键在于中间那行。很多人(包括我)会下意识把「有没有选中」当成判断依据,而正确的判断是「是不是全选」。

bug 1:只要选了任意一项,全选就变成全清

修复前的实现只有五行:

function toggleAll(side: "left" | "right") {
  const items = side === "left" ? filteredLeftItems.value : filteredRightItems.value;
  const list = side === "left" ? leftChecked : rightChecked;
  if (list.value.length > 0) list.value = [];
  else list.value = items.filter((i) => !i.disabled).map((i) => i.key);
}

list.value.length > 0 判断的是「有没有选中」。于是部分选中时走进了清空分支——用户以为在补全,实际在清空。

修复是把判断条件从「有没有」换成「是不是全部」:

const keys = items.filter((i) => !i.disabled).map((i) => i.key);
const allChecked = keys.length > 0 && keys.every((k) => list.value.includes(k));
list.value = allChecked
  ? list.value.filter((k) => !keys.includes(k))
  : Array.from(new Set([...list.value, ...keys]));

改完之后三个状态各走各的分支。实测第一个示例(7 项,其中「系统设置」禁用):

组件文档页顶部:权限分配示例,左 7 项、右 0 项

初始 0/7,表头是空框:

初始态:未分配权限 0/7,表头复选框未选

勾「查看用户」,计数 1/7,表头变成横线(半选):

勾选一项后:1/7,表头显示横线而不是勾

点表头,补到 6/7,表头变成勾:

点表头后:6/7,六个可选项全选中

为什么不是 7/7?因为第 7 项是禁用的。把左侧列表滚到底能看到它:

滚到底:系统设置灰着、未勾选,计数仍是 6/7

这张图顺便验证了「全选跳过禁用项」不是嘴上说说——计数 6/7、禁用项没勾、表头仍是全选态。

bug 2:分母把禁用项算进去了

修完 bug 1,我发现另一个数字不对:全选之后表头还是半选。

const leftIndeterminate = computed(
  () => leftChecked.value.length > 0 && leftChecked.value.length < leftItems.value.length,
);

leftItems.length 是 7(含 1 个禁用),而全选只能选到 6。于是 6 < 7 永远成立,半选态永远不消失。

分母必须是「可参与选择的项目数」:

function isIndeterminate(items: TransferItem[], checked: TransferKey[]) {
  const visible = items.filter((i) => !i.disabled);
  const n = visible.filter((i) => checked.includes(i.key)).length;
  return n > 0 && n < visible.length;
}

这个 bug 不报错、不崩溃,只是让表头永远少一档状态。靠读代码很难发现,只有把「全选」真的点下去、再看一眼图标才会暴露。

顺手量出来的一件事:count 不是「已移入数量」

写断言时我第一版写错了。把 6 项移到右侧后,我以为右侧应该显示 6/6,实际是 0/6:

右移之后:左侧只剩禁用项 0/1,右侧 0/6

0/6 里的 0 是「右侧勾选了几项」,不是「移入了几项」。移动完成后勾选被清空,所以是 0。计数器的语义是 勾选数/可见数,跟数据在哪一侧无关。

想看到 6/6,得再点一次右侧表头:

右侧表头全选:6/6

再点左箭头移回去,两侧勾选都复位:

移回左侧:0/7,右侧 0/0

这三个数字后来全部进了测试。如果我按「以为是 6/6」的假设写断言,测试第一次跑就会红——这算是断言帮我纠正了理解,而不是代码有问题。

bug 3:搜索 + 全选,看不见的已选项被丢掉

前两个修完,我以为结束了。直到给「搜索 + 全选」补用例:

先勾「查看用户」,再在搜索框输入「订单」(过滤后只剩 2 项),点表头全选。修复前的实现是:

list.value = selectable.map((i) => i.key);   // 整体替换

它把「当前可见项」当成新的全部选中集合——之前勾的「查看用户」不在可见项里,被一起丢掉了。用户看到的是:搜索一下、点个全选,之前选的东西没了。

改成增量更新:

list.value = allChecked
  ? list.value.filter((k) => !keys.includes(k))   // 只移除可见项
  : Array.from(new Set([...list.value, ...keys])); // 取并集

实测:已勾 1 项,搜索「订单」后可见 2 项,点表头,计数变成 3/7(1 + 2,而不是只剩 2):

搜索「订单」后点表头:计数 3/7,已选的是并集而不是替换

清空搜索,可见 6 项里有 3 项已选,表头回到半选:

清空搜索:3/7,表头回到半选态

再搜索「订单」,这两项都已选中,表头是全选;此时点一下,只取消这 2 项:

过滤状态下取消全选:只移除可见的 2 项,计数回到 1/7

这一步还暴露了「半选」的判定范围问题:它必须按当前可见项算。否则隐藏项会把表头顶成「全选」或「半选」,而用户眼前的列表明明是另一种状态。

顺带修的:aria-checked 不是布尔值

改到这里我去核对表头的 ARIA 属性,发现两处问题。

第一,aria-checked 写的是 leftChecked.length > 0:

<span role="checkbox" :aria-checked="leftChecked.length > 0">

半选时它输出 true。读屏用户听到「已选中」,但列表里明明只选了一部分。ARIA 的 checkbox 支持三个值:true / false / mixed,半选就该用 mixed。

第二,这个 span 有 role="checkbox" 却没有 tabindex,也没有键盘处理——读屏说它是复选框,键盘用户却永远碰不到它。这和之前 Cell / Grid 的问题一模一样:给了语义,没给入口。

两处一起修:

<span
  role="checkbox"
  tabindex="0"
  :aria-checked="leftIndeterminate ? 'mixed' : leftAllSelected"
  @click="toggleAll('left')"
  @keydown.enter.prevent="toggleAll('left')"
  @keydown.space.prevent="toggleAll('left')"
>

实测键盘路径:聚焦表头按 Enter,计数从 1/7 变成 6/7,aria-checked 从 mixed 变成 true:

键盘聚焦表头按 Enter:同样完成全选,6/7

注意 aria-checked 和可见状态现在都由同一组计算属性派生(leftIndeterminate / leftAllSelected),不会再出现「视觉是半选、读屏说全选」的分叉。

测试里三个容易写错的断言

这次补的 8 个用例,几乎每个都踩过一次坑,挑三个说。

一、mount 的 props 是静态的,不要断言 DOM 回写。

moveTo 只发 update:modelValue,父级没有真的更新 props。所以「移到右侧后右侧有几项」在单测里不会变。断言应该打在事件载荷上:

expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([[1]]);

二、trigger("keydown") 不触发浏览器默认行为。

它能证明「按键处理器被调用了」,不能证明「真实浏览器里 Enter 真的能激活」。后者只能靠真机(这次就是靠 ego 真浏览器按 Enter 验的)。

三、计数的分母是「可见项总数」,包含禁用项。

全选后是 3/4,不是 3/3——第 4 项禁用,不参与勾选,但仍算在「共有几项」里。第一次写 3/3 的断言直接红了。

结果

Transfer 的用例从 6 个补到 14 个:

用例钉住的行为
部分选中时表头显示半选三态的视觉前提
半选点表头补齐bug 1
全选跳过 disabledbug 2 的前提
移动后勾选复位count 语义
aria-checked 输出 mixedARIA 三态
表头 tabindex + Enter键盘入口
过滤后全选取并集bug 3
过滤后取消只移除可见项bug 3 的另一半

全量 868 个用例、104 个文件通过,ruanno-ui@0.2.11 已发布,文档站同步更新。Playground 里可以自己拖着参数试:

Playground 面板:props 与示例联动

同一个逻辑在第二个示例(数字 key + 禁用成员)里表现一致,说明修复没有依赖 key 的类型:

成员挑选示例:数字 key,同样跳过「休假中」的禁用成员

结论

三个 bug 有一个共同点:都不是「算错了」,而是「判断依据选错了」。

  • bug 1 用「有没有选中」代替「是不是全选」;
  • bug 2 用「全部项目数」代替「可选项目数」;
  • bug 3 用「可见项」代替「已选项」。

三处代码都能跑、都不报错,只在特定操作顺序下表现成「选了又没了」。这类问题读过一遍源码也未必看得出,必须真的按用户的操作顺序点一遍——先勾一项,再全选;先勾一项,再搜索,再全选。

真机验证的顺序也一样:先断言 DOM(计数、aria-checked、禁用项是否被勾),再截图。截图好看但数字不对的例子,前面已经吃过一次亏了。


在线示例:ui.ruanno.top/components/… 源码:gitee.com/ruanno-top/…