147 lines
6.8 KiB
TypeScript
147 lines
6.8 KiB
TypeScript
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;
|
||
}
|