iter(g301): 新增拖拽排序列表 sortableList

This commit is contained in:
HIsheR
2026-10-27 00:29:01 +08:00
parent b9c592a52a
commit ed3b863923
13 changed files with 324 additions and 10 deletions
+1
View File
@@ -87,3 +87,4 @@ export { sparkline } from './sparkline.js';
export { heatmap } from './heatmap.js';
export { progressRing } from './progressRing.js';
export { noticeBar } from './noticeBar.js';
export { sortableList } from './sortableList.js';
+109
View File
@@ -0,0 +1,109 @@
import { effect, h, signal } from '@ui-arch/frame';
import { empty } from './empty.js';
import type { SortableListHandle, SortableListProps } from './types.js';
/*** 拖拽排序列表:整行可拖(HTML5 拖放)+ Alt+↑↓/Home/End 键盘搬位,顺序变化写回 value 信号并触发 onChange ***/
export function sortableList(props: SortableListProps): SortableListHandle {
// —— 状态集中声明 ——
const value = props.value ?? signal(props.items); // 当前顺序:受控信号优先,否则内部持有
const disabled = (): boolean => (typeof props.disabled === 'function' ? props.disabled() : props.disabled === true); // 禁用读法:布尔直读、信号保持响应性
const handle = (el: HTMLElement): string => el.dataset.label ?? ''; // 行 → 条目文案(文案即排序键)
let dragging: HTMLElement | null = null; // 正在被拖的行:dragover 据此搬节点
const rows = new Map(props.items.map((text) => [text, h('div', {
class: 'ua-sortable-item', role: 'listitem', tabindex: '0', draggable: 'true', 'data-label': text,
}, h('span', { class: 'ua-sortable-grip', 'aria-hidden': 'true' }, '⠿'), h('span', { class: 'ua-sortable-label' }, text))])); // 行只建一次,排序靠搬节点
// —— 空列表提前返回:句柄三件套给齐,调用方无需判空 ——
if (!props.items.length) {
const el = h('div', { class: 'ua-sortable' }, empty({ title: props.emptyText ?? '暂无可排序项' })) as HTMLElement;
return { el, move: (): void => undefined, order: (): string[] => [] };
}
/*** 同步无障碍位次:role=list 的 posinset 随顺序变 ***/
const syncAria = (): void => { Array.from(root.children).forEach((el, i) => el.setAttribute('aria-posinset', String(i + 1))); };
/*** 按给定顺序重排 DOM:只搬已有节点,不重建元素(拖拽中重建会中断手势) ***/
const place = (next: string[]): void => {
next.forEach((text) => { const row = rows.get(text); if (row) root.append(row); }); // 依次 append 即按序落位
syncAria();
};
/*** 提交当前 DOM 顺序:顺序没变就不打扰外部 ***/
const commit = (): void => {
const next = Array.from(root.children).map((el) => handle(el as HTMLElement));
if (next.join('\u0000') === value().join('\u0000')) return;
value(next);
props.onChange?.(next);
};
const root = h(
'div',
{ class: 'ua-sortable', role: 'list' },
...(props.value ? props.value() : props.items).map((text) => rows.get(text)).filter((row): row is HTMLElement => !!row),
) as HTMLElement;
syncAria();
// —— 鼠标 / 触摸拖放:dragstart 记源行,dragover 就地搬节点,dragend 落定提交 ——
root.addEventListener('dragstart', (e) => {
const row = (e.target as HTMLElement).closest('.ua-sortable-item') as HTMLElement | null;
if (!row || disabled()) { e.preventDefault(); return; } // 禁用时不许拖起
dragging = row;
row.classList.add('is-dragging');
e.dataTransfer?.setData('text/plain', handle(row)); // Firefox 不设数据就不派发 dragover
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
});
root.addEventListener('dragover', (e) => {
if (!dragging) return;
e.preventDefault(); // 不拦截就拿不到 drop 与后续事件
const over = (e.target as HTMLElement).closest('.ua-sortable-item') as HTMLElement | null;
if (!over || over === dragging) return;
const box = over.getBoundingClientRect();
const after = e.clientY > box.top + box.height / 2; // 过中线即插到其后,与指针位置一致
over.parentElement?.insertBefore(dragging, after ? over.nextSibling : over);
});
root.addEventListener('dragend', () => {
if (!dragging) return;
dragging.classList.remove('is-dragging');
dragging = null;
syncAria();
commit();
});
// —— 键盘等价:Alt 组合才搬位,裸 ↑↓ 留给页面滚动 ——
root.addEventListener('keydown', (e) => {
if (!e.altKey || disabled()) return;
const row = (e.target as HTMLElement).closest('.ua-sortable-item') as HTMLElement | null;
if (!row) return;
const order = Array.from(root.children) as HTMLElement[];
const at = order.indexOf(row);
const to = e.key === 'Home' ? 0 : e.key === 'End' ? order.length - 1
: e.key === 'ArrowUp' ? at - 1 : e.key === 'ArrowDown' ? at + 1 : at;
if (to === at) return; // 已在顶 / 底:静默忽略
e.preventDefault();
root.insertBefore(row, to < at ? order[to] : order[to].nextSibling); // 向下搬要插到目标之后
row.focus(); // 节点搬动后显式收回焦点,读屏与连续搬位都不断线
syncAria();
commit();
});
// —— 受控回写:外部改顺序信号即重排,拖拽中跳过以免打断手势 ——
effect(() => {
const next = value();
if (!root.children.length || dragging) return; // 首帧尚未挂载 / 正在拖
const now = Array.from(root.children).map((el) => handle(el as HTMLElement));
if (next.join('\u0000') === now.join('\u0000')) return;
place(next);
});
return {
el: root,
/*** 程序化搬位:下标越界或禁用时静默忽略 ***/
move: (from: number, to: number): void => {
const order = Array.from(root.children) as HTMLElement[];
if (disabled() || from === to || !order[from] || to < 0 || to >= order.length) return;
const row = order[from];
root.insertBefore(row, to < from ? order[to] : order[to].nextSibling);
syncAria();
commit();
},
order: (): string[] => Array.from(root.children).map((el) => handle(el as HTMLElement)),
};
}
+16
View File
@@ -1130,3 +1130,19 @@ export interface NoticeBarProps {
closable?: boolean; // 是否显示关闭按钮;缺省 true
onClose?: () => void; // 关闭回调
}
/*** 拖拽排序列表入参 ***/
export interface SortableListProps {
items: string[]; // 全部条目文案,文案即排序键(要求唯一)
value?: Signal<string[]>; // 受控顺序信号;传了则内部改动写回,外部改动即重排
onChange?: (next: string[]) => void; // 顺序变化回调(拖放落定 / 键盘搬位 / move 调用后各一次)
disabled?: boolean | Signal<boolean>; // 禁用拖拽与键盘搬位;缺省 false
emptyText?: string; // 无条目占位文案;缺省「暂无可排序项」
}
/*** 拖拽排序列表句柄 ***/
export interface SortableListHandle {
el: HTMLElement; // 列表根节点
move: (from: number, to: number) => void; // 程序化搬位:下标越界或禁用时静默忽略
order: () => string[]; // 读取当前顺序(以 DOM 为唯一真相)
}
+46 -1
View File
@@ -101,7 +101,8 @@
"sparkline(props):迷你趋势线,把数值序列压成折线(可填充 + 末点标记),无坐标轴无交互",
"heatmap(props):日历热力图,按周列 × 星期行铺格子、四档浓度表示当天数量,悬停看明细",
"progressRing(props):环形进度,SVG 双圆靠 dasharray 画弧 + 中心百分比,value 可传信号自动重绘",
"noticeBar(props):单行通知条,语义色 + 左侧符号 + 可选跑马灯与关闭,适合页顶常驻播报"
"noticeBar(props):单行通知条,语义色 + 左侧符号 + 可选跑马灯与关闭,适合页顶常驻播报",
"sortableList(props):拖拽排序列表,整行可拖 + Alt+↑↓ 键盘搬位,返回 { el, move, order } 句柄"
],
"keyTypes": [
"ButtonProps",
@@ -4521,6 +4522,50 @@
"跑马灯在减少动效偏好下自动停成静态首屏——完整原文始终在 title 里,不必为可访问性另写一份",
"点 × 是先淡出再隐藏(200ms,和 alert 同一档),回调 onClose 立刻触发:想同步清理外部状态就在回调里做,别等隐藏"
]
},
{
"id": "sortableList",
"title": "拖拽排序列表 SortableList",
"demo": "sec-sortable",
"props": [
{
"n": "items",
"t": "string[]",
"r": 1,
"d": "全部条目文案;文案即排序键,要求唯一(重名会让顺序回读撞车)"
},
{
"n": "value",
"t": "Signal<string[]>",
"d": "受控顺序信号;传了则内部改动写回,外部改动即重排(拖拽中跳过)"
},
{
"n": "onChange",
"t": "(next: string[]) => void",
"k": 1,
"d": "顺序变化回调,拖放落定 / 键盘搬位 / move 调用后各触发一次"
},
{
"n": "disabled",
"t": "boolean | Signal<boolean>",
"d": "禁用拖拽与键盘搬位;缺省 false",
"df": "false"
},
{
"n": "emptyText",
"t": "string",
"d": "无条目占位文案;缺省「暂无可排序项」"
}
],
"example": "const sort = sortableList({ items: ['需求评审', '接口联调', '灰度发布'], onChange: (next) => console.log(next) });\ncontainer.append(sort.el);",
"steps": [
"items 传全部条目文案、顺序即当前顺序:文案同时是排序键,所以必须唯一(重名会分不清谁被搬动)",
"鼠标 / 触摸按住任意一行拖动,指针越过邻行中线就换位;松手后写回 value 信号并触发 onChange,拖拽期间不重排避免手势中断",
"键盘等价:Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾(裸 ↑↓ 留给页面滚动)",
"被拖的行以虚线边框 + 淡色虚影标示,落点由指针位置决定,不会出现「拖了三格只动一格」的错觉",
"外部要按代码调整顺序用句柄 move(from, to),下标越界或禁用时静默忽略;order() 以 DOM 为唯一真相回读当前顺序",
"什么时候别用:条目超过一屏(拖到边缘不会自动滚动,改用筛选或上下按钮)、需要跨列表搬运(用两个实例并自己管状态)"
]
}
]
}
+43
View File
@@ -2699,6 +2699,49 @@ button.ua-step-dot:hover {
}
}
/* —— 拖拽排序列表:整行可拖,Alt+↑↓ 键盘搬位 —— */
.ua-sortable {
display: flex;
flex-direction: column;
gap: var(--ua-space-1);
}
.ua-sortable-item {
display: flex;
align-items: center;
gap: var(--ua-space-2);
padding: var(--ua-space-2) var(--ua-space-3);
border: 1px solid var(--ua-color-border);
border-radius: var(--ua-radius-md);
background: var(--ua-color-surface);
color: var(--ua-color-text);
font-size: var(--ua-font-size-md);
cursor: grab;
transition: background var(--ua-duration-fast) var(--ua-ease), border-color var(--ua-duration-fast) var(--ua-ease);
}
.ua-sortable-item:hover {
background: var(--ua-color-tint-accent-weak); /* 低视觉比重的悬停底色 */
}
.ua-sortable-item:active {
background: var(--ua-color-surface-3); /* 拿起时按下加深一档,与悬停区分 */
cursor: grabbing;
}
.ua-sortable-item:focus-visible {
outline: var(--ua-focus-outline);
outline-offset: 2px;
}
.ua-sortable-item.is-dragging {
border-style: dashed;
border-color: var(--ua-color-accent);
background: var(--ua-color-tint-accent);
opacity: 0.7; /* 拖拽中的虚影:本体留在原地随指针走,稍淡以示「正在被搬」 */
}
.ua-sortable-grip {
flex: none;
color: var(--ua-color-text-faint);
font-size: var(--ua-font-size-sm);
line-height: var(--ua-leading-none);
}
/* —— 轮播 —— */
.ua-carousel {
position: relative;