iter(g308): sortableList 拖到边缘自动滚动

This commit is contained in:
HIsheR
2026-10-27 00:37:45 +08:00
parent 8c88d6c33a
commit f1916ef099
6 changed files with 51 additions and 7 deletions
+9
View File
@@ -3158,6 +3158,15 @@
"motivation": "g303 给拖拽行加了抬升投影,但行仍是静态定位、没有层级——同一容器里后续兄弟元素绘制在后,被拖那一行的投影直接被下一行的底色切掉,「拿起来」看起来像贴纸被压住。层级令牌只有 dropdown 起的五档(100 起),拿它抬一行会污染语义。", "motivation": "g303 给拖拽行加了抬升投影,但行仍是静态定位、没有层级——同一容器里后续兄弟元素绘制在后,被拖那一行的投影直接被下一行的底色切掉,「拿起来」看起来像贴纸被压住。层级令牌只有 dropdown 起的五档(100 起),拿它抬一行会污染语义。",
"acceptance": "① 新增 --ua-z-raised: 10 令牌(层内抬升,够压过同容器兄弟即可);② .ua-sortable-item.is-dragging 补 position: relative + z-index: var(--ua-z-raised);③ ua tokens --unused 不报新令牌、audit:visual 的 z-literal 仍为 0;④ npm run check 全绿。", "acceptance": "① 新增 --ua-z-raised: 10 令牌(层内抬升,够压过同容器兄弟即可);② .ua-sortable-item.is-dragging 补 position: relative + z-index: var(--ua-z-raised);③ ua tokens --unused 不报新令牌、audit:visual 的 z-literal 仍为 0;④ npm run check 全绿。",
"notes": "tokens.css 新增 --ua-z-raised: 10(层内抬升档,与 dropdown 100 起的那套分层语义区分);.is-dragging 补 position: relative + z-index: var(--ua-z-raised),投影不再被后续行盖住;tokens --unused 0 个、audit:visual 合计仍 0 处,docs 重生成" "notes": "tokens.css 新增 --ua-z-raised: 10(层内抬升档,与 dropdown 100 起的那套分层语义区分);.is-dragging 补 position: relative + z-index: var(--ua-z-raised),投影不再被后续行盖住;tokens --unused 0 个、audit:visual 合计仍 0 处,docs 重生成"
},
{
"id": "g308",
"domain": "tune",
"status": "open",
"title": "sortableList 支持拖到边缘自动滚,摘掉「长列表拖不动」的限制",
"motivation": "文档里写明「拖到边缘不会自动滚动,条目超过一屏就别用」,这等于把最常见的场景(几十条检查单)挡在门外:拖到容器下沿不动,用户只能反复滚动重来。",
"acceptance": "① dragstart 里一次算好最近的纵向滚动祖先(overflow-y auto/scroll 且确已溢出),dragover 每帧只在贴边 48px 感应带内按帧滚 12px;② 离开感应带或 dragend 立即取消帧并清状态,不留后台 rAF;③ 找不到滚动祖先(整页滚动)时不接管;④ manifest 步骤与配方 19 要点同步改写,npm run check 全绿。",
"notes": ""
} }
] ]
} }
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "ui-arch-registry-index/0.1", "$schema": "ui-arch-registry-index/0.1",
"generatedAt": "2026-10-26T16:34:08.281Z", "generatedAt": "2026-10-26T16:37:31.858Z",
"repoRoot": ".", "repoRoot": ".",
"packages": [ "packages": [
{ {
@@ -4695,7 +4695,8 @@
"键盘等价:Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾(裸 ↑↓ 留给页面滚动)", "键盘等价:Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾(裸 ↑↓ 留给页面滚动)",
"被拖的行以虚线边 + 抬升投影标示(不用整行降透明度:那会把正文对比压到 4.86,低于正文 7:1 口径),落点由指针位置决定,不会出现「拖了三格只动一格」的错觉", "被拖的行以虚线边 + 抬升投影标示(不用整行降透明度:那会把正文对比压到 4.86,低于正文 7:1 口径),落点由指针位置决定,不会出现「拖了三格只动一格」的错觉",
"外部要按代码调整顺序用句柄 move(from, to),下标越界或禁用时静默忽略;order() 以 DOM 为唯一真相回读当前顺序", "外部要按代码调整顺序用句柄 move(from, to),下标越界或禁用时静默忽略;order() 以 DOM 为唯一真相回读当前顺序",
"什么时候别用:条目超过一屏(拖到边缘不会自动滚动,改用筛选或上下按钮)、需要跨列表搬运(用两个实例并自己管状态)" "条目超过一屏也能拖:指针进入最近滚动祖先(overflow-y 为 auto/scroll 且内容确已溢出)上下 48px 感应带即按帧自动滚,松手或离开感应带立刻停;找不到滚动祖先(整页跟随滚动)时不接管滚动,交给浏览器",
"什么时候别用:需要严格的两级联动排序(树 / 表格分组)时它不够用,得自己在 onChange 里按父节点重排;跨列表搬运用两个实例各自持有顺序信号"
] ]
}, },
{ {
+4 -3
View File
@@ -1,6 +1,6 @@
# @ui-arch 组件库 Agent 文档(无图形版) # @ui-arch 组件库 Agent 文档(无图形版)
> 自动生成于 2026-10-26T16:34:08.281Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 自动生成于 2026-10-26T16:37:31.858Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
## 快速上手 ## 快速上手
@@ -2520,7 +2520,8 @@ container.append(sort.el);
3. 键盘等价:Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾(裸 ↑↓ 留给页面滚动) 3. 键盘等价:Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾(裸 ↑↓ 留给页面滚动)
4. 被拖的行以虚线边 + 抬升投影标示(不用整行降透明度:那会把正文对比压到 4.86,低于正文 7:1 口径),落点由指针位置决定,不会出现「拖了三格只动一格」的错觉 4. 被拖的行以虚线边 + 抬升投影标示(不用整行降透明度:那会把正文对比压到 4.86,低于正文 7:1 口径),落点由指针位置决定,不会出现「拖了三格只动一格」的错觉
5. 外部要按代码调整顺序用句柄 move(from, to),下标越界或禁用时静默忽略;order() 以 DOM 为唯一真相回读当前顺序 5. 外部要按代码调整顺序用句柄 move(from, to),下标越界或禁用时静默忽略;order() 以 DOM 为唯一真相回读当前顺序
6. 什么时候别用:条目超过一屏(拖到边缘不会自动滚动,改用筛选或上下按钮)、需要跨列表搬运(用两个实例并自己管状态) 6. 条目超过一屏也能拖:指针进入最近滚动祖先(overflow-y 为 auto/scroll 且内容确已溢出)上下 48px 感应带即按帧自动滚,松手或离开感应带立刻停;找不到滚动祖先(整页跟随滚动)时不接管滚动,交给浏览器
7. 什么时候别用:需要严格的两级联动排序(树 / 表格分组)时它不够用,得自己在 onChange 里按父节点重排;跨列表搬运用两个实例各自持有顺序信号
现场演示:apps/docs/demo.html#sec-sortable 现场演示:apps/docs/demo.html#sec-sortable
@@ -3313,7 +3314,7 @@ card({ title: '发布检查单(可拖动,Alt+↑↓ 键盘搬位)' },
- `sortableList` 返回 `{ el, move, order }` 句柄(与 `form` / `virtualList` 同类):节点在 `el` 上,业务侧要改顺序用 `move(from, to)`,读当前顺序用 `order()`。 - `sortableList` 返回 `{ el, move, order }` 句柄(与 `form` / `virtualList` 同类):节点在 `el` 上,业务侧要改顺序用 `move(from, to)`,读当前顺序用 `order()`。
- 顺序只存一份:`value` 信号同时喂组件与摘要 / 上报逻辑,否则会出现「界面拖了、提交的仍是老顺序」。 - 顺序只存一份:`value` 信号同时喂组件与摘要 / 上报逻辑,否则会出现「界面拖了、提交的仍是老顺序」。
- `move()` 与拖拽走同一条提交路径(都会触发 `onChange`),所以「置顶 / 上移」按钮不必另写状态同步;越界下标静默忽略,不用自己判边界。 - `move()` 与拖拽走同一条提交路径(都会触发 `onChange`),所以「置顶 / 上移」按钮不必另写状态同步;越界下标静默忽略,不用自己判边界。
- 拖到边缘不会自动滚动:条目多到一屏放不下时改用筛选或上下移按钮,不要指望拖出视口;跨列表搬运用两个实例各持一个信号。 - 拖到最近滚动容器的上下沿会按帧自动滚(感应带 48px),长清单一屏装不下也能拖到位;容器是整页滚动时组件不接管,交给浏览器。跨列表搬运用两个实例各持一个顺序信号。
- 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案(`'确认条款 #1'`)。 - 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案(`'确认条款 #1'`)。
## 通用约定 ## 通用约定
+32
View File
@@ -2,6 +2,9 @@ import { effect, h, signal } from '@ui-arch/frame';
import { empty } from './empty.js'; import { empty } from './empty.js';
import type { SortableListHandle, SortableListProps } from './types.js'; import type { SortableListHandle, SortableListProps } from './types.js';
const EDGE_BAND = 48; // 贴边感应带(像素):指针离上下沿这么近才开始自动滚
const EDGE_STEP = 12; // 每帧滚动量(像素):约 720px/s,跟得上甩动速度
/*** 拖拽排序列表:整行可拖(HTML5 拖放)+ Alt+↑↓/Home/End 键盘搬位,顺序变化写回 value 信号并触发 onChange ***/ /*** 拖拽排序列表:整行可拖(HTML5 拖放)+ Alt+↑↓/Home/End 键盘搬位,顺序变化写回 value 信号并触发 onChange ***/
export function sortableList(props: SortableListProps): SortableListHandle { 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 disabled = (): boolean => (typeof props.disabled === 'function' ? props.disabled() : props.disabled === true); // 禁用读法:布尔直读、信号保持响应性
const handle = (el: HTMLElement): string => el.dataset.label ?? ''; // 行 → 条目文案(文案即排序键) const handle = (el: HTMLElement): string => el.dataset.label ?? ''; // 行 → 条目文案(文案即排序键)
let dragging: HTMLElement | null = null; // 正在被拖的行:dragover 据此搬节点 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', { const rows = new Map(props.items.map((text) => [text, h('div', {
class: 'ua-sortable-item', role: 'listitem', tabindex: '0', draggable: 'true', 'data-label': text, 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))])); // 行只建一次,排序靠搬节点 }, 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(); syncAria();
// —— 鼠标 / 触摸拖放:dragstart 记源行,dragover 就地搬节点,dragend 落定提交 —— // —— 鼠标 / 触摸拖放: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) => { root.addEventListener('dragstart', (e) => {
const row = (e.target as HTMLElement).closest('.ua-sortable-item') as HTMLElement | null; const row = (e.target as HTMLElement).closest('.ua-sortable-item') as HTMLElement | null;
if (!row || disabled()) { e.preventDefault(); return; } // 禁用时不许拖起 if (!row || disabled()) { e.preventDefault(); return; } // 禁用时不许拖起
@@ -49,10 +71,16 @@ export function sortableList(props: SortableListProps): SortableListHandle {
row.classList.add('is-dragging'); row.classList.add('is-dragging');
e.dataTransfer?.setData('text/plain', handle(row)); // Firefox 不设数据就不派发 dragover e.dataTransfer?.setData('text/plain', handle(row)); // Firefox 不设数据就不派发 dragover
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
scroller = scrollHost(); // 一次算好:dragover 每帧都算 getComputedStyle 会拖慢手势
}); });
root.addEventListener('dragover', (e) => { root.addEventListener('dragover', (e) => {
if (!dragging) return; if (!dragging) return;
e.preventDefault(); // 不拦截就拿不到 drop 与后续事件 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; const over = (e.target as HTMLElement).closest('.ua-sortable-item') as HTMLElement | null;
if (!over || over === dragging) return; if (!over || over === dragging) return;
const box = over.getBoundingClientRect(); const box = over.getBoundingClientRect();
@@ -60,6 +88,10 @@ export function sortableList(props: SortableListProps): SortableListHandle {
over.parentElement?.insertBefore(dragging, after ? over.nextSibling : over); over.parentElement?.insertBefore(dragging, after ? over.nextSibling : over);
}); });
root.addEventListener('dragend', () => { root.addEventListener('dragend', () => {
if (raf) cancelAnimationFrame(raf); // 先停贴边滚动,再收尾
raf = 0;
edgeDir = 0;
scroller = null;
if (!dragging) return; if (!dragging) return;
dragging.classList.remove('is-dragging'); dragging.classList.remove('is-dragging');
dragging = null; dragging = null;
+2 -1
View File
@@ -4566,7 +4566,8 @@
"键盘等价:Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾(裸 ↑↓ 留给页面滚动)", "键盘等价:Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾(裸 ↑↓ 留给页面滚动)",
"被拖的行以虚线边 + 抬升投影标示(不用整行降透明度:那会把正文对比压到 4.86,低于正文 7:1 口径),落点由指针位置决定,不会出现「拖了三格只动一格」的错觉", "被拖的行以虚线边 + 抬升投影标示(不用整行降透明度:那会把正文对比压到 4.86,低于正文 7:1 口径),落点由指针位置决定,不会出现「拖了三格只动一格」的错觉",
"外部要按代码调整顺序用句柄 move(from, to),下标越界或禁用时静默忽略;order() 以 DOM 为唯一真相回读当前顺序", "外部要按代码调整顺序用句柄 move(from, to),下标越界或禁用时静默忽略;order() 以 DOM 为唯一真相回读当前顺序",
"什么时候别用:条目超过一屏(拖到边缘不会自动滚动,改用筛选或上下按钮)、需要跨列表搬运(用两个实例并自己管状态)" "条目超过一屏也能拖:指针进入最近滚动祖先(overflow-y 为 auto/scroll 且内容确已溢出)上下 48px 感应带即按帧自动滚,松手或离开感应带立刻停;找不到滚动祖先(整页跟随滚动)时不接管滚动,交给浏览器",
"什么时候别用:需要严格的两级联动排序(树 / 表格分组)时它不够用,得自己在 onChange 里按父节点重排;跨列表搬运用两个实例各自持有顺序信号"
] ]
}, },
{ {
+1 -1
View File
@@ -428,7 +428,7 @@ card({ title: '发布检查单(可拖动,Alt+↑↓ 键盘搬位)' },
- `sortableList` 返回 `{ el, move, order }` 句柄(与 `form` / `virtualList` 同类):节点在 `el` 上,业务侧要改顺序用 `move(from, to)`,读当前顺序用 `order()`。 - `sortableList` 返回 `{ el, move, order }` 句柄(与 `form` / `virtualList` 同类):节点在 `el` 上,业务侧要改顺序用 `move(from, to)`,读当前顺序用 `order()`。
- 顺序只存一份:`value` 信号同时喂组件与摘要 / 上报逻辑,否则会出现「界面拖了、提交的仍是老顺序」。 - 顺序只存一份:`value` 信号同时喂组件与摘要 / 上报逻辑,否则会出现「界面拖了、提交的仍是老顺序」。
- `move()` 与拖拽走同一条提交路径(都会触发 `onChange`),所以「置顶 / 上移」按钮不必另写状态同步;越界下标静默忽略,不用自己判边界。 - `move()` 与拖拽走同一条提交路径(都会触发 `onChange`),所以「置顶 / 上移」按钮不必另写状态同步;越界下标静默忽略,不用自己判边界。
- 拖到边缘不会自动滚动:条目多到一屏放不下时改用筛选或上下移按钮,不要指望拖出视口;跨列表搬运用两个实例各持一个信号。 - 拖到最近滚动容器的上下沿会按帧自动滚(感应带 48px),长清单一屏装不下也能拖到位;容器是整页滚动时组件不接管,交给浏览器。跨列表搬运用两个实例各持一个顺序信号。
- 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案(`'确认条款 #1'`)。 - 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案(`'确认条款 #1'`)。
## 通用约定 ## 通用约定