iter(g47): 新增 cascader 级联选择组件
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
import { pushOverlay } from './overlay.js';
|
||||
import { flip } from './position.js';
|
||||
import type { CascaderOption, CascaderProps } from './types.js';
|
||||
|
||||
/*** 沿路径解析节点链:路径失效时在断点截断,避免脏数据把列算错 ***/
|
||||
function chain(options: CascaderOption[], path: string[]): CascaderOption[] {
|
||||
const out: CascaderOption[] = [];
|
||||
let level = options;
|
||||
for (const v of path) {
|
||||
const hit = level.find((o) => o.value === v);
|
||||
if (!hit) break;
|
||||
out.push(hit);
|
||||
level = hit.children ?? [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/*** 级联选择:多列联动面板,悬停下钻 / 点击提交,value 信号双向绑定,Esc 与弹层栈共用逐层关闭 ***/
|
||||
export function cascader(props: CascaderProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 面板开合
|
||||
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位:空间不足自动翻转
|
||||
const value: Signal<string[]> = props.value ?? signal<string[]>([]); // 已提交路径
|
||||
const path = signal<string[]>([]); // 浏览路径(悬停/下钻都会改,不等于已提交)
|
||||
const sep = props.separator ?? ' / '; // 路径分隔符
|
||||
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
|
||||
|
||||
const panels = h('div', { class: 'ua-cascader-panels', role: 'menu' }) as HTMLElement; // 多列容器
|
||||
const labelText = (): string => chain(props.options, value()).map((o) => o.label).join(sep); // 触发器文案
|
||||
|
||||
/*** 由浏览路径推出各列选项:根列恒在,逐级 append 子级 ***/
|
||||
const columns = (): CascaderOption[][] => {
|
||||
const out: CascaderOption[][] = [props.options];
|
||||
for (let d = 0; d < path().length; d++) {
|
||||
const node = out[d].find((o) => o.value === path()[d]);
|
||||
if (!node?.children?.length) break; // 断在叶子:后续不再出列
|
||||
out.push(node.children);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/*** 提交路径:写信号 + 回调 + 收起并把焦点还给触发器 ***/
|
||||
const commit = (p: string[]): void => {
|
||||
value(p);
|
||||
props.onChange?.([...p]);
|
||||
open(false);
|
||||
trigger.focus();
|
||||
};
|
||||
|
||||
/*** 造一列:当前路径高亮、已提交路径打标、有子级显示下钻箭头 ***/
|
||||
const column = (opts: CascaderOption[], depth: number): HTMLElement => {
|
||||
const cur = path()[depth]; // 本列浏览中的值
|
||||
const done = value()[depth]; // 本列已提交的值
|
||||
const rows = opts.map((o) => {
|
||||
const hasChild = Boolean(o.children?.length);
|
||||
const cls = `ua-cascader-opt${o.value === cur ? ' is-active' : ''}${o.value === done ? ' is-picked' : ''}${o.disabled ? ' is-disabled' : ''}`;
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: cls,
|
||||
role: 'menuitem',
|
||||
'aria-haspopup': hasChild ? 'true' : 'false',
|
||||
disabled: o.disabled === true, // 禁用项既不可选也不可下钻
|
||||
onClick: () => {
|
||||
path([...path().slice(0, depth), o.value]); // 点选即把路径截断到本列
|
||||
if (!hasChild || props.changeOnSelect) commit([...path().slice(0, depth), o.value]); // 叶子或允许中间层:立即提交
|
||||
},
|
||||
onmouseenter: () => {
|
||||
if (o.disabled || o.value === cur) return; // 卫语句:禁用项与当前项不触发联动
|
||||
path([...path().slice(0, depth), o.value]);
|
||||
},
|
||||
},
|
||||
h('span', { class: 'ua-cascader-label' }, o.label),
|
||||
hasChild ? h('span', { class: 'ua-cascader-arrow', 'aria-hidden': 'true' }, '›') : null,
|
||||
);
|
||||
});
|
||||
return h('ul', { class: 'ua-cascader-col', role: 'group' }, ...rows) as HTMLElement;
|
||||
};
|
||||
|
||||
/*** 重绘所有列:列数随浏览深度增长 ***/
|
||||
const render = (): void => {
|
||||
const cols = columns();
|
||||
panels.replaceChildren(...cols.map((opts, d) => column(opts, d)));
|
||||
};
|
||||
|
||||
/*** 把焦点送到某列第 i 项(重建后 DOM 已换,必须等下一帧) ***/
|
||||
const focusCol = (depth: number, i = 0): void => {
|
||||
requestAnimationFrame(() => {
|
||||
const ul = panels.children[depth] as HTMLElement | undefined;
|
||||
if (!ul) return; // 卫语句:列不存在
|
||||
(ul.children[Math.min(i, ul.children.length - 1)] as HTMLElement | undefined)?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
// —— 触发器 ——
|
||||
const trigger = h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'ua-cascader-trigger',
|
||||
'aria-haspopup': 'menu',
|
||||
'aria-expanded': () => String(open()),
|
||||
disabled: props.disabled,
|
||||
onClick: () => { if (!open()) path([...value()]); open(!open()); },
|
||||
},
|
||||
h('span', { class: () => `ua-cascader-value${value().length ? '' : ' is-empty'}` }, () => labelText() || props.placeholder || '请选择'),
|
||||
h('span', { class: 'ua-cascader-caret', 'aria-hidden': 'true' }, '▾'),
|
||||
);
|
||||
|
||||
const pop = h('div', { class: () => `ua-cascader-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, panels);
|
||||
const root = h('div', { class: () => `ua-cascader${open() ? ' ua-open' : ''}` }, trigger, pop);
|
||||
|
||||
// 面板键盘导航:↑↓ 同列移动、→ 下钻、← 回退、Enter 提交,Esc 交给弹层栈
|
||||
panels.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const btn = document.activeElement as HTMLElement | null;
|
||||
const ul = btn?.parentElement as HTMLElement | null;
|
||||
if (!ul?.classList.contains('ua-cascader-col') || !btn) return; // 卫语句:焦点不在选项上
|
||||
const siblings = Array.from(ul.children); // 列内项,HTMLCollection 不能直接展开
|
||||
const depth = Array.from(panels.children).indexOf(ul); // 当前列序号
|
||||
const at = siblings.indexOf(btn);
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const next = siblings[(at + (e.key === 'ArrowDown' ? 1 : -1) + siblings.length) % siblings.length] as HTMLElement;
|
||||
next?.focus(); // 列内环形移动
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); btn.click(); if (open()) focusCol(depth + 1); return; } // 下钻并聚焦新列首项
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); focusCol(Math.max(depth - 1, 0), at); return; } // 回退到上一列同序项
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); } // 与鼠标点击同语义
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||
open(false); // 点外部收起
|
||||
});
|
||||
effect(render); // 依赖 open / path / value,任一变化即重绘
|
||||
effect(() => {
|
||||
if (open()) releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入栈:与弹窗/抽屉共用逐层关闭
|
||||
else { releaseEsc?.(); releaseEsc = null; }
|
||||
});
|
||||
effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); }); // 空间不足自动翻转
|
||||
return root;
|
||||
}
|
||||
@@ -45,3 +45,4 @@ export { drawer } from './drawer.js';
|
||||
export { form, formItem } from './form.js';
|
||||
export { dataTable } from './dataTable.js';
|
||||
export { transfer } from './transfer.js';
|
||||
export { cascader } from './cascader.js';
|
||||
|
||||
@@ -495,4 +495,23 @@ export interface TransferProps {
|
||||
onChange?: (keys: string[]) => void; // 右侧集合变化回调(搬运后触发,传副本)
|
||||
}
|
||||
|
||||
/*** 级联选项节点 ***/
|
||||
export interface CascaderOption {
|
||||
value: string; // 节点值:路径由逐级 value 组成
|
||||
label: string; // 显示文案
|
||||
children?: CascaderOption[]; // 子级选项;缺省为叶子
|
||||
disabled?: boolean; // 禁用:不可点选、不可下钻
|
||||
}
|
||||
|
||||
/*** 级联选择入参 ***/
|
||||
export interface CascaderProps {
|
||||
options: CascaderOption[]; // 根级选项
|
||||
value?: Signal<string[]>; // 选中路径(逐级 value 数组);不传则内部维护
|
||||
placeholder?: string; // 未选时占位文案;缺省「请选择」
|
||||
separator?: string; // 路径分隔符;缺省 ' / '
|
||||
changeOnSelect?: boolean; // 允许停在中间层;缺省 false 必须选到叶子
|
||||
disabled?: boolean | Signal<boolean>; // 禁用触发器
|
||||
onChange?: (path: string[]) => void; // 选中回调(叶子或中间层,传副本)
|
||||
}
|
||||
|
||||
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
|
||||
|
||||
Reference in New Issue
Block a user