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 = props.value ?? signal([]); // 已提交路径 const path = signal([]); // 浏览路径(悬停/下钻都会改,不等于已提交) 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; }