Sortable
sortable拖拽排序 · @dnd-kit headless + 键盘可拖(Space 抓起/方向键移动) + 手柄/整项两式 + 横竖向 · 行内交互元素(输入框/按钮)指针与键盘双路守卫内置·默认即安全 + renderItem 给 index(序号/唯一 aria-label) · 受控 onChange(arrayMove)
用法
列设置 · 手柄拖拽(垂直)
受控 items + onChange 回吐新顺序;handle 时仅左侧手柄可拖,键盘可达。
拖拽调整列顺序(手柄拖动 / 聚焦手柄后 Space 抓起 · 方向键移动 · Space 放下)
- 订单编号唯一标识
- 客户名称来自客户主数据
- 订单金额含税
- 订单状态枚举
- 负责人当前跟单人
- 创建时间可排序
当前顺序:订单编号 → 客户名称 → 订单金额 → 订单状态 → 负责人 → 创建时间
const [fields, setFields] = useState(initialFields);
<Sortable
items={fields}
onChange={setFields}
handle
renderItem={(f) => (
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-foreground">{f.label}</span>
<span className="shrink-0 text-xs text-muted">{f.hint}</span>
</div>
)}
/>整项可拖(无手柄)
handle={false} 时整行可拖,适合行内无交互元素的简单列表。
拖拽调整列顺序(手柄拖动 / 聚焦手柄后 Space 抓起 · 方向键移动 · Space 放下)
- 订单编号唯一标识
- 客户名称来自客户主数据
- 订单金额含税
- 订单状态枚举
- 负责人当前跟单人
- 创建时间可排序
当前顺序:订单编号 → 客户名称 → 订单金额 → 订单状态 → 负责人 → 创建时间
const [fields, setFields] = useState(initialFields);
<Sortable
items={fields}
onChange={setFields}
renderItem={(f) => <span className="font-medium text-foreground">{f.label}</span>}
/>行内交互元素 + 序号(state.index)
整项可拖时,行内 input/button 不会被拖拽劫持(守卫内置在 sensor 层,无需设 handle);state.index 直接给出下标,用于「第 N 题」与唯一 aria-label。
行内有输入框与按钮:拖它们不会触发排序,拖空白处才排序
- 第 1 题您对本次服务的整体满意度
- 第 2 题上门人员是否准时
- 第 3 题问题是否一次解决
<Sortable
items={list}
onChange={setList}
renderItem={(q, { index }) => (
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-xs text-muted">第 {index + 1} 题</span>
<span className="min-w-0 flex-1 truncate">{q.title}</span>
{/* 输入框可拖选文字、按钮可点,都不会触发排序 */}
<input type="number" value={q.score} aria-label={`第 ${index + 1} 题分值`} onChange={...} />
<button type="button" aria-label={`删除第 ${index + 1} 题`} onClick={...}>删除</button>
</div>
)}
/>横向排序(orientation)
orientation="horizontal" 横向排列,适合看板列 / 筛选标签。
横向拖拽排序(看板列 / 筛选标签)
- 待处理
- 进行中
- 已完成
- 已归档
const [tags, setTags] = useState(initialTags);
<Sortable
items={tags}
orientation="horizontal"
onChange={setTags}
renderItem={(t) => <span className="font-medium text-foreground">{t.name}</span>}
/>何时用
单列表内拖拽改顺序(表格列设置、标签排序、表单字段排序),受控数据 + onChange 回吐 arrayMove 后的新数组。要跨多列流转(看板/任务流)用 Kanban;要节点画布连线编排用 Flow。
导入
import { Sortable } from "@hulianui/ui"Props
SortableProps<T> 泛型。
| 名称 | 类型 | 默认 | 说明 |
|---|---|---|---|
| items * | T[] | — | 受控数据数组;拖拽后由你据 onChange 写回 state |
| getId | (item: T) => UniqueIdentifier | 读 item.id | 取每项稳定 id,须列表内唯一且稳定 |
| orientation | "vertical"|"horizontal" | "vertical" | 排列方向 |
| handle | boolean | false | true=仅左侧手柄可拖(触屏体验更稳、抓手更明确);false=整项可拖。行内交互元素已由组件守卫,两种模式都不会劫持 |
| className | string | — | 容器类名 |
Events
| 事件 | 类型 | 说明 |
|---|---|---|
| onChange * | (items: T[]) => void | 顺序变化回调(拖拽或键盘移动均触发),参数是 arrayMove 后的新数组 |
Slots
| 插槽 | 类型 | 说明 |
|---|---|---|
| renderItem * | (item: T, state: SortableItemState) => ReactNode | 渲染单项的渲染函数;state.dragging 表示该项正被拖拽,state.index 是该项在 items 中的下标(0 起) |
SortableItemState:
| 字段 | 类型 | 说明 |
|---|---|---|
| dragging | boolean | 该项是否正被拖拽 |
| index | number | 该项在当前 items 中的下标(0 起)。用于「第 N 项」序号与行内控件的唯一 aria-label,无需消费方 items.findIndex 兜回来(O(n²)) |
禁忌 / 坑
- 受控组件:
onChange不会替你改 state,必须自己把新数组写回(onChange={setItems})。 getId返回的 id 必须稳定唯一;用数组下标当 id 会在重排后错乱。- 行内的
input / textarea / select / button / label / a / [role=button] / [role=link] / [contenteditable]不会劫持拖拽——守卫内置在指针 sensor 里,默认(`handle={false}`)就安全,不必为此设handle(参考 [[dnd-kit-draggable-container-guard-interactive-children]])。自绘的可拖控件(色卡、滑块、画布)不在上述标签之列,给它加data-no-drag即可放行。 - 拖拽手柄的无障碍名称跟随
ConfigProvider locale;enUS提供 “Reorder item N”,未包 Provider 时保持中文。 - 守卫向上查找止步于当前项(
<li>),不会一路找到 document——整个列表被外层<a>/<label>包住时不会全体锁死。 - 展示序号别用
items.findIndex(...)反查(O(n²)),直接取renderItem第二参的state.index。
相关
Table · Book3D · ProTable · PricingTable · JsonViewer · EditableTable
Playground
拖拽调整列顺序(手柄拖动 / 聚焦手柄后 Space 抓起 · 方向键移动 · Space 放下)
- 订单编号唯一标识
- 客户名称来自客户主数据
- 订单金额含税
- 订单状态枚举
- 负责人当前跟单人
- 创建时间可排序
当前顺序:订单编号 → 客户名称 → 订单金额 → 订单状态 → 负责人 → 创建时间
const [items, setItems] = useState(fields);
<Sortable
items={items}
onChange={setItems}
handle={true}
renderItem={(f) => <span>{f.label}</span>}
/>