diff --git a/.ua/index.json b/.ua/index.json index 6631521..471a40e 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T03:53:26.496Z", + "generatedAt": "2026-10-26T04:07:52.893Z", "repoRoot": ".", "packages": [ { @@ -187,7 +187,8 @@ "tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应", "upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求)", "notification(props):右下角通知卡片(标题 + 描述 + 操作区),返回 { el, close } 句柄,缺省常驻需手动关闭", - "result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态" + "result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态", + "transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选" ], "keyTypes": [ "ButtonProps", @@ -2054,6 +2055,52 @@ "extra 放操作区,常见为主按钮 + 幽灵按钮;不传则不渲染", "组件自带 role=\"status\",插入页面后读屏会播报终态文案" ] + }, + { + "id": "transfer", + "title": "穿梭框 Transfer", + "demo": "sec-transfer", + "props": [ + { + "n": "dataSource", + "t": "TransferItem[]", + "r": 1, + "d": "全量条目,左右两栏都按是否在 targetKeys 里派生" + }, + { + "n": "targetKeys", + "t": "Signal", + "d": "右侧集合信号;不传则组件内部维护,传了即外部受控" + }, + { + "n": "titles", + "t": "[string, string]", + "d": "左右栏标题;缺省「待选列表 / 已选列表」" + }, + { + "n": "searchable", + "t": "boolean", + "d": "是否显示搜索框;缺省 true,逐栏独立过滤" + }, + { + "n": "height", + "t": "number", + "d": "列表区固定高度像素;缺省走样式表 220,超出滚动" + }, + { + "n": "onChange", + "t": "(keys: string[]) => void", + "d": "右侧集合变化回调(搬运后触发,传副本)" + } + ], + "example": "transfer({ targetKeys: enabled, titles: ['待选', '已启用'], onChange: (keys) => toast.info(`已启用 ${keys.length} 项`), dataSource: [{ key: 'a11y', label: '无障碍语义' }, { key: 'ssr', label: 'SSR(暂不可用)', disabled: true }] })", + "steps": [ + "dataSource 是唯一数据源,条目归属由 targetKeys 决定;搬运只改 targetKeys,不删条目", + "两栏独立勾选,勾选后点中间 → / ← 整体搬运,搬完两栏勾选自动清空;无勾选时按钮自动禁用", + "每栏均有搜索框与全选:全选只覆盖当前搜索结果里未禁用的条目,部分勾选时全选框显示半选", + "disabled 条目不可勾选、不计入全选基数、也不会被搬运", + "传入 targetKeys 信号即为受控用法,外部写入即可程序化搬运(如「一键全选」)" + ] } ] }, diff --git a/AGENTS.md b/AGENTS.md index f95ed6d..944964f 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):44 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 +- `packages/skin/ui`(@ui-arch/ui):45 个 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 38eb862..0dabd38 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,7 +34,7 @@ ui-arch-mvp/ │ │ ├── table-core/ # 表格状态内核 │ │ └── frame/ # 信号 + hyperscript 微框架 │ ├── skin/ # 皮肤层(DOM 组件,可发布) -│ │ └── ui/ # 44 个组件 + ui.css +│ │ └── ui/ # 45 个组件 + ui.css │ ├── design/ # 资产层(可发布) │ │ └── tokens/ # --ua-* 设计令牌 │ └── tools/ # 工程层(private,不发布) diff --git a/README.md b/README.md index 0e78c06..6bb4f55 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ npm run demo # 打开演示:apps/demo/dist/demo.html npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全量文档 llms-full.txt ``` -## 组件(共 44 个) +## 组件(共 45 个) -accordion · alert · avatar · badge · breadcrumb · button · carousel · card · checkbox · dataTable · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · notification · pagination · popover · progress · radioGroup · rate · result · segmented · select · skeleton · slider · spinner · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · tree · upload +accordion · alert · avatar · badge · breadcrumb · button · carousel · card · checkbox · dataTable · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · notification · pagination · popover · progress · radioGroup · rate · result · segmented · select · skeleton · slider · spinner · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · transfer · tree · upload -分组:表单(button / textField / inputNumber / textarea / checkbox / switchButton / select / radioGroup / slider / rate / segmented / upload / form + formItem)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel)、其他(divider)。 +分组:表单(button / textField / inputNumber / textarea / checkbox / switchButton / select / radioGroup / slider / rate / segmented / upload / form + formItem / transfer)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel)、其他(divider)。 ## 可访问性约定 @@ -31,7 +31,7 @@ accordion · alert · avatar · badge · breadcrumb · button · carousel · car |---|---| | `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:44 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | +| `packages/skin/ui` | @ui-arch/ui:45 个 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 a3b3088..aeccf89 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -17,6 +17,7 @@ import { card, checkbox, dataTable, + transfer, descriptions, divider, drawer, @@ -181,6 +182,7 @@ const menuPick = signal(''); const listPick = signal(''); // 列表点击选中的条目标题 const slideIndex = signal(0); // 轮播当前下标(外部受控演示) const treePick = signal('core'); // 树形选中节点 key +const transferKeys = signal(['a11y']); // 穿梭框右侧集合(外部受控演示) const cityPick = signal(''); // 可搜索下拉的选中城市 const pagerPage = signal(1); // 独立分页器当前页 const pickedFiles = signal(''); // 文件选择结果(名称串) @@ -675,6 +677,30 @@ const content = h( button({ label: '错误(可关)', variant: 'danger', onClick: () => notification({ title: '保存失败', description: '服务端返回 500,请稍后重试', variant: 'danger' }) }), ), ), + section( + 'sec-transfer', + '穿梭框', + 'transfer:左右两栏由同一份 dataSource 按归属派生,勾选后点箭头整体搬运;两栏各自搜索、全选支持半选,禁用条目既不参与全选也不可搬运。', + h( + 'div', + { class: 'col' }, + transfer({ + targetKeys: transferKeys, + titles: ['待选能力', '已启用能力'], + height: 180, + onChange: (keys) => toast.info(`已启用 ${keys.length} 项`), + dataSource: [ + { key: 'a11y', label: '无障碍语义(aria)' }, + { key: 'theme', label: '暗色主题令牌' }, + { key: 'motion', label: '微交互动效' }, + { key: 'virtual', label: '虚拟滚动' }, + { key: 'ssr', label: 'SSR 水合(暂不可用)', disabled: true }, + { key: 'i18n', label: '多语言文案' }, + ], + }), + h('span', { class: 'out' }, () => `已启用 ${transferKeys().length} 项:${transferKeys().join(' / ') || '无'}`), + ), + ), section( 'sec-result', '结果页', diff --git a/llms-full.txt b/llms-full.txt index 97a15f0..8e52b3c 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T03:53:26.496Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T04:07:52.893Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -1174,6 +1174,33 @@ result({ status: 'success', title: '提交成功', description: '工单 #2049 现场演示:apps/docs/demo.html#sec-result +### transfer — 穿梭框 Transfer + +参数: + +- dataSource(必填):TransferItem[] —— 全量条目,左右两栏都按是否在 targetKeys 里派生 +- targetKeys:Signal —— 右侧集合信号;不传则组件内部维护,传了即外部受控 +- titles:[string, string] —— 左右栏标题;缺省「待选列表 / 已选列表」 +- searchable:boolean —— 是否显示搜索框;缺省 true,逐栏独立过滤 +- height:number —— 列表区固定高度像素;缺省走样式表 220,超出滚动 +- onChange:(keys: string[]) => void —— 右侧集合变化回调(搬运后触发,传副本) + +示例: + +``` +transfer({ targetKeys: enabled, titles: ['待选', '已启用'], onChange: (keys) => toast.info(`已启用 ${keys.length} 项`), dataSource: [{ key: 'a11y', label: '无障碍语义' }, { key: 'ssr', label: 'SSR(暂不可用)', disabled: true }] }) +``` + +手把手: + +1. dataSource 是唯一数据源,条目归属由 targetKeys 决定;搬运只改 targetKeys,不删条目 +2. 两栏独立勾选,勾选后点中间 → / ← 整体搬运,搬完两栏勾选自动清空;无勾选时按钮自动禁用 +3. 每栏均有搜索框与全选:全选只覆盖当前搜索结果里未禁用的条目,部分勾选时全选框显示半选 +4. disabled 条目不可勾选、不计入全选基数、也不会被搬运 +5. 传入 targetKeys 信号即为受控用法,外部写入即可程序化搬运(如「一键全选」) + +现场演示:apps/docs/demo.html#sec-transfer + API 速览: - button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用;type 缺省 button(表单内不隐式提交) @@ -1222,6 +1249,7 @@ API 速览: - upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求) - notification(props):右下角通知卡片(标题 + 描述 + 操作区),返回 { el, close } 句柄,缺省常驻需手动关闭 - result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态 +- transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选 一行用法: diff --git a/llms.txt b/llms.txt index 3ba4568..59416fa 100644 --- a/llms.txt +++ b/llms.txt @@ -93,6 +93,7 @@ - upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求) - notification(props):右下角通知卡片(标题 + 描述 + 操作区),返回 { el, close } 句柄,缺省常驻需手动关闭 - result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态 + - transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选 - 用法: `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 c0744bb..bfb1bb5 100644 --- a/packages/skin/ui/src/index.ts +++ b/packages/skin/ui/src/index.ts @@ -44,3 +44,4 @@ export { modal } from './modal.js'; export { drawer } from './drawer.js'; export { form, formItem } from './form.js'; export { dataTable } from './dataTable.js'; +export { transfer } from './transfer.js'; diff --git a/packages/skin/ui/src/transfer.ts b/packages/skin/ui/src/transfer.ts new file mode 100644 index 0000000..840511e --- /dev/null +++ b/packages/skin/ui/src/transfer.ts @@ -0,0 +1,113 @@ +import { effect, h, signal } from '@ui-arch/frame'; +import type { Signal } from '@ui-arch/frame'; +import { button } from './button.js'; +import type { TransferItem, TransferProps } from './types.js'; + +/*** 单栏句柄:容器 / 列表 / 计数 / 全选框,供同步函数统一刷新 ***/ +interface Pane { + root: HTMLElement; // 栏容器 + list: HTMLElement; // 条目滚动区 + foot: HTMLElement; // 底部计数 + all: HTMLInputElement; // 全选框(支持半选三态) +} + +/*** 穿梭框:左右两栏勾选后整体搬运,targetKeys 信号双向绑定,搜索词逐栏过滤、行内不重建保住焦点 ***/ +export function transfer(props: TransferProps): HTMLElement { + // —— 状态集中声明 —— + const sides: ['left', 'right'] = ['left', 'right']; // 固定两栏,避免各处手写左右字符串 + const target: Signal = props.targetKeys ?? signal([]); // 右侧集合:外部受控优先 + const picks = { left: signal([]), right: signal([]) } as Record<'left' | 'right', Signal>; // 两栏勾选 key + const queries = { left: signal(''), right: signal('') } as Record<'left' | 'right', Signal>; // 两栏搜索词 + const boxes = { left: new Map(), right: new Map() } as Record<'left' | 'right', Map>; // key → 勾选框,回填用 + const sigs = { left: '', right: '' } as Record<'left' | 'right', string>; // 上次渲染的条目签名,变化才重建行 + const panes = {} as Record<'left' | 'right', Pane>; // 两栏句柄,建栏后填充 + const titles = props.titles ?? ['待选列表', '已选列表']; // 栏标题 + const searchable = props.searchable !== false; // 缺省开启搜索 + + /*** 归属判定:key 在右侧集合里即算已选 ***/ + const isTarget = (key: string): boolean => target().includes(key); + + /*** 按归属 + 搜索词筛出本栏可见条目,顺序沿用 dataSource ***/ + const visible = (sd: 'left' | 'right'): TransferItem[] => { + const q = queries[sd]().trim().toLowerCase(); // 大小写不敏感的包含匹配 + return props.dataSource.filter((it) => (sd === 'right' ? isTarget(it.key) : !isTarget(it.key)) && (q ? it.label.toLowerCase().includes(q) : true)); + }; + + /*** 搬移:把源栏勾选整体送入目标栏,搬完清空两栏勾选并回传 ***/ + const move = (dir: 'right' | 'left'): void => { + const from = dir === 'right' ? picks.left() : picks.right(); // 源栏勾选 + if (!from.length) return; // 卫语句:没勾选不动作 + const set = new Set(target()); + for (const key of from) dir === 'right' ? set.add(key) : set.delete(key); + target([...set]); + picks.left([]); // 搬运后两栏勾选都清空,避免误操作 + picks.right([]); + props.onChange?.([...set]); + }; + + /*** 重建某栏条目行:仅在归属或搜索词变化时调用,勾选态变化不重建以保住焦点 ***/ + const renderRows = (sd: 'left' | 'right', items: TransferItem[]): void => { + const map = boxes[sd]; + map.clear(); + const rows = items.map((it) => { + const box = h('input', { type: 'checkbox', class: 'ua-transfer-box' }) as HTMLInputElement; + box.disabled = it.disabled === true; // 禁用条目:原生勾选框即不可点 + box.addEventListener('change', () => { + const set = new Set(picks[sd]()); + box.checked ? set.add(it.key) : set.delete(it.key); // 用户操作写回信号 + picks[sd]([...set]); + }); + map.set(it.key, box); + const row = h('li', { class: 'ua-transfer-item' }, h('label', { class: 'ua-transfer-label' }, box, h('span', {}, it.label))); + if (it.disabled) row.classList.add('is-disabled'); + return row; + }); + panes[sd].list.replaceChildren(...rows); + if (!rows.length) panes[sd].list.append(h('li', { class: 'ua-transfer-empty' }, '无匹配条目')); // 空态兜底 + }; + + /*** 造一栏:标题 + 全选 + 搜索 + 列表 + 计数 ***/ + const makePane = (sd: 'left' | 'right'): Pane => { + const all = h('input', { type: 'checkbox', class: 'ua-transfer-all', 'aria-label': `全选${titles[sd === 'left' ? 0 : 1]}` }) as HTMLInputElement; + all.addEventListener('change', () => { + const keys = visible(sd).filter((it) => !it.disabled).map((it) => it.key); // 禁用条目不参与全选 + const set = new Set(picks[sd]()); + keys.forEach((k) => (all.checked ? set.add(k) : set.delete(k))); + picks[sd]([...set]); + }); + const search = searchable ? (h('input', { type: 'search', class: 'ua-transfer-search', placeholder: '搜索条目', 'aria-label': `搜索${titles[sd === 'left' ? 0 : 1]}` }) as HTMLInputElement) : null; + search?.addEventListener('input', () => queries[sd](search.value)); + const list = h('ul', { class: 'ua-transfer-list', role: 'group', 'aria-label': titles[sd === 'left' ? 0 : 1] }) as HTMLElement; + if (props.height) list.style.height = `${props.height}px`; // 固定高度,超出滚动 + const foot = h('div', { class: 'ua-transfer-foot' }); + const root = h('div', { class: 'ua-transfer-pane' }, h('div', { class: 'ua-transfer-head' }, h('span', { class: 'ua-transfer-title' }, titles[sd === 'left' ? 0 : 1]), all), search, list, foot) as HTMLElement; + return { root, list, foot, all }; + }; + + /*** 全量刷新:按需重建行 + 回填勾选态 + 全选三态 + 计数 ***/ + const sync = (): void => { + for (const sd of sides) { + const pane = panes[sd]; + if (!pane) continue; // 卫语句:首绘时另一栏还没建好 + const items = visible(sd); + const sig = items.map((it) => it.key).join('|'); + if (sig !== sigs[sd]) { sigs[sd] = sig; renderRows(sd, items); } // 条目集合变了才重建 + const picked = new Set(picks[sd]()); + for (const [key, box] of boxes[sd]) box.checked = picked.has(key); + const usable = items.filter((it) => !it.disabled).length; // 可勾选条数(全选基数) + pane.all.checked = usable > 0 && picked.size >= usable; + pane.all.indeterminate = picked.size > 0 && picked.size < usable; // 半选:部分勾选 + pane.foot.textContent = `已勾选 ${picked.size} / 共 ${items.length} 条`; + } + }; + + panes.left = makePane('left'); + panes.right = makePane('right'); + // 中间操作列:两个方向按钮,无勾选时禁用(信号驱动,零手动同步) + const toRight = button({ label: '→', variant: 'ghost', disabled: () => !picks.left().length, onClick: () => move('right') }); + const toLeft = button({ label: '←', variant: 'ghost', disabled: () => !picks.right().length, onClick: () => move('left') }); + const ops = h('div', { class: 'ua-transfer-ops' }, h('span', { class: 'ua-transfer-ops-hint' }, '移动'), toRight, toLeft); + + effect(sync); + return h('div', { class: 'ua-transfer' }, panes.left.root, ops, panes.right.root) as HTMLElement; +} diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 8452fcc..805c18b 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -478,4 +478,21 @@ export interface CarouselProps { onChange?: (index: number) => void; // 切换回调(点击/箭头/键盘/自动都触发) } +/*** 穿梭框条目 ***/ +export interface TransferItem { + key: string; // 唯一键:左右搬运按 key 记,不记下标 + label: string; // 显示文案 + disabled?: boolean; // 禁用:不可勾选、不参与全选、不参与搬运 +} + +/*** 穿梭框入参 ***/ +export interface TransferProps { + dataSource: TransferItem[]; // 全量条目:左右两栏都由它按归属派生 + targetKeys?: Signal; // 右侧集合信号;不传则组件内部维护 + titles?: [string, string]; // 左右栏标题;缺省「待选列表 / 已选列表」 + searchable?: boolean; // 是否显示搜索框;缺省 true + height?: number; // 列表区固定高度像素;缺省走样式表 220 + onChange?: (keys: string[]) => void; // 右侧集合变化回调(搬运后触发,传副本) +} + export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方 diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 6d51b6c..4ee1f69 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -58,7 +58,8 @@ "tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应", "upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求)", "notification(props):右下角通知卡片(标题 + 描述 + 操作区),返回 { el, close } 句柄,缺省常驻需手动关闭", - "result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态" + "result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态", + "transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选" ], "keyTypes": [ "ButtonProps", @@ -1925,6 +1926,52 @@ "extra 放操作区,常见为主按钮 + 幽灵按钮;不传则不渲染", "组件自带 role=\"status\",插入页面后读屏会播报终态文案" ] + }, + { + "id": "transfer", + "title": "穿梭框 Transfer", + "demo": "sec-transfer", + "props": [ + { + "n": "dataSource", + "t": "TransferItem[]", + "r": 1, + "d": "全量条目,左右两栏都按是否在 targetKeys 里派生" + }, + { + "n": "targetKeys", + "t": "Signal", + "d": "右侧集合信号;不传则组件内部维护,传了即外部受控" + }, + { + "n": "titles", + "t": "[string, string]", + "d": "左右栏标题;缺省「待选列表 / 已选列表」" + }, + { + "n": "searchable", + "t": "boolean", + "d": "是否显示搜索框;缺省 true,逐栏独立过滤" + }, + { + "n": "height", + "t": "number", + "d": "列表区固定高度像素;缺省走样式表 220,超出滚动" + }, + { + "n": "onChange", + "t": "(keys: string[]) => void", + "d": "右侧集合变化回调(搬运后触发,传副本)" + } + ], + "example": "transfer({ targetKeys: enabled, titles: ['待选', '已启用'], onChange: (keys) => toast.info(`已启用 ${keys.length} 项`), dataSource: [{ key: 'a11y', label: '无障碍语义' }, { key: 'ssr', label: 'SSR(暂不可用)', disabled: true }] })", + "steps": [ + "dataSource 是唯一数据源,条目归属由 targetKeys 决定;搬运只改 targetKeys,不删条目", + "两栏独立勾选,勾选后点中间 → / ← 整体搬运,搬完两栏勾选自动清空;无勾选时按钮自动禁用", + "每栏均有搜索框与全选:全选只覆盖当前搜索结果里未禁用的条目,部分勾选时全选框显示半选", + "disabled 条目不可勾选、不计入全选基数、也不会被搬运", + "传入 targetKeys 信号即为受控用法,外部写入即可程序化搬运(如「一键全选」)" + ] } ] } diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 5b09407..86af691 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -2438,3 +2438,111 @@ button.ua-step-dot:hover { .ua-result-extra { margin-top: var(--ua-space-1); } +.ua-transfer { + display: flex; + align-items: center; + gap: var(--ua-space-2); + width: 100%; +} +.ua-transfer-pane { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + border: 1px solid var(--ua-color-border); + border-radius: var(--ua-radius-md); + background: var(--ua-color-surface); + overflow: hidden; +} +.ua-transfer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ua-space-1); + padding: var(--ua-space-1) var(--ua-space-2); + border-bottom: 1px solid var(--ua-color-border); +} +.ua-transfer-title { + font-size: var(--ua-font-size-sm); + font-weight: 600; + color: var(--ua-color-text); +} +.ua-transfer-search { + margin: var(--ua-space-1) var(--ua-space-2) 0; + padding: 4px var(--ua-space-1); + font-size: var(--ua-font-size-sm); + color: var(--ua-color-text); + background: var(--ua-color-bg); + border: 1px solid var(--ua-color-border); + border-radius: var(--ua-radius-sm); + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.ua-transfer-search:focus { + border-color: var(--ua-color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent); +} +.ua-transfer-list { + display: flex; + flex-direction: column; + gap: 2px; + height: 220px; + margin: 0; + padding: var(--ua-space-1); + overflow: auto; + list-style: none; +} +.ua-transfer-item { + border-radius: var(--ua-radius-sm); + font-size: var(--ua-font-size-sm); + color: var(--ua-color-text); + transition: background 0.15s ease; +} +.ua-transfer-item:hover { + background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent); +} +.ua-transfer-item.is-disabled { + opacity: 0.45; + cursor: not-allowed; +} +.ua-transfer-item.is-disabled:hover { + background: transparent; +} +.ua-transfer-label { + display: flex; + align-items: center; + gap: var(--ua-space-1); + padding: 6px var(--ua-space-1); + cursor: pointer; +} +.ua-transfer-item.is-disabled .ua-transfer-label { + cursor: not-allowed; +} +.ua-transfer-empty { + padding: var(--ua-space-2) 0; + text-align: center; + font-size: var(--ua-font-size-sm); + color: var(--ua-color-text-muted); +} +.ua-transfer-foot { + padding: 6px var(--ua-space-2); + border-top: 1px solid var(--ua-color-border); + font-size: var(--ua-font-size-sm); + color: var(--ua-color-text-muted); +} +.ua-transfer-ops { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ua-space-1); +} +.ua-transfer-ops-hint { + font-size: var(--ua-font-size-sm); + color: var(--ua-color-text-muted); +} +@media (prefers-reduced-motion: reduce) { + .ua-transfer-item, + .ua-transfer-search { + transition: none; + } +}