浮层
Command - 命令面板
可筛选的动作列表——⌘K 那个面。
用法
import { Command } from '@misoto22/folio'说明
可筛选的动作列表——⌘K 那个面。
建在 cmdk 上,而不是建在 Select 或菜单上,因为这个交互两者都不是:列表随你打字实时筛选,高亮行跟着方向键走而焦点留在输入框里,回车执行高亮那一行。这就是 ARIA combobox 模式,也是最不该自己手搓的部分——“aria-activedescendant 在动而焦点不动”,恰恰是自制面板在读屏软件下失灵的那一处。
结构
| 部件 | 说明 |
|---|---|
| 输入框必填 | CommandInput,也就是那个 combobox,带一个搜索字形,底下一条发丝线。它的高度是写死的 52px,不是 --control-h 那类 token,所以它不跟着 data-density 走。 |
| 列表必填 | CommandList,内联时高度上限 18rem,放进 CommandDialog 里抬到 26rem,靠自己那条六像素的发丝滚动条滚动。 |
| 行 | 一行:一个图标元素、标签、一条可选的附注,以及印成 Kbd 的快捷键。高亮那一行拿的是 --accent-muted 加行首一道强调色竖线,因为只靠一块填充,在滚动中很难抓住。 |
| 空状态 | CommandEmpty。只有树里真的存在一个空状态,cmdk 才会渲染它——所以没有它,一次没匹配上的筛选留下的是输入框底下一条空白。 |
| 页脚 | CommandFooter 和它里面的 CommandHint 行。命令面板是一块键盘界面,而它的按键是看不见的;这里是唯一会把它们印出来的地方。 |
实践建议
推荐
- 要用就用 CommandDialog,不要用光秃秃的 Command。根节点只是一个内联的带边框盒子,没有遮罩、没有焦点陷阱,自己也不处理 Escape——所以只用它搭起来的面板会一直开着,直到别的东西来关它。
- 永远要渲染 CommandEmpty。只有它在场,cmdk 才会显示空状态——所以漏掉它的面板,对一次什么都没匹配到的筛选给出的回答,是一个输入框加一条空白,外加零解释。
- 字形需要自己的尺寸或颜色时,给 CommandItem 传元素——icon={<Search size={16} />};不需要时就传组件——icon={Search}。两种写法在这里和在 DropdownMenuItem 里都行;它们过去隔着一次 import,彼此恰好相反。
- ⌘K 要自己绑,然后再把它印出来:这个组件里没有任何东西在监听按键,所以在应用加上 keydown 处理之前,这个面板根本没有快捷键;而读者是从 CommandFooter 里知道它存在的。
避免
- 除非那个值正是读者会打出来的东西,否则别给 CommandItem 传 value——cmdk 优先按 value 过滤,只有在没有 value 时才退回到行自己的文字,所以把一个 id 当 value 传进去,会让看得见的那个标签搜不到。id 必须留作 value 时,把标签里的那些词当作 keywords 传进去,SearchableMenu 就是这么做的。
- CommandDialog 是带着 hideTitle 和 showClose={false} 渲染的,所以这个面板既没有可见标题,也没有可见的关闭:出口只有 Escape 和遮罩,两个都不会自报家门。把 Escape 印在 CommandFooter 里,别假定人人都知道。
- 别指望这个面板跟着 data-density 走——CommandInput 是写死的 52px,不是 --control-h 那类 token,所以在 compact 下,应用里其他控件全都缩了,只有它这个输入框纹丝不动。
示例
inline
命令面板的各个部件,摊平在行内,好让它们同时可见:那个 combobox 输入框、带分组和分隔线的过滤列表,以及印着按键的页脚。在应用里要拿 CommandDialog,而不是这个光秃秃的根——这个根是一个行内的带边框盒子,没有遮罩、没有焦点陷阱、自己也不处理 Escape,所以拿它搭出来的面板会一直开着,直到别的什么东西把它关掉。永远渲染 CommandEmpty:cmdk 只有在树里存在一个空状态时才会显示它,所以一个没有它的面板,对一次匹配不上的过滤,回应的是一条空白的带子。另外注意,这里的 icon 属性收的是一个元素,和一个 import 之外的 DropdownMenuItem 正好相反。
dialog
命令面板几乎总是被使用的那个样子:模态、偏上居中,由这个例子自己绑定的一个按键打开。组件里没有任何东西在监听按键——只有应用加上处理器之后,这个快捷键才存在——而 CommandDialog 是带着 hideTitle 和 showClose={false} 渲染的,所以既没有可见的标题,也没有可见的关闭。Escape 和遮罩是仅有的两个出口,而且两个都不会自报家门,这也是为什么这里把快捷键印在页面上,以及为什么一个真实的面板会带一个 CommandFooter。这个处理器在 defaultPrevented 时会退出,好让它不跟另一个已经绑了同样按键的面板打架。
or press ⌘ K
values and keywords
cmdk 是按条目的 value 过滤的,只有在没有 value 时才退回用这一行自己的文字——所以把一个内部 id 当成 value 传进去,会让看得见的标签搜不出来,而这正是一个面板带着毛病交付出去最常见的方式。这里每一个 value 都是读者真的会去敲的东西,而每一行的后半段是一份 keywords,装着他们可能改敲的那些词:回滚配 revert,找人配 teammate。meta 是行尾那条安静的注记,说明这是哪一类东西——不是描述,因为一个每行都印一句话的面板,大约到第六行就不再能扫读了。
组成部分
在调用处组合,而不是通过 props 配置——所以这个组件没有预料到的布局,依然表达得出来。
CommandInput
筛选框。带着搜索图标和 combobox 语义。
这个组件没有自己的属性。
CommandList
这个组件没有自己的属性。
CommandEmpty
筛选没命中时显示。写清楚什么会命中,别写“无结果”。
这个组件没有自己的属性。
CommandFooter
底部那条键位提示。
面板是一个键盘界面,而它的键是看不见的:屏幕上没有任何东西说方向键会移动选中行、回车会执行它;一个伸手去够鼠标的读者,是被设计辜负了,而不是他自己的问题。
这个组件没有自己的属性。
CommandHint
CommandFooter 里的一组“键 + 动作”。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children必填 | ReactNode | 它们做什么——一个动词,小写,不要写成句子。 | |
| keys必填 | string[] | 这条提示描述的按键,用 `Kbd` 键帽打印出来。 |
同时接受 Omit<ComponentProps<'span'>, 'children'> 里的全部属性,它们会直接透传给底层元素,不再逐条列出。
CommandGroup
这个组件没有自己的属性。
CommandSeparator
分组之间的分隔线。
标成纯装饰,因为 ARIA 只允许 listbox 里出现 option 和 group——而这个库把它渲染成 role="separator",那会给每一个用到它的面板塞进一条严重的 aria-required-children 违规。分组本身已经会被播报,所以把分隔线从无障碍树里拿掉不损失任何东西。
这个组件没有自己的属性。
CommandItem
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| icon | RemixiconComponentType | ReactNode | 前置的图标字形。两种写法都行——`icon={<Settings size={16} />}` 传的是元素,`icon={Settings}` 传的是组件,尺寸由这里来定。 它是让长列表能被扫读的关键——眼睛先按形状分类再读字,四十行长得一样的文字会让这件事失效。 | |
| meta | ReactNode | 行尾一句安静的注解——这是个什么东西,或者它当前的状态。不是描述:一个每行都印一句话的面板,大约到第六行就不能扫读了。 | |
| shortcut | string | 打印在行尾的快捷键。 |
同时接受 ComponentProps<typeof CommandPrimitive.Item> 里的全部属性,它们会直接透传给底层元素,不再逐条列出。
CommandDialog
模态里的面板,也就是它几乎总是被用的那个样子。
对话框自己的内边距被去掉了:面板是通到边缘的,而且它的输入框是焦点第一个落到的地方。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children必填 | ReactNode | — | |
| label必填 | string | 给面板起个名字,供辅助技术使用。 | |
| onOpenChange必填 | (open: boolean) => void | — | |
| open必填 | boolean | — | |
| inputLabel | string | 搜索框的名字需要和对话框读起来不一样时用它——比如「资料库搜索」面板里的「搜索记录」输入框。默认取 `label`。在 `CommandInput` 上写 `aria-label` 做不到这一点:输入框是按引用命名的,而引用的优先级高于 `aria-label`。 | |
| onCloseAutoFocus | DialogContentProps['onCloseAutoFocus'] | 关闭时对话框交还焦点的那一刻调用。默认会把焦点还给 `DialogTrigger`,而通过快捷键打开的面板没有触发器,焦点就掉回了页面。阻止默认行为,然后把焦点放回打开它的那个元素。 | |
| onEscapeKeyDown | DialogContentProps['onEscapeKeyDown'] | 在 Escape 关闭对话框之前调用。阻止默认行为即可让它保持打开,就像嵌套视图后退一步那样。 | |
| onOpenAutoFocus | DialogContentProps['onOpenAutoFocus'] | 对话框把焦点移进来时调用。阻止默认行为即可自己安排焦点。 | |
| shouldFilter | boolean | 应用已经筛选过这些行时——服务端搜索,或它自己的索引——传 `false`。否则面板会拿输入再筛一遍,把应用本来要显示的结果藏起来。 |
键盘操作
| 按键 | 作用 |
|---|---|
| ↑↓ | 移动高亮。焦点留在输入框里,所以你打的字还能继续改。 |
| Enter | 执行高亮那一项。 |
| Escape | 关闭面板。 |
无障碍
- 列表随打字过滤,高亮随方向键移动,焦点始终留在输入框里。最后这条是 ARIA combobox 模式,也是自制面板一定会做错的地方。