RowActions
row-actions收拢表格行的操作,超出数量自动收进溢出菜单
用法
基本:主操作 + 常规 + 破坏性
层级用 tone 表达:主操作 brand、破坏性 danger、其余中性。破坏性动作给 confirm。
| 发票号 | 客户 | 金额 | 操作 |
|---|---|---|---|
| IV-2026-0431 | 延安百货大楼 | ¥ 128,400 | |
| IV-2026-0432 | 广云家政服务 | ¥ 32,000 |
<RowActions
actions={[
{ key: "view", label: "查看", tone: "brand", onSelect: () => {} },
{ key: "edit", label: "编辑", onSelect: () => {} },
{ key: "del", label: "删除", tone: "danger", confirm: { title: "确认删除这条记录?" }, onSelect: () => {} },
]}
/>超出 max 自动收进菜单
露出 max-1 个,其余进溢出菜单;破坏性动作在菜单里排最后并用分隔线隔开,避免手滑误触。
| 发票号 | 客户 | 金额 | 操作 |
|---|---|---|---|
| IV-2026-0431 | 延安百货大楼 | ¥ 128,400 | |
| IV-2026-0432 | 广云家政服务 | ¥ 32,000 |
<RowActions
max={3}
actions={[
{ key: "view", label: "查看" },
{ key: "edit", label: "编辑" },
{ key: "copy", label: "复制发票号" },
{ key: "export", label: "导出 PDF" },
{ key: "void", label: "作废", tone: "danger", confirm: { title: "确认作废这张发票?" } },
]}
/>按钮档(动作会改数据时)
variant=button 换成描边按钮:点击范围与可点性一眼可见。语气色与文字档一致,差的只是明显程度。
| 发票号 | 客户 | 金额 | 操作 |
|---|---|---|---|
| IV-2026-0431 | 延安百货大楼 | ¥ 128,400 | |
| IV-2026-0432 | 广云家政服务 | ¥ 32,000 |
<RowActions
variant="button"
actions={[
{ key: "view", label: "查看", tone: "brand" },
{ key: "edit", label: "编辑" },
{ key: "del", label: "删除", tone: "danger", confirm: { title: "确认删除这条记录?" } },
]}
/>图标档(密集表格)
variant=icon 省横向空间;label 自动当无障碍名与悬浮提示,按钮上不再有可见文字。
| 发票号 | 客户 | 金额 | 操作 |
|---|---|---|---|
| IV-2026-0431 | 延安百货大楼 | ¥ 128,400 | |
| IV-2026-0432 | 广云家政服务 | ¥ 32,000 |
<RowActions
variant="icon"
actions={[
{ key: "view", label: "查看", icon: <Eye className="size-4" /> },
{ key: "edit", label: "编辑", icon: <Pencil className="size-4" /> },
{ key: "del", label: "删除", tone: "danger", icon: <Trash2 className="size-4" />, confirm: { title: "确认删除?" } },
]}
/>禁用要给原因
灰按钮本身答不了「为什么不能点」。disabledReason 会在悬浮/聚焦时说明,收进菜单时直接写在名字后面。
| 发票号 | 客户 | 金额 | 操作 |
|---|---|---|---|
| IV-2026-0431 | 延安百货大楼 | ¥ 128,400 | |
| IV-2026-0432 | 广云家政服务 | ¥ 32,000 |
<RowActions
actions={[
{ key: "view", label: "查看" },
{ key: "del", label: "删除", tone: "danger", disabled: true, disabledReason: "已开票不可删除" },
]}
/>何时用
表格「操作」列里那一排动作(查看 / 编辑 / 导出 / 删除)。它管的是行操作这个模式本身的形状:谁重谁轻、多了怎么收、破坏性的怎么拦、禁用的怎么解释。
页面级的批量操作栏用 ProTable 的 batchActions;工具栏用 toolbarActions;单独一颗按钮就用 Button,不必套这一层。
导入
import { RowActions } from "@hulianui/ui"Props
| 名称 | 类型 | 默认 | 说明 |
|---|---|---|---|
| actions * | RowActionItem[] | - | 动作表。hidden 的项先剔掉,再算折叠 |
| variant | "text" | "button" | "icon" | "text" | 三档只差「有多明显」:文字档无边框、按钮档描边、图标档只有图标(要求每项都给 icon) |
| max | number | 3 | 最多露出几个。超出时露出前 max - 1 个,其余进溢出菜单 |
| size | "sm" | "md" | "sm" | 密度档 |
| align | "start" | "center" | "end" | "start" | 列内对齐 |
| moreLabel | string | 本地化「更多操作」 | 溢出菜单触发器的无障碍名 |
| revealOnHover | boolean | false | 平时隐去、悬浮该行才显现。需要父级行元素带 `group/row`;键盘聚焦时同样显现,触屏恒显 |
RowActionItem:
| 名称 | 类型 | 默认 | 说明 |
|---|---|---|---|
| key * | string | - | React key |
| label * | string | - | 动作名。必须是纯字符串:它同时当无障碍名、悬浮提示、菜单 type-ahead 检索词 |
| icon | ReactNode | - | 文字档作前缀,图标档是按钮的全部内容 |
| tone | "neutral" | "brand" | "danger" | "neutral" | 层级。主操作 brand、破坏性 danger |
| disabled | boolean | false | 不可用。仍可聚焦、仍能读到名字 |
| disabledReason | ReactNode | - | 为什么不可用。给了 `disabled` 就该给它 |
| confirm | RowActionConfirm | - | 二次确认:{ title, description?, confirmText?, cancelText? } |
| hidden | boolean | false | 按权限藏起来 |
| onSelect | () => void | Promise<unknown> | - | 点击回调;有 confirm 时在确认之后才调。返回 Promise 时自动进 loading(见下) |
| render | ReactElement | - | 换元素渲染,典型是路由 <Link> |
动效
动作按钮(含溢出菜单键)带库的按压反馈:按下轻微缩放,时长与曲线取自动效体系的 fast 档,prefers-reduced-motion: reduce 下自动去掉——这条偏好一律由库负责,不必在调用处关。这份手感来自 Button 底座(BUTTON_BASE_CLASS),全库走 <Button> 的地方一致,RowActions 不额外做什么。
revealOnHover 的显隐同样走 fast 档的过渡,减弱动效下变成直接切换。
异步动作
onSelect 返回 Promise 时组件自己处理这一整套,消费方不必再传 loading / disabled:
- 这个动作转圈,同一行里其他动作暂时点不动——一行里同时发两个写操作,服务端看到的顺序基本是随机的
- 有
confirm时确认键跟着转圈,成功才关框;失败留在原地让用户能重试 - 执行期间 Esc / 点遮罩 / 取消键都关不掉框:动作还在飞就关掉,用户会以为自己取消了,而它根本没被取消
- reject 时组件只结束 loading,不显示任何错误文案——那是业务语义,请在
onSelect里自己 catch 并 toast
{ key: "del", label: "删除", tone: "danger",
confirm: { title: "确认删除?" },
onSelect: async () => {
try { await api.remove(row.id); await mutate() }
catch (e) { toast({ title: "删除失败", tone: "danger" }); throw e } // 抛出去,框才会留在原地
} }三档怎么选
| 档位 | 长相 | 什么时候用 |
|---|---|---|
text(默认) | 无边框文字 | 动作以「只读跳转」为主的列表。一排边框会把表格切碎 |
button | 描边按钮 | 动作真的会改数据。可点性与点击范围不该靠猜 |
icon | 只有图标 | 密集表、列宽紧张。名字改由无障碍名与悬浮提示承担 |
三档的语气色一致(tone 说了算),溢出菜单键也跟着档位走——按钮档里不会冒出一颗无边框的「⋯」。
动作之间的间距也按档给:文字档 16px,按钮档与图标档 4px。不是一个常数,因为文字档的按钮是variant="link",而 link 在 Button 里被钉了 px-0(纯文字链接,文字左缘要与表头对齐)——
两个动作之间的全部间隔就只剩这个 gap;另外两档的按钮自带横向内边距,视觉间隔是 gap + 2×padding。
给同一个数,文字档就会挤成一句话。
设计判据
- 超出时露出 `max - 1` 个而不是 `max` 个:菜单键本身也占一格,露满
max再加一颗「⋯」,实际控件数就是max + 1,列宽会比调用方以为的宽一格。 - 破坏性动作在菜单里排最后并用分隔线隔开:菜单是「手滑就点中」的地方,把删除排在编辑旁边等于鼓励误触。
- 禁用不用原生 `disabled`:原生禁用的按钮既不可聚焦、也不派发指针事件,于是「这个按钮为什么是灰的」这条提示永远弹不出来——而那正是最需要它的时候。组件改用
aria-disabled+ 点击短路,名字仍可读、提示仍可弹、键盘仍可到达。 - 确认框由组件自己持有,不走命令式
modal.confirm:后者要求消费方在根上挂<ModalProvider />,漏挂时是静默无事发生——用户点了删除、什么都没弹、动作也没跑,控制台一声不响。行操作是最不该踩这个的地方。自持还有一个好处:同一个动作被折进菜单前后,确认体验完全一致。 - 导航型动作走 `render`:用
onSelect做router.push会丢掉 Cmd+点击开新标签、中键、右键复制链接——这些是后台用户天天用的原生能力,只有真的渲染成<a>才有。
禁忌 / 坑
label是string不是ReactNode,因为它要同时当无障碍名、提示文案与菜单检索词,这三处都只认字符串。要富文本请重新想想这个动作名是不是太长了。- 图标档里每项都该有
icon:没有的话按钮会是空的,只剩无障碍名——那是能读不能看。 hidden与disabled别混:没权限用 `hidden`(不该让人知道有这个动作),当前状态不允许用 `disabled` + `disabledReason`(该让人知道为什么现在不行)。- 窄屏不会自动少露几个:
max是个定值。列宽紧张时请自己按断点传更小的max,或者换variant="icon"。刻意没做容器查询自动降档——操作列的宽度本身就由内容决定,再让内容反过来跟着宽度变会绕成循环,实测不稳定。 - 全部动作被
hidden筛掉时组件什么都不渲染(不是留个空壳),所以别指望它撑出列宽。 - 一行超过 5 个动作时先想想是不是该做成批量操作或详情页里的操作,而不是继续加
max。
相关
Table · ProTable · Button · Menu · AlertDialog · Popconfirm
Playground
<RowActions
max={3}
actions={actions}
/>