跳到正文
册页

浮层

SearchableMenu - 可搜索菜单

一个能打字筛选的动作菜单。

用法

什么时候用它

DropdownMenu 超过十几行就不再能扫读,而用二级菜单去救只会更糟。这就是同一份列表加上一个过滤框。它不是 Command 面板:那个是页面级的、模态的;这个锚在某个控件上。
TSX
import { SearchableMenu } from '@misoto22/folio'

说明

一个能打字筛选的动作菜单。

这个正方形本来只有三个角,它是第四个;值得把区别讲清楚,因为拿错太容易了:

| | 选项少 | 选项多 | |---|---|---| | 设置一个值 | Select | Combobox | | 执行一个动作 | DropdownMenu | SearchableMenu |

DropdownMenu 超过十几行就不再能扫读,而通常的应对——套二级菜单——只会更糟。这个就是同一份列表,上面加一个筛选框。

和 Command 不是一回事:那个是页面级的面板,通常是模态的,通常绑在 ⌘K 上。这个锚在某个控件上,就像它替代的那个菜单一样。

这些行是 listbox 里的 option 而不是 menuitem,因为筛选这个模式要求如此——高亮通过 aria-activedescendant 移动而焦点留在输入框里,菜单做不到这件事。这个取舍是刻意的:一个没法筛的菜单,对读者来说比一个会执行动作的 listbox 更糟。

结构

SearchableMenu anatomy
部件说明
触发器必填这里自己搭的一个胶囊按钮,不是 Button:--control-h-sm 高,一条发丝线边框,带一个尖角。它的无障碍名称是 label,不是你当作它的文字传进去的 children。
面板必填一个去掉了内边距、宽度写死 16rem 的 Popover,所以筛选框和列表在里面是齐边铺满的。它不是模态的——这是 Popover,不是 Dialog。
筛选框必填Command 的输入框。打开时焦点落在这里,而且一直留在这里;高亮是通过 aria-activedescendant 在它下面移动的。
行每个 MenuAction 一行,形态是 listbox 里的 option。shortcut 在行尾印成一个 Kbd,destructive 把它画成 --danger,而选中一行是先关掉菜单,再执行 onSelect。
空状态emptyMessage,筛选什么都匹配不到时显示。要说清什么才匹配得上,而不是写一句「没有结果」。

实践建议

推荐

  • 给动作配 keywords,是为了那些读者会伸手去打、而标签上没印出来的词——Export 配「download」,Delete 配「bin」。标签自己的文字会被替你提进筛选里;而一个纯由元素拼出来的标签一个字都印不出来,开发环境会警告 SEARCHABLE_MENU_LABEL_UNREADABLE,而不是把一行什么都匹配不上的东西发出去。
  • label 要就是触发器上看得见的那行字。它是作为 aria-label 设在触发器上的,会盖过 children——所以一个显示「Status」、label 却是「Row actions」的按钮,播报出来的是读者念不出口的东西(WCAG 2.5.3)。
  • 同一组的动作在数组里要挨着放:分组是靠遍历这份列表、并且只往最后一组里追加建起来的,所以同一个组名在别的行之后再次出现,会多出一个文字完全一样的第二个标题。

避免

  • 它底下是一个 Popover,所以不是模态的:筛选框开着的时候后面的页面照样滚,而面板会跟着重新锚定。真要把页面按住不动,那是 CommandDialog。
  • className 落在触发器上,不是面板上——面板宽度写死 16rem,没有任何属性能把它撑宽,所以一行长标签是折到第二行,而不是盒子长大把它装下。
  • 触发器是 --control-h-sm——默认密度下 36px,data-density="compact" 下 30px——低于 44px 的指针目标(WCAG 2.5.5);而且它不是系统那个 Button,所以 variant 和 size 都够不到它。

示例

a filtered menu

一个 DropdownMenu 超过十来行就不再能扫读,而嵌套子菜单只会更糟;这是同一份列表,上面加了个过滤器。它不是 Command 面板——那个是页面级的、模态的、绑在一个按键上的。这个是锚定在某个控件上的,就像它所取代的那个菜单一样,而且它底下就是一个 Popover,所以过滤器开着的时候,背后的页面照样能滚。这些行是 listbox 的 option,而不是 menuitem,因为过滤要求这样:高亮通过 aria-activedescendant 移动,焦点留在输入框里,而菜单做不到这一点。

nine actions, one filter

searchable keywords

过滤器匹配的是每一行的 value,而这个组件把 action.id 当成了那个 value——所以碰上 act_7f2 这样的 id,看得见的标签什么都匹配不上,敲你眼睛看到的东西,换回来的是空状态。keywords 就是补救,而它同时也是那些「读者会想到、却没人写下来」的词的去处:找 CSV 导出的人会敲 spreadsheet,要回滚一次发布的人会敲 undo。往下面的过滤器里随便敲一个,看着对的那一行留下来。

four opaque ids, matched by their keywords

groups in order

分组是把数组走一遍、并且只延长最后一个分组构建出来的,所以一个在别的行之后又出现一次的组名,会产出第二个文字完全相同的标题,而不是并进第一个。把同一组的成员挨在一起,就像这里,而排布完全由你书写它们的顺序控制——这里刻意没有排序属性。还有两样东西跟着行走:shortcut 会在行尾印成一枚键帽,而 destructive 会给它上色。触发器的无障碍名称是 label,不是你作为 children 传进去的那段文字,所以让两者说同一句话。

two groups, written adjacent

键盘操作

SearchableMenu keyboard interactions
按键作用
EnterSpace打开菜单。
↑↓移动高亮,焦点始终留在筛选框里。
Enter执行高亮那一项动作。
Escape什么都不执行,直接关闭。

无障碍

  • 在有边界的框里——设备预览、内嵌控制台——用 `<OverlayContainer container={el}>` 包住这棵子树。面板会渲染进那个元素,按它的边界翻转,而不是按视口;框上设的 `dir` 和 `data-density` 也就跟着生效了。
  • 行是 listbox 里的 option 而不是 menuitem,因为过滤这件事要求如此——高亮通过 aria-activedescendant 移动,焦点留在输入框里,而菜单做不到。
  • 这个取舍是刻意的:一个没法筛的菜单,对读者来说比一个会执行动作的 listbox 更糟。