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 为唯一真相)
}