iter(g93): 树形组件支持按标签搜索

This commit is contained in:
HIsheR
2026-10-26 13:12:28 +08:00
parent 1b106168a7
commit 90bba1c0b6
8 changed files with 83 additions and 9 deletions
+36 -4
View File
@@ -14,6 +14,8 @@ export function tree(props: TreeProps): HTMLElement {
const selfOf = new Map<string, TreeNode>(); // key → 节点对象
const parentOf = new Map<string, string | null>(); // key → 父 key(根为 null)
let tabStop: string | null = null; // roving tabindex:整棵树只保留一个 Tab 停点
const keyword = signal(''); // 搜索词:非空时按标签过滤,命中节点保留父链
const nodeEls = new Map<string, HTMLElement>(); // key → 节点根(行 + 子容器),整枝显隐用
/*** 切换 Tab 停点:只有当前行 tabindex=0,其余 -1,Tab 进出树只停一次 ***/
const syncTabStop = (key: string | null): void => {
@@ -86,7 +88,24 @@ export function tree(props: TreeProps): HTMLElement {
parentOf.set(node.key, parent);
if (!node.children?.length) caret.classList.add('ua-tree-caret-leaf'); // 叶子箭头置空位占位
const kids = h('div', { class: 'ua-tree-children', role: 'group' }, ...(node.children ?? []).map((c) => build(c, depth + 1, node.key)));
return h('div', { class: 'ua-tree-node' }, row, kids) as HTMLElement;
const nodeEl = h('div', { class: 'ua-tree-node' }, row, kids) as HTMLElement;
nodeEls.set(node.key, nodeEl);
return nodeEl;
};
/*** 命中集合:自身标签含关键词或子孙含关键词(父链整条保留,避免结果断在中间) ***/
const hitting = (): Set<string> => {
const q = keyword().trim().toLowerCase();
const set = new Set<string>();
if (!q) return set; // 卫语句:无关键词不过滤
const walk = (node: TreeNode): boolean => {
let hit = node.label.toLowerCase().includes(q);
for (const c of node.children ?? []) if (walk(c)) hit = true; // 子孙命中同样保留本节点
if (hit) set.add(node.key);
return hit;
};
props.nodes.forEach(walk);
return set;
};
const root = h('div', { class: 'ua-tree', role: 'tree' }, ...props.nodes.map((n) => build(n, 0, null))) as HTMLElement;
@@ -102,15 +121,20 @@ export function tree(props: TreeProps): HTMLElement {
const open = expanded();
const sel = selected();
const picked = checked();
const hits = hitting(); // 搜索命中集合;空集合表示未过滤
const filtering = keyword().trim().length > 0;
for (const [key, row] of rows) {
const on = open.includes(key);
const shown = !filtering || hits.has(key);
const nodeEl = nodeEls.get(key);
if (nodeEl) nodeEl.hidden = !shown; // 不命中整枝隐藏,连带子节点退出键盘遍历
row.classList.toggle('ua-tree-open', on);
row.classList.toggle('ua-tree-selected', key === sel);
row.setAttribute('aria-selected', String(key === sel));
row.setAttribute('aria-expanded', String(on));
toggles.get(key)?.classList.toggle('ua-tree-caret-open', on); // 箭头随展开态旋转
const kids = row.nextElementSibling as HTMLElement | null;
if (kids) kids.hidden = !on; // 收起即隐藏子容器
if (kids) kids.hidden = filtering ? !shown : !on; // 搜索时命中枝强制展开,否则跟随展开态
const box = boxes.get(key);
if (!box) continue; // 卫语句:非多选模式没有勾选框
const self = selfOf.get(key);
@@ -122,7 +146,7 @@ export function tree(props: TreeProps): HTMLElement {
/*** 当前可见的节点行(收起分支的子节点不参与 ↑↓ 遍历) ***/
const visibleRows = (): HTMLElement[] =>
Array.from(root.querySelectorAll<HTMLElement>('.ua-tree-row')).filter((el) => !el.closest('.ua-tree-children[hidden]'));
Array.from(root.querySelectorAll<HTMLElement>('.ua-tree-row')).filter((el) => !el.closest('[hidden]')); // 收起或搜索隐藏的枝都不参与 ↑↓ 遍历
root.addEventListener('focusin', (e: FocusEvent) => {
const row = (e.target as HTMLElement).closest('.ua-tree-row') as HTMLElement | null;
@@ -155,5 +179,13 @@ export function tree(props: TreeProps): HTMLElement {
if (e.key === 'ArrowLeft' && expanded().includes(key)) { e.preventDefault(); toggle(key); } // ← 收起
});
return root;
if (!props.searchable) return root;
const search = h('input', {
type: 'search',
class: 'ua-tree-search',
placeholder: '搜索节点…',
'aria-label': '搜索节点',
oninput: (e: Event) => keyword((e.target as HTMLInputElement).value),
}) as HTMLInputElement;
return h('div', { class: 'ua-tree-box' }, search, root); // 搜索框放在 role=tree 之外,语义不混
}
+1
View File
@@ -487,6 +487,7 @@ export interface TreeProps {
expanded?: Signal<string[]>; // 展开的 key 集合;不传则内部维护
selected?: Signal<string>; // 选中 key 信号;不传则内部维护
defaultExpandAll?: boolean; // 首屏展开全部分支;缺省 false
searchable?: boolean; // 顶部搜索框:按标签过滤并保留命中节点的父链;缺省 false
multi?: boolean; // 是否显示勾选框并开启多选;缺省 false
checked?: Signal<string[]>; // 勾选 key 集合(multi 时生效);不传则内部维护
onSelect?: (node: TreeNode) => void; // 选中回调(点击或 Enter/空格)
+7 -1
View File
@@ -1977,6 +1977,11 @@
"t": "boolean",
"d": "首屏展开全部分支;缺省 false"
},
{
"n": "searchable",
"t": "boolean",
"d": "顶部搜索框:按标签过滤,命中节点保留父链并自动展开;缺省 false"
},
{
"n": "onSelect",
"t": "(node: TreeNode) => void",
@@ -2007,7 +2012,8 @@
"键盘:↑↓ 在可见节点间移动、Home/End 到首末行(只移动焦点),Enter/空格 选中、→ 展开、← 收起;disabled 节点点击与键盘都不响应",
"defaultExpandAll 首屏展开全部分支(外部已给 expanded 初值时以外部为准)",
"multi: true 时每行前置勾选框:勾父级下钻全选子孙(跳过禁用项),子孙全选后父级自动上勾、部分勾选显示半选",
"勾选框点击不触发选中(stopPropagation),键盘在多选模式下 Enter/空格改为切换勾选"
"勾选框点击不触发选中(stopPropagation),键盘在多选模式下 Enter/空格改为切换勾选",
"节点多时开 searchable:输入即过滤,命中节点连同父链一起显示,隐藏的枝不参与方向键遍历"
]
},
{
+18
View File
@@ -2375,6 +2375,24 @@ button.ua-step-dot:hover {
.ua-tree-children[hidden] {
display: none;
}
.ua-tree-box {
display: flex;
flex-direction: column;
gap: var(--ua-space-2);
}
.ua-tree-search {
padding: 5px 10px;
border: 1px solid var(--ua-color-border);
border-radius: var(--ua-radius-md);
background: var(--ua-color-bg);
color: var(--ua-color-text);
font-size: var(--ua-font-size-sm);
}
.ua-tree-search:focus-visible {
outline: none;
border-color: var(--ua-color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.ua-tree-row,
.ua-tree-caret {