Files
UI/packages/skin/ui/src/cascader.ts
T
2026-10-26 12:10:21 +08:00

147 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}