iter(g46): 新增 transfer 穿梭框组件
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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<string[]> = props.targetKeys ?? signal<string[]>([]); // 右侧集合:外部受控优先
|
||||
const picks = { left: signal<string[]>([]), right: signal<string[]>([]) } as Record<'left' | 'right', Signal<string[]>>; // 两栏勾选 key
|
||||
const queries = { left: signal(''), right: signal('') } as Record<'left' | 'right', Signal<string>>; // 两栏搜索词
|
||||
const boxes = { left: new Map<string, HTMLInputElement>(), right: new Map<string, HTMLInputElement>() } as Record<'left' | 'right', Map<string, HTMLInputElement>>; // 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;
|
||||
}
|
||||
@@ -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<string[]>; // 右侧集合信号;不传则组件内部维护
|
||||
titles?: [string, string]; // 左右栏标题;缺省「待选列表 / 已选列表」
|
||||
searchable?: boolean; // 是否显示搜索框;缺省 true
|
||||
height?: number; // 列表区固定高度像素;缺省走样式表 220
|
||||
onChange?: (keys: string[]) => void; // 右侧集合变化回调(搬运后触发,传副本)
|
||||
}
|
||||
|
||||
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
|
||||
|
||||
@@ -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<string[]>",
|
||||
"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 信号即为受控用法,外部写入即可程序化搬运(如「一键全选」)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user