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; }