iter(g308): sortableList 拖到边缘自动滚动
This commit is contained in:
@@ -2,6 +2,9 @@ import { effect, h, signal } from '@ui-arch/frame';
|
||||
import { empty } from './empty.js';
|
||||
import type { SortableListHandle, SortableListProps } from './types.js';
|
||||
|
||||
const EDGE_BAND = 48; // 贴边感应带(像素):指针离上下沿这么近才开始自动滚
|
||||
const EDGE_STEP = 12; // 每帧滚动量(像素):约 720px/s,跟得上甩动速度
|
||||
|
||||
/*** 拖拽排序列表:整行可拖(HTML5 拖放)+ Alt+↑↓/Home/End 键盘搬位,顺序变化写回 value 信号并触发 onChange ***/
|
||||
export function sortableList(props: SortableListProps): SortableListHandle {
|
||||
// —— 状态集中声明 ——
|
||||
@@ -9,6 +12,9 @@ export function sortableList(props: SortableListProps): SortableListHandle {
|
||||
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 据此搬节点
|
||||
let scroller: HTMLElement | null = null; // 拖拽期间的滚动宿主:dragstart 时算一次
|
||||
let edgeDir = 0; // 指针贴边方向:-1 向上 / 1 向下 / 0 不滚
|
||||
let raf = 0; // 自动滚动的帧句柄,0 表示没在滚
|
||||
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))])); // 行只建一次,排序靠搬节点
|
||||
@@ -42,6 +48,22 @@ export function sortableList(props: SortableListProps): SortableListHandle {
|
||||
syncAria();
|
||||
|
||||
// —— 鼠标 / 触摸拖放:dragstart 记源行,dragover 就地搬节点,dragend 落定提交 ——
|
||||
/*** 最近的纵向滚动祖先:拖到边缘要能自动滚,否则长列表根本拖不到视口外 ***/
|
||||
const scrollHost = (): HTMLElement | null => {
|
||||
let node = root.parentElement;
|
||||
while (node) {
|
||||
const style = getComputedStyle(node);
|
||||
if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
/*** 贴边滚动一帧:方向由指针位置决定,离开感应带即自停 ***/
|
||||
const tick = (): void => {
|
||||
if (!scroller || !edgeDir) { raf = 0; return; } // 松手或离开感应带:停帧,不再排下一帧
|
||||
scroller.scrollTop += edgeDir * EDGE_STEP;
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
root.addEventListener('dragstart', (e) => {
|
||||
const row = (e.target as HTMLElement).closest('.ua-sortable-item') as HTMLElement | null;
|
||||
if (!row || disabled()) { e.preventDefault(); return; } // 禁用时不许拖起
|
||||
@@ -49,10 +71,16 @@ export function sortableList(props: SortableListProps): SortableListHandle {
|
||||
row.classList.add('is-dragging');
|
||||
e.dataTransfer?.setData('text/plain', handle(row)); // Firefox 不设数据就不派发 dragover
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||
scroller = scrollHost(); // 一次算好:dragover 每帧都算 getComputedStyle 会拖慢手势
|
||||
});
|
||||
root.addEventListener('dragover', (e) => {
|
||||
if (!dragging) return;
|
||||
e.preventDefault(); // 不拦截就拿不到 drop 与后续事件
|
||||
if (scroller) { // 贴边自动滚:编辑器里拖到容器上下沿即持续滚动
|
||||
const box = scroller.getBoundingClientRect();
|
||||
const dir = e.clientY < box.top + EDGE_BAND ? -1 : e.clientY > box.bottom - EDGE_BAND ? 1 : 0;
|
||||
if (dir !== edgeDir) { edgeDir = dir; if (dir && !raf) raf = requestAnimationFrame(tick); }
|
||||
}
|
||||
const over = (e.target as HTMLElement).closest('.ua-sortable-item') as HTMLElement | null;
|
||||
if (!over || over === dragging) return;
|
||||
const box = over.getBoundingClientRect();
|
||||
@@ -60,6 +88,10 @@ export function sortableList(props: SortableListProps): SortableListHandle {
|
||||
over.parentElement?.insertBefore(dragging, after ? over.nextSibling : over);
|
||||
});
|
||||
root.addEventListener('dragend', () => {
|
||||
if (raf) cancelAnimationFrame(raf); // 先停贴边滚动,再收尾
|
||||
raf = 0;
|
||||
edgeDir = 0;
|
||||
scroller = null;
|
||||
if (!dragging) return;
|
||||
dragging.classList.remove('is-dragging');
|
||||
dragging = null;
|
||||
|
||||
@@ -4566,7 +4566,8 @@
|
||||
"键盘等价:Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾(裸 ↑↓ 留给页面滚动)",
|
||||
"被拖的行以虚线边 + 抬升投影标示(不用整行降透明度:那会把正文对比压到 4.86,低于正文 7:1 口径),落点由指针位置决定,不会出现「拖了三格只动一格」的错觉",
|
||||
"外部要按代码调整顺序用句柄 move(from, to),下标越界或禁用时静默忽略;order() 以 DOM 为唯一真相回读当前顺序",
|
||||
"什么时候别用:条目超过一屏(拖到边缘不会自动滚动,改用筛选或上下按钮)、需要跨列表搬运(用两个实例并自己管状态)"
|
||||
"条目超过一屏也能拖:指针进入最近滚动祖先(overflow-y 为 auto/scroll 且内容确已溢出)上下 48px 感应带即按帧自动滚,松手或离开感应带立刻停;找不到滚动祖先(整页跟随滚动)时不接管滚动,交给浏览器",
|
||||
"什么时候别用:需要严格的两级联动排序(树 / 表格分组)时它不够用,得自己在 onChange 里按父节点重排;跨列表搬运用两个实例各自持有顺序信号"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -428,7 +428,7 @@ card({ title: '发布检查单(可拖动,Alt+↑↓ 键盘搬位)' },
|
||||
- `sortableList` 返回 `{ el, move, order }` 句柄(与 `form` / `virtualList` 同类):节点在 `el` 上,业务侧要改顺序用 `move(from, to)`,读当前顺序用 `order()`。
|
||||
- 顺序只存一份:`value` 信号同时喂组件与摘要 / 上报逻辑,否则会出现「界面拖了、提交的仍是老顺序」。
|
||||
- `move()` 与拖拽走同一条提交路径(都会触发 `onChange`),所以「置顶 / 上移」按钮不必另写状态同步;越界下标静默忽略,不用自己判边界。
|
||||
- 拖到边缘不会自动滚动:条目多到一屏放不下时改用筛选或上下移按钮,不要指望拖出视口;跨列表搬运用两个实例各持一个信号。
|
||||
- 拖到最近滚动容器的上下沿会按帧自动滚(感应带 48px),长清单一屏装不下也能拖到位;容器是整页滚动时组件不接管,交给浏览器。跨列表搬运用两个实例各持一个顺序信号。
|
||||
- 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案(`'确认条款 #1'`)。
|
||||
|
||||
## 通用约定
|
||||
|
||||
Reference in New Issue
Block a user