Sortable 排序列表
通用拖拽重排序容器。Pointer Events + setPointerCapture 实现,跨桌面/移动端一致;列表、网格、横向 chip 行都可用。
English translation pending This page hasn't been translated yet — falling back to Chinese. PRs welcome on GitHub.
基础用法
传 items 数组 + itemKey(取唯一 id),通过默认插槽渲染每一项。拖动项目时邻居自动让位,松手提交新顺序。组件会 emit('update:items', next),所以可以直接用 v-model:items 双向绑定。
背景 视口
<script setup lang="ts">
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';
interface Item {
id: string;
label: string;
hint: string;
}
const items = ref<Item[]>([
{ id: 'a', label: '需求分析', hint: 'PM 输入' },
{ id: 'b', label: '设计稿评审', hint: 'Design' },
{ id: 'c', label: '前后端拉齐', hint: 'Eng' },
{ id: 'd', label: '联调', hint: 'QA' },
{ id: 'e', label: '上线灰度', hint: 'SRE' },
]);
</script>
<template>
<div class="sortable-demo">
<CfSortable v-model:items="items" item-key="id">
<template #default="{ item, isDragging }">
<div class="sortable-row" :class="{ 'is-active': isDragging }">
<span class="sortable-row__index" />
<div class="sortable-row__body">
<strong>{{ item.label }}</strong>
<span>{{ item.hint }}</span>
</div>
</div>
</template>
</CfSortable>
<pre class="sortable-demo__state">{{ items.map((x) => x.id).join(' → ') }}</pre>
</div>
</template>
<style scoped>
.sortable-demo {
display: grid;
gap: 10px;
}
.sortable-row {
display: grid;
grid-template-columns: 8px 1fr;
gap: 10px;
align-items: center;
padding: 10px 12px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-3);
}
.sortable-row.is-active {
border-color: var(--accent-1);
background: var(--bg-2);
}
.sortable-row__index {
width: 4px;
height: 22px;
background: var(--accent-1);
border-radius: 2px;
}
.sortable-row__body {
display: flex;
align-items: baseline;
gap: 10px;
color: var(--fg-1);
}
.sortable-row__body span {
color: var(--fg-3);
font-size: var(--t-12);
}
.sortable-demo__state {
margin: 0;
padding: 8px 10px;
background: var(--bg-inset);
border-radius: var(--r-3);
font-size: var(--t-12);
color: var(--fg-2);
}
</style> <script setup>
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';
const items = ref<Item[]>([
{ id: 'a', label: '需求分析', hint: 'PM 输入' },
{ id: 'b', label: '设计稿评审', hint: 'Design' },
{ id: 'c', label: '前后端拉齐', hint: 'Eng' },
{ id: 'd', label: '联调', hint: 'QA' },
{ id: 'e', label: '上线灰度', hint: 'SRE' },
]);
</script>
<template>
<div class="sortable-demo">
<CfSortable v-model:items="items" item-key="id">
<template #default="{ item, isDragging }">
<div class="sortable-row" :class="{ 'is-active': isDragging }">
<span class="sortable-row__index" />
<div class="sortable-row__body">
<strong>{{ item.label }}</strong>
<span>{{ item.hint }}</span>
</div>
</div>
</template>
</CfSortable>
<pre class="sortable-demo__state">{{ items.map((x) => x.id).join(' → ') }}</pre>
</div>
</template>
<style scoped>
.sortable-demo {
display: grid;
gap: 10px;
}
.sortable-row {
display: grid;
grid-template-columns: 8px 1fr;
gap: 10px;
align-items: center;
padding: 10px 12px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-3);
}
.sortable-row.is-active {
border-color: var(--accent-1);
background: var(--bg-2);
}
.sortable-row__index {
width: 4px;
height: 22px;
background: var(--accent-1);
border-radius: 2px;
}
.sortable-row__body {
display: flex;
align-items: baseline;
gap: 10px;
color: var(--fg-1);
}
.sortable-row__body span {
color: var(--fg-3);
font-size: var(--t-12);
}
.sortable-demo__state {
margin: 0;
padding: 8px 10px;
background: var(--bg-inset);
border-radius: var(--r-3);
font-size: var(--t-12);
color: var(--fg-2);
}
</style> import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';
export default function Demo() {
interface Item {
id: string;
label: string;
hint: string;
}
const [items, setItems] = useState<Item[]>([
{ id: 'a', label: '需求分析', hint: 'PM 输入' },
{ id: 'b', label: '设计稿评审', hint: 'Design' },
{ id: 'c', label: '前后端拉齐', hint: 'Eng' },
{ id: 'd', label: '联调', hint: 'QA' },
{ id: 'e', label: '上线灰度', hint: 'SRE' },
]);
return (
<>
<div className="sortable-demo">
<CfSortable items={items} onItemsChange={setItems} item-key="id">
<div className="sortable-row" className={{ 'is-active': isDragging }}>
<span className="sortable-row__index" />
<div className="sortable-row__body">
<strong>{item.label}</strong>
<span>{item.hint}</span>
</div>
</div>
</>
);
} import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';
export default function Demo() {
const [items, setItems] = useState<Item[]>([
{ id: 'a', label: '需求分析', hint: 'PM 输入' },
{ id: 'b', label: '设计稿评审', hint: 'Design' },
{ id: 'c', label: '前后端拉齐', hint: 'Eng' },
{ id: 'd', label: '联调', hint: 'QA' },
{ id: 'e', label: '上线灰度', hint: 'SRE' },
]);
return (
<>
<div className="sortable-demo">
<CfSortable items={items} onItemsChange={setItems} item-key="id">
<div className="sortable-row" className={{ 'is-active': isDragging }}>
<span className="sortable-row__index" />
<div className="sortable-row__body">
<strong>{item.label}</strong>
<span>{item.hint}</span>
</div>
</div>
</>
);
} 拖拽手柄
传 handle 选择器,只有命中手柄的 pointerdown 才会启动拖拽。其余区域可正常点击 / 选中。
背景 视口
<script setup lang="ts">
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';
interface Track {
id: string;
title: string;
artist: string;
}
const tracks = ref<Track[]>([
{ id: '1', title: 'Northbound', artist: 'Riff Cohen' },
{ id: '2', title: 'Pyramids', artist: 'Frank Ocean' },
{ id: '3', title: 'Pegasus', artist: 'Inferno' },
{ id: '4', title: 'Cape', artist: 'Tycho' },
{ id: '5', title: 'Halftime', artist: 'Nas' },
]);
</script>
<template>
<CfSortable v-model:items="tracks" item-key="id" handle=".track-grip">
<template #default="{ item }">
<div class="track-row">
<button type="button" class="track-grip" aria-label="drag">
<svg viewBox="0 0 16 16" width="14" height="14">
<circle cx="6" cy="4" r="1.2" fill="currentColor" />
<circle cx="10" cy="4" r="1.2" fill="currentColor" />
<circle cx="6" cy="8" r="1.2" fill="currentColor" />
<circle cx="10" cy="8" r="1.2" fill="currentColor" />
<circle cx="6" cy="12" r="1.2" fill="currentColor" />
<circle cx="10" cy="12" r="1.2" fill="currentColor" />
</svg>
</button>
<div class="track-meta">
<strong>{{ item.title }}</strong>
<span>{{ item.artist }}</span>
</div>
</div>
</template>
</CfSortable>
</template>
<style scoped>
.track-row {
display: grid;
grid-template-columns: 32px 1fr;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-3);
cursor: default;
}
.track-grip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
background: transparent;
color: var(--fg-3);
border-radius: var(--r-2);
cursor: grab;
touch-action: none;
}
.track-grip:hover {
color: var(--fg-1);
background: var(--bg-2);
}
.track-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.track-meta strong {
color: var(--fg-1);
font-size: var(--t-13);
}
.track-meta span {
color: var(--fg-3);
font-size: var(--t-12);
}
</style> <script setup>
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';
const tracks = ref<Track[]>([
{ id: '1', title: 'Northbound', artist: 'Riff Cohen' },
{ id: '2', title: 'Pyramids', artist: 'Frank Ocean' },
{ id: '3', title: 'Pegasus', artist: 'Inferno' },
{ id: '4', title: 'Cape', artist: 'Tycho' },
{ id: '5', title: 'Halftime', artist: 'Nas' },
]);
</script>
<template>
<CfSortable v-model:items="tracks" item-key="id" handle=".track-grip">
<template #default="{ item }">
<div class="track-row">
<button type="button" class="track-grip" aria-label="drag">
<svg viewBox="0 0 16 16" width="14" height="14">
<circle cx="6" cy="4" r="1.2" fill="currentColor" />
<circle cx="10" cy="4" r="1.2" fill="currentColor" />
<circle cx="6" cy="8" r="1.2" fill="currentColor" />
<circle cx="10" cy="8" r="1.2" fill="currentColor" />
<circle cx="6" cy="12" r="1.2" fill="currentColor" />
<circle cx="10" cy="12" r="1.2" fill="currentColor" />
</svg>
</button>
<div class="track-meta">
<strong>{{ item.title }}</strong>
<span>{{ item.artist }}</span>
</div>
</div>
</template>
</CfSortable>
</template>
<style scoped>
.track-row {
display: grid;
grid-template-columns: 32px 1fr;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-3);
cursor: default;
}
.track-grip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
background: transparent;
color: var(--fg-3);
border-radius: var(--r-2);
cursor: grab;
touch-action: none;
}
.track-grip:hover {
color: var(--fg-1);
background: var(--bg-2);
}
.track-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.track-meta strong {
color: var(--fg-1);
font-size: var(--t-13);
}
.track-meta span {
color: var(--fg-3);
font-size: var(--t-12);
}
</style> import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';
export default function Demo() {
interface Track {
id: string;
title: string;
artist: string;
}
const [tracks, setTracks] = useState<Track[]>([
{ id: '1', title: 'Northbound', artist: 'Riff Cohen' },
{ id: '2', title: 'Pyramids', artist: 'Frank Ocean' },
{ id: '3', title: 'Pegasus', artist: 'Inferno' },
{ id: '4', title: 'Cape', artist: 'Tycho' },
{ id: '5', title: 'Halftime', artist: 'Nas' },
]);
return (
<>
<CfSortable items={tracks} onItemsChange={setTracks} item-key="id" handle=".track-grip">
<div className="track-row">
<button type="button" className="track-grip" aria-label="drag">
<svg viewBox="0 0 16 16" width="14" height="14">
<circle cx="6" cy="4" r="1.2" fill="currentColor" />
<circle cx="10" cy="4" r="1.2" fill="currentColor" />
<circle cx="6" cy="8" r="1.2" fill="currentColor" />
<circle cx="10" cy="8" r="1.2" fill="currentColor" />
<circle cx="6" cy="12" r="1.2" fill="currentColor" />
<circle cx="10" cy="12" r="1.2" fill="currentColor" />
</svg>
</button>
<div className="track-meta">
<strong>{item.title}</strong>
<span>{item.artist}</span>
</div>
</div>
</>
);
} import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';
export default function Demo() {
const [tracks, setTracks] = useState<Track[]>([
{ id: '1', title: 'Northbound', artist: 'Riff Cohen' },
{ id: '2', title: 'Pyramids', artist: 'Frank Ocean' },
{ id: '3', title: 'Pegasus', artist: 'Inferno' },
{ id: '4', title: 'Cape', artist: 'Tycho' },
{ id: '5', title: 'Halftime', artist: 'Nas' },
]);
return (
<>
<CfSortable items={tracks} onItemsChange={setTracks} item-key="id" handle=".track-grip">
<div className="track-row">
<button type="button" className="track-grip" aria-label="drag">
<svg viewBox="0 0 16 16" width="14" height="14">
<circle cx="6" cy="4" r="1.2" fill="currentColor" />
<circle cx="10" cy="4" r="1.2" fill="currentColor" />
<circle cx="6" cy="8" r="1.2" fill="currentColor" />
<circle cx="10" cy="8" r="1.2" fill="currentColor" />
<circle cx="6" cy="12" r="1.2" fill="currentColor" />
<circle cx="10" cy="12" r="1.2" fill="currentColor" />
</svg>
</button>
<div className="track-meta">
<strong>{item.title}</strong>
<span>{item.artist}</span>
</div>
</div>
</>
);
} 横向轴
传 axis="x" 让排序方向变为水平。Chip 行、Tab 行、画廊缩略图常用。
背景 视口
<script setup lang="ts">
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';
interface Tab {
id: string;
label: string;
}
const tabs = ref<Tab[]>([
{ id: 'overview', label: '总览' },
{ id: 'logs', label: '日志' },
{ id: 'metrics', label: '指标' },
{ id: 'traces', label: '链路' },
{ id: 'errors', label: '错误' },
]);
</script>
<template>
<CfSortable v-model:items="tabs" item-key="id" axis="x">
<template #default="{ item }">
<div class="tab-chip">{{ item.label }}</div>
</template>
</CfSortable>
</template>
<style scoped>
.tab-chip {
display: inline-flex;
align-items: center;
padding: 6px 14px;
margin-right: 6px;
background: var(--bg-2);
color: var(--fg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-pill);
font-size: var(--t-13);
white-space: nowrap;
}
</style> <script setup>
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';
const tabs = ref<Tab[]>([
{ id: 'overview', label: '总览' },
{ id: 'logs', label: '日志' },
{ id: 'metrics', label: '指标' },
{ id: 'traces', label: '链路' },
{ id: 'errors', label: '错误' },
]);
</script>
<template>
<CfSortable v-model:items="tabs" item-key="id" axis="x">
<template #default="{ item }">
<div class="tab-chip">{{ item.label }}</div>
</template>
</CfSortable>
</template>
<style scoped>
.tab-chip {
display: inline-flex;
align-items: center;
padding: 6px 14px;
margin-right: 6px;
background: var(--bg-2);
color: var(--fg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-pill);
font-size: var(--t-13);
white-space: nowrap;
}
</style> import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';
export default function Demo() {
interface Tab {
id: string;
label: string;
}
const [tabs, setTabs] = useState<Tab[]>([
{ id: 'overview', label: '总览' },
{ id: 'logs', label: '日志' },
{ id: 'metrics', label: '指标' },
{ id: 'traces', label: '链路' },
{ id: 'errors', label: '错误' },
]);
return (
<>
<CfSortable items={tabs} onItemsChange={setTabs} item-key="id" axis="x">
<div className="tab-chip">{item.label}</div>
</>
);
} import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';
export default function Demo() {
const [tabs, setTabs] = useState<Tab[]>([
{ id: 'overview', label: '总览' },
{ id: 'logs', label: '日志' },
{ id: 'metrics', label: '指标' },
{ id: 'traces', label: '链路' },
{ id: 'errors', label: '错误' },
]);
return (
<>
<CfSortable items={tabs} onItemsChange={setTabs} item-key="id" axis="x">
<div className="tab-chip">{item.label}</div>
</>
);
} API
Props
| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
items | T[] | — | 数据数组(必传) |
itemKey | keyof T | ((item, index) => string | number) | — | 唯一 key |
axis | 'x' | 'y' | 'y' | 排序轴 |
handle | string | — | 拖拽手柄 CSS 选择器;不传则整行可拖 |
disabled | boolean | false | 禁用拖拽 |
threshold | number | 4 | 起拖位移阈值 (px) |
animation | number | 180 | 邻居复位过渡时长 (ms) |
tag | string | 'div' | 根标签 |
Events
| 事件 | 载荷 | 说明 |
|---|---|---|
update:items | T[] | 新顺序,配合 v-model:items |
reorder | { from, to, items } | 提交后触发 |
drag-start | { item, index } | 越过 threshold 时 |
drag-end | { item, index, cancelled } | 松手 / Esc 取消 |
默认插槽
{ item, index, isDragging } — 渲染单条数据;isDragging 用于显示当前拖动项的悬浮态。
反馈与讨论
Sortable 排序列表 · Discussion