From f1916ef0998140cdfe68f25ae4f36a6bd88fdfcd Mon Sep 17 00:00:00 2001 From: HIsheR Date: Tue, 27 Oct 2026 00:37:45 +0800 Subject: [PATCH] =?UTF-8?q?iter(g308):=20sortableList=20=E6=8B=96=E5=88=B0?= =?UTF-8?q?=E8=BE=B9=E7=BC=98=E8=87=AA=E5=8A=A8=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ua/backlog.json | 9 ++++++++ .ua/index.json | 5 +++-- llms-full.txt | 7 +++--- packages/skin/ui/src/sortableList.ts | 32 ++++++++++++++++++++++++++++ packages/skin/ui/ua.manifest.json | 3 ++- packages/tools/cli/recipes.md | 2 +- 6 files changed, 51 insertions(+), 7 deletions(-) diff --git a/.ua/backlog.json b/.ua/backlog.json index 7cbb4c2..c1c4ef7 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -3158,6 +3158,15 @@ "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 全绿。", "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": "" } ] } diff --git a/.ua/index.json b/.ua/index.json index 1bccaf9..bd64d89 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T16:34:08.281Z", + "generatedAt": "2026-10-26T16:37:31.858Z", "repoRoot": ".", "packages": [ { @@ -4695,7 +4695,8 @@ "键盘等价:Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾(裸 ↑↓ 留给页面滚动)", "被拖的行以虚线边 + 抬升投影标示(不用整行降透明度:那会把正文对比压到 4.86,低于正文 7:1 口径),落点由指针位置决定,不会出现「拖了三格只动一格」的错觉", "外部要按代码调整顺序用句柄 move(from, to),下标越界或禁用时静默忽略;order() 以 DOM 为唯一真相回读当前顺序", - "什么时候别用:条目超过一屏(拖到边缘不会自动滚动,改用筛选或上下按钮)、需要跨列表搬运(用两个实例并自己管状态)" + "条目超过一屏也能拖:指针进入最近滚动祖先(overflow-y 为 auto/scroll 且内容确已溢出)上下 48px 感应带即按帧自动滚,松手或离开感应带立刻停;找不到滚动祖先(整页跟随滚动)时不接管滚动,交给浏览器", + "什么时候别用:需要严格的两级联动排序(树 / 表格分组)时它不够用,得自己在 onChange 里按父节点重排;跨列表搬运用两个实例各自持有顺序信号" ] }, { diff --git a/llms-full.txt b/llms-full.txt index a4c3b1e..54f19ce 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @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)。 ## 快速上手 @@ -2520,7 +2520,8 @@ container.append(sort.el); 3. 键盘等价:Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾(裸 ↑↓ 留给页面滚动) 4. 被拖的行以虚线边 + 抬升投影标示(不用整行降透明度:那会把正文对比压到 4.86,低于正文 7:1 口径),落点由指针位置决定,不会出现「拖了三格只动一格」的错觉 5. 外部要按代码调整顺序用句柄 move(from, to),下标越界或禁用时静默忽略;order() 以 DOM 为唯一真相回读当前顺序 -6. 什么时候别用:条目超过一屏(拖到边缘不会自动滚动,改用筛选或上下按钮)、需要跨列表搬运(用两个实例并自己管状态) +6. 条目超过一屏也能拖:指针进入最近滚动祖先(overflow-y 为 auto/scroll 且内容确已溢出)上下 48px 感应带即按帧自动滚,松手或离开感应带立刻停;找不到滚动祖先(整页跟随滚动)时不接管滚动,交给浏览器 +7. 什么时候别用:需要严格的两级联动排序(树 / 表格分组)时它不够用,得自己在 onChange 里按父节点重排;跨列表搬运用两个实例各自持有顺序信号 现场演示:apps/docs/demo.html#sec-sortable @@ -3313,7 +3314,7 @@ card({ title: '发布检查单(可拖动,Alt+↑↓ 键盘搬位)' }, - `sortableList` 返回 `{ el, move, order }` 句柄(与 `form` / `virtualList` 同类):节点在 `el` 上,业务侧要改顺序用 `move(from, to)`,读当前顺序用 `order()`。 - 顺序只存一份:`value` 信号同时喂组件与摘要 / 上报逻辑,否则会出现「界面拖了、提交的仍是老顺序」。 - `move()` 与拖拽走同一条提交路径(都会触发 `onChange`),所以「置顶 / 上移」按钮不必另写状态同步;越界下标静默忽略,不用自己判边界。 -- 拖到边缘不会自动滚动:条目多到一屏放不下时改用筛选或上下移按钮,不要指望拖出视口;跨列表搬运用两个实例各持一个信号。 +- 拖到最近滚动容器的上下沿会按帧自动滚(感应带 48px),长清单一屏装不下也能拖到位;容器是整页滚动时组件不接管,交给浏览器。跨列表搬运用两个实例各持一个顺序信号。 - 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案(`'确认条款 #1'`)。 ## 通用约定 diff --git a/packages/skin/ui/src/sortableList.ts b/packages/skin/ui/src/sortableList.ts index 4e3acf0..7179c49 100644 --- a/packages/skin/ui/src/sortableList.ts +++ b/packages/skin/ui/src/sortableList.ts @@ -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; diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 781ea46..82a869c 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -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 里按父节点重排;跨列表搬运用两个实例各自持有顺序信号" ] }, { diff --git a/packages/tools/cli/recipes.md b/packages/tools/cli/recipes.md index b0305a7..85140bd 100644 --- a/packages/tools/cli/recipes.md +++ b/packages/tools/cli/recipes.md @@ -428,7 +428,7 @@ card({ title: '发布检查单(可拖动,Alt+↑↓ 键盘搬位)' }, - `sortableList` 返回 `{ el, move, order }` 句柄(与 `form` / `virtualList` 同类):节点在 `el` 上,业务侧要改顺序用 `move(from, to)`,读当前顺序用 `order()`。 - 顺序只存一份:`value` 信号同时喂组件与摘要 / 上报逻辑,否则会出现「界面拖了、提交的仍是老顺序」。 - `move()` 与拖拽走同一条提交路径(都会触发 `onChange`),所以「置顶 / 上移」按钮不必另写状态同步;越界下标静默忽略,不用自己判边界。 -- 拖到边缘不会自动滚动:条目多到一屏放不下时改用筛选或上下移按钮,不要指望拖出视口;跨列表搬运用两个实例各持一个信号。 +- 拖到最近滚动容器的上下沿会按帧自动滚(感应带 48px),长清单一屏装不下也能拖到位;容器是整页滚动时组件不接管,交给浏览器。跨列表搬运用两个实例各持一个顺序信号。 - 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案(`'确认条款 #1'`)。 ## 通用约定