diff --git a/.ua/backlog.json b/.ua/backlog.json index 4f42e1e..68b4da9 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -3095,6 +3095,15 @@ "motivation": "toast.ts 里写了 pointerenter 暂停倒计时,但样式只在 .ua-toast.is-closable / .is-action 时才给 pointer-events: auto,默认 toast 全程穿透——最常见的「toast.success(msg)」根本收不到 hover,暂停逻辑成了死代码;键盘用户 Tab 进关闭按钮时也照样被倒计时收走。", "acceptance": "① .ua-toast 自身接收指针,删掉 is-closable / is-action 两处补丁式 pointer-events(宿主容器仍穿透,只有卡片本体可命中);② 暂停 / 恢复抽成 pause/resume,剩余时长不低于 400ms 且重复进入不二次扣减;③ focusin / focusout 与悬停同口径暂停,焦点在卡片内部移动不恢复;④ manifest 的 closable 说明与手把手步骤同步,npm run check 全绿。", "notes": "pointer-events: auto 落到 .ua-toast 本体,删掉 is-closable / is-action 两处补丁规则;暂停恢复抽成 pause/resume(剩余不低于 400ms、重复进入不二次扣减),focusin/focusout 与悬停同口径、焦点在卡片内部移动不恢复;manifest 的 closable 说明与手把手步骤同步" + }, + { + "id": "g301", + "domain": "new", + "status": "open", + "title": "新增拖拽排序列表 sortableList(88 个组件)", + "motivation": "库里有 list 展示列表、virtualList 长列表,但缺「可调整顺序」的交互件:任务排优先级、字段顺序、看板列这类场景每次都得自己写一遍拖放,dragstart/dragover 的边界(不拦 dragover 就没 drop、拖拽中重建节点会中断手势)反复踩。", + "acceptance": "① 整行可拖,指针越过邻行中线即换位,dragend 落定;② 键盘等价 Alt+↑↓ 上下搬一格、Alt+Home/End 搬首尾,裸 ↑↓ 留给页面滚动;③ 顺序写回 value 信号并触发 onChange,拖拽期间跳过外部重排避免中断手势;④ 句柄 { el, move, order },下标越界与禁用时静默忽略;⑤ 样式只消费 --ua-* 令牌,禁止新增硬编码阴影/时长/字号;⑥ manifest/docs/README/demo/llms-full 同步,npm run check 全绿。", + "notes": "" } ] } diff --git a/.ua/index.json b/.ua/index.json index 8de5895..542940c 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T16:25:36.184Z", + "generatedAt": "2026-10-26T16:28:37.124Z", "repoRoot": ".", "packages": [ { @@ -230,7 +230,8 @@ "sparkline(props):迷你趋势线,把数值序列压成折线(可填充 + 末点标记),无坐标轴无交互", "heatmap(props):日历热力图,按周列 × 星期行铺格子、四档浓度表示当天数量,悬停看明细", "progressRing(props):环形进度,SVG 双圆靠 dasharray 画弧 + 中心百分比,value 可传信号自动重绘", - "noticeBar(props):单行通知条,语义色 + 左侧符号 + 可选跑马灯与关闭,适合页顶常驻播报" + "noticeBar(props):单行通知条,语义色 + 左侧符号 + 可选跑马灯与关闭,适合页顶常驻播报", + "sortableList(props):拖拽排序列表,整行可拖 + Alt+↑↓ 键盘搬位,返回 { el, move, order } 句柄" ], "keyTypes": [ "ButtonProps", @@ -4650,6 +4651,50 @@ "跑马灯在减少动效偏好下自动停成静态首屏——完整原文始终在 title 里,不必为可访问性另写一份", "点 × 是先淡出再隐藏(200ms,和 alert 同一档),回调 onClose 立刻触发:想同步清理外部状态就在回调里做,别等隐藏" ] + }, + { + "id": "sortableList", + "title": "拖拽排序列表 SortableList", + "demo": "sec-sortable", + "props": [ + { + "n": "items", + "t": "string[]", + "r": 1, + "d": "全部条目文案;文案即排序键,要求唯一(重名会让顺序回读撞车)" + }, + { + "n": "value", + "t": "Signal", + "d": "受控顺序信号;传了则内部改动写回,外部改动即重排(拖拽中跳过)" + }, + { + "n": "onChange", + "t": "(next: string[]) => void", + "k": 1, + "d": "顺序变化回调,拖放落定 / 键盘搬位 / move 调用后各触发一次" + }, + { + "n": "disabled", + "t": "boolean | Signal", + "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 为唯一真相回读当前顺序", + "什么时候别用:条目超过一屏(拖到边缘不会自动滚动,改用筛选或上下按钮)、需要跨列表搬运(用两个实例并自己管状态)" + ] } ] }, diff --git a/AGENTS.md b/AGENTS.md index 6e5a287..81bb173 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ - `packages/core/table-core`(@ui-arch/table-core):Headless 表格逻辑内核 - `packages/core/frame`(@ui-arch/frame):signal + hyperscript 微框架 -- `packages/skin/ui`(@ui-arch/ui):87 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 +- `packages/skin/ui`(@ui-arch/ui):88 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 - `packages/design/tokens`(@ui-arch/tokens):设计令牌 - `packages/tools/cli`(@ui-arch/cli):`ua` 命令行 + MCP 注册表服务(private) - `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cf312dd..0891b7c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -39,7 +39,7 @@ ui-arch-mvp/ │ │ ├── table-core/ # 表格状态内核 │ │ └── frame/ # 信号 + hyperscript 微框架 │ ├── skin/ # 皮肤层(DOM 组件,可发布) -│ │ └── ui/ # 87 个组件 + ui.css +│ │ └── ui/ # 88 个组件 + ui.css │ ├── design/ # 资产层(可发布) │ │ └── tokens/ # --ua-* 设计令牌 │ └── tools/ # 工程层(private,不发布) diff --git a/README.md b/README.md index 6ba873a..57ae2fa 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全 npm run changelog # 依据 git 提交史重写 CHANGELOG.md(发版前跑;changelog:check 可查漂移) ``` -## 组件(共 87 个) +## 组件(共 88 个) -accordion · affix · alert · anchor · autoComplete · avatar · avatarGroup · backTop · badge · breadcrumb · button · calendar · carousel · card · cascader · checkbox · checkboxGroup · codeBlock · colorPicker · commandPalette · copyButton · countdown · dataTable · datePicker · descriptions · diffView · divider · drawer · empty · floatButton · form · grid · heatmap · highlight · image · inputNumber · inputTag · jsonViewer · kbd · layout · link · list · loading · menu · modal · mentions · multiSelect · noticeBar · notification · pagination · pinInput · popover · popconfirm · progress · progressRing · radioGroup · rate · relativeTime · result · segmented · select · signaturePad · skeleton · slider · space · sparkline · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · timePicker · toast · tooltip · tour · transfer · tree · treeSelect · typography · upload · virtualList · watermark +accordion · affix · alert · anchor · autoComplete · avatar · avatarGroup · backTop · badge · breadcrumb · button · calendar · carousel · card · cascader · checkbox · checkboxGroup · codeBlock · colorPicker · commandPalette · copyButton · countdown · dataTable · datePicker · descriptions · diffView · divider · drawer · empty · floatButton · form · grid · heatmap · highlight · image · inputNumber · inputTag · jsonViewer · kbd · layout · link · list · loading · menu · modal · mentions · multiSelect · noticeBar · notification · pagination · pinInput · popover · popconfirm · progress · progressRing · radioGroup · rate · relativeTime · result · segmented · select · signaturePad · skeleton · slider · sortableList · space · sparkline · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · timePicker · toast · tooltip · tour · transfer · tree · treeSelect · typography · upload · virtualList · watermark -分组:表单(button / copyButton / signaturePad / textField / inputNumber / inputTag / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / mentions / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / timePicker / pinInput / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / loading / toast / notification / noticeBar / result / spinner / progress / progressRing / skeleton / badge / empty / statistic / countdown)、浮层(tooltip / popover / popconfirm / modal / drawer / menu / commandPalette)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / treeSelect / anchor)、数据展示(dataTable / jsonViewer / diffView / codeBlock / relativeTime / sparkline / heatmap / descriptions / card / list / kbd / highlight / timeline / tag / avatar / avatarGroup / image / carousel / calendar / typography / virtualList)、其他(floatButton / grid / layout / link / space / divider / splitter / backTop / affix / tour / watermark)。 +分组:表单(button / copyButton / signaturePad / textField / inputNumber / inputTag / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / mentions / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / timePicker / pinInput / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / loading / toast / notification / noticeBar / result / spinner / progress / progressRing / skeleton / badge / empty / statistic / countdown)、浮层(tooltip / popover / popconfirm / modal / drawer / menu / commandPalette)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / treeSelect / anchor)、数据展示(dataTable / jsonViewer / diffView / codeBlock / relativeTime / sparkline / heatmap / descriptions / card / list / sortableList / kbd / highlight / timeline / tag / avatar / avatarGroup / image / carousel / calendar / typography / virtualList)、其他(floatButton / grid / layout / link / space / divider / splitter / backTop / affix / tour / watermark)。 ## 可访问性约定 @@ -34,7 +34,7 @@ accordion · affix · alert · anchor · autoComplete · avatar · avatarGroup |---|---| | `packages/core/frame` | @ui-arch/frame:signal / computed / effect + hyperscript(无虚拟 DOM) | | `packages/core/table-core` | @ui-arch/table-core:headless 表格(filter → sort → paginate 派生管线) | -| `packages/skin/ui` | @ui-arch/ui:87 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | +| `packages/skin/ui` | @ui-arch/ui:88 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | | `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) | | `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server | | `apps/demo` | 交互演示(esbuild 打包成单文件) | diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 7e17d0c..b3e6758 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -79,6 +79,7 @@ import { showToast, signaturePad, skeleton, + sortableList, splitter, slider, sparkline, @@ -329,6 +330,10 @@ effect(() => { localStorage.setItem(THEME_KEY, mode); // 记住选择,刷新不闪 }); +// —— 拖拽排序演示:顺序写回信号,下方摘要随之刷新 —— +const sortableOrder = signal(['需求评审', '接口联调', '灰度发布', '线上回归']); +const sortableDemo = sortableList({ items: ['需求评审', '接口联调', '灰度发布', '线上回归'], value: sortableOrder }); + const content = h( 'div', { class: 'page' }, @@ -1914,6 +1919,17 @@ content.append( noticeBar({ text: '连接已断开,正在重试第 3 次…', variant: 'danger', icon: '⚠' }), ), ), + section( + 'sec-sortable', + '拖拽排序', + 'sortableList:整行可拖(越过邻行中线即换位),Alt+↑↓ 上下搬一格、Alt+Home/End 搬首尾;松手写回 value 信号,摘要行跟着刷新。', + h( + 'div', + { class: 'col' }, + sortableDemo.el, + h('p', { class: 'desc' }, () => `当前顺序:${sortableOrder().join(' → ')}`), + ), + ), ); // 悬浮按钮:fixed 定位,挂 body 不参与页面布局 diff --git a/llms-full.txt b/llms-full.txt index 3ed6a19..f378014 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T16:25:36.184Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T16:28:37.124Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -2495,6 +2495,34 @@ noticeBar({ text: '系统将于今晚 23:00 维护 10 分钟', scroll: true }) 现场演示:apps/docs/demo.html#sec-notice +### sortableList — 拖拽排序列表 SortableList + +参数: + +- items(必填):string[] —— 全部条目文案;文案即排序键,要求唯一(重名会让顺序回读撞车) +- value:Signal —— 受控顺序信号;传了则内部改动写回,外部改动即重排(拖拽中跳过) +- onChange(回调):(next: string[]) => void —— 顺序变化回调,拖放落定 / 键盘搬位 / move 调用后各触发一次 +- disabled(默认 false):boolean | Signal —— 禁用拖拽与键盘搬位;缺省 false +- emptyText:string —— 无条目占位文案;缺省「暂无可排序项」 + +示例: + +``` +const sort = sortableList({ items: ['需求评审', '接口联调', '灰度发布'], onChange: (next) => console.log(next) }); +container.append(sort.el); +``` + +手把手: + +1. items 传全部条目文案、顺序即当前顺序:文案同时是排序键,所以必须唯一(重名会分不清谁被搬动) +2. 鼠标 / 触摸按住任意一行拖动,指针越过邻行中线就换位;松手后写回 value 信号并触发 onChange,拖拽期间不重排避免手势中断 +3. 键盘等价:Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾(裸 ↑↓ 留给页面滚动) +4. 被拖的行以虚线边框 + 淡色虚影标示,落点由指针位置决定,不会出现「拖了三格只动一格」的错觉 +5. 外部要按代码调整顺序用句柄 move(from, to),下标越界或禁用时静默忽略;order() 以 DOM 为唯一真相回读当前顺序 +6. 什么时候别用:条目超过一屏(拖到边缘不会自动滚动,改用筛选或上下按钮)、需要跨列表搬运(用两个实例并自己管状态) + +现场演示:apps/docs/demo.html#sec-sortable + API 速览: - button(props):按钮,variant 三档视觉强度,disabled / loading 支持布尔或信号动态同步(loading 转圈并禁点),onClick 点击回调 @@ -2586,6 +2614,7 @@ API 速览: - heatmap(props):日历热力图,按周列 × 星期行铺格子、四档浓度表示当天数量,悬停看明细 - progressRing(props):环形进度,SVG 双圆靠 dasharray 画弧 + 中心百分比,value 可传信号自动重绘 - noticeBar(props):单行通知条,语义色 + 左侧符号 + 可选跑马灯与关闭,适合页顶常驻播报 +- sortableList(props):拖拽排序列表,整行可拖 + Alt+↑↓ 键盘搬位,返回 { el, move, order } 句柄 一行用法: diff --git a/llms.txt b/llms.txt index 3fa5078..d0eeae1 100644 --- a/llms.txt +++ b/llms.txt @@ -136,6 +136,7 @@ - heatmap(props):日历热力图,按周列 × 星期行铺格子、四档浓度表示当天数量,悬停看明细 - progressRing(props):环形进度,SVG 双圆靠 dasharray 画弧 + 中心百分比,value 可传信号自动重绘 - noticeBar(props):单行通知条,语义色 + 左侧符号 + 可选跑马灯与关闭,适合页顶常驻播报 + - sortableList(props):拖拽排序列表,整行可拖 + Alt+↑↓ 键盘搬位,返回 { el, move, order } 句柄 - 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')` - 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号 - 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调 diff --git a/packages/skin/ui/src/index.ts b/packages/skin/ui/src/index.ts index 21aaad8..59d7aff 100644 --- a/packages/skin/ui/src/index.ts +++ b/packages/skin/ui/src/index.ts @@ -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'; diff --git a/packages/skin/ui/src/sortableList.ts b/packages/skin/ui/src/sortableList.ts new file mode 100644 index 0000000..4e3acf0 --- /dev/null +++ b/packages/skin/ui/src/sortableList.ts @@ -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)), + }; +} diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 4226ade..20fef17 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -1130,3 +1130,19 @@ export interface NoticeBarProps { closable?: boolean; // 是否显示关闭按钮;缺省 true onClose?: () => void; // 关闭回调 } + +/*** 拖拽排序列表入参 ***/ +export interface SortableListProps { + items: string[]; // 全部条目文案,文案即排序键(要求唯一) + value?: Signal; // 受控顺序信号;传了则内部改动写回,外部改动即重排 + onChange?: (next: string[]) => void; // 顺序变化回调(拖放落定 / 键盘搬位 / move 调用后各一次) + disabled?: boolean | Signal; // 禁用拖拽与键盘搬位;缺省 false + emptyText?: string; // 无条目占位文案;缺省「暂无可排序项」 +} + +/*** 拖拽排序列表句柄 ***/ +export interface SortableListHandle { + el: HTMLElement; // 列表根节点 + move: (from: number, to: number) => void; // 程序化搬位:下标越界或禁用时静默忽略 + order: () => string[]; // 读取当前顺序(以 DOM 为唯一真相) +} diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 9f795fd..d2c46c9 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -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", + "d": "受控顺序信号;传了则内部改动写回,外部改动即重排(拖拽中跳过)" + }, + { + "n": "onChange", + "t": "(next: string[]) => void", + "k": 1, + "d": "顺序变化回调,拖放落定 / 键盘搬位 / move 调用后各触发一次" + }, + { + "n": "disabled", + "t": "boolean | Signal", + "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 为唯一真相回读当前顺序", + "什么时候别用:条目超过一屏(拖到边缘不会自动滚动,改用筛选或上下按钮)、需要跨列表搬运(用两个实例并自己管状态)" + ] } ] } diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 9beee02..5bb9189 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -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;