iter(g47): 新增 cascader 级联选择组件

This commit is contained in:
HIsheR
2026-10-26 12:10:21 +08:00
parent cdef6050d0
commit d81b724408
12 changed files with 468 additions and 10 deletions
+146
View File
@@ -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;
}
+1
View File
@@ -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';
+19
View File
@@ -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 }; // 再导出常用内核类型,方便调用方