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
+8
View File
@@ -1100,6 +1100,14 @@
"acceptance": "BadgeProps 新增 count / max / dot,label 改为可选;count 超过 max(缺省 99)显示 N+,负数归零;dot 渲染 8px 圆点并以 role=img + label 作可访问名;新增样式只消费 --ua-* 令牌;文档与演示(三种形态)同步;npm run check 全绿",
"notes": "自补:组件能力补齐;保持纯展示、无内部状态",
"status": "done"
},
{
"id": "g93",
"title": "树形组件支持按标签搜索",
"motivation": "节点一多(如目录树、组织架构)就只能靠展开逐层找;tree 只有展开/收起与方向键,没有任何过滤入口,几十个节点等于不可用",
"acceptance": "TreeProps 新增 searchable:顶部搜索框按标签包含匹配(大小写不敏感),命中节点连同父链保留、命中枝临时强制展开,不命中整枝隐藏且不参与 ↑↓ 遍历(visibleRows 改判 [hidden]);搜索框放在 role=tree 之外避免语义混淆;文档与演示同步;tree ≤ 200 行;npm run check 全绿",
"notes": "自补:可用性;搜索只改视图显隐,不回写 expanded 信号,退出搜索即恢复用户原有展开态",
"status": "open"
}
]
}
+8 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "ui-arch-registry-index/0.1",
"generatedAt": "2026-10-26T05:11:11.108Z",
"generatedAt": "2026-10-26T05:12:11.001Z",
"repoRoot": ".",
"packages": [
{
@@ -2106,6 +2106,11 @@
"t": "boolean",
"d": "首屏展开全部分支;缺省 false"
},
{
"n": "searchable",
"t": "boolean",
"d": "顶部搜索框:按标签过滤,命中节点保留父链并自动展开;缺省 false"
},
{
"n": "onSelect",
"t": "(node: TreeNode) => void",
@@ -2136,7 +2141,8 @@
"键盘:↑↓ 在可见节点间移动、Home/End 到首末行(只移动焦点),Enter/空格 选中、→ 展开、← 收起;disabled 节点点击与键盘都不响应",
"defaultExpandAll 首屏展开全部分支(外部已给 expanded 初值时以外部为准)",
"multi: true 时每行前置勾选框:勾父级下钻全选子孙(跳过禁用项),子孙全选后父级自动上勾、部分勾选显示半选",
"勾选框点击不触发选中(stopPropagation),键盘在多选模式下 Enter/空格改为切换勾选"
"勾选框点击不触发选中(stopPropagation),键盘在多选模式下 Enter/空格改为切换勾选",
"节点多时开 searchable:输入即过滤,命中节点连同父链一起显示,隐藏的枝不参与方向键遍历"
]
},
{
+2 -1
View File
@@ -691,11 +691,12 @@ const content = h(
section(
'sec-tree',
'树形',
'tree:递归节点,箭头展开/收起、点标签选中;键盘 Enter 选中、→ 展开、← 收起、↑↓/Home/End 行间移动,整棵树只占一个 Tab 停点(roving tabindex),禁用节点不响应。',
'tree:递归节点,箭头展开/收起、点标签选中;键盘 Enter 选中、→ 展开、← 收起、↑↓/Home/End 行间移动,整棵树只占一个 Tab 停点(roving tabindex);顶部搜索按标签过滤并保留命中父链。',
h(
'div',
{ class: 'row' },
tree({
searchable: true,
nodes: [
{ key: 'src', label: 'src', children: [
{ key: 'core', label: 'core', children: [{ key: 'frame', label: 'frame' }, { key: 'table', label: 'table-core' }] },
+3 -1
View File
@@ -1,6 +1,6 @@
# @ui-arch 组件库 Agent 文档(无图形版)
> 自动生成于 2026-10-26T05:11:11.108Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
> 自动生成于 2026-10-26T05:12:11.001Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
## 快速上手
@@ -1136,6 +1136,7 @@ const i = signal(0); carousel({ items: [h('div', null, 'A'), h('div', null, 'B')
- expanded:Signal<string[]> —— 展开的 key 集合;不传则内部维护
- selected:Signal<string> —— 选中 key 信号;不传则内部维护
- defaultExpandAll:boolean —— 首屏展开全部分支;缺省 false
- searchable:boolean —— 顶部搜索框:按标签过滤,命中节点保留父链并自动展开;缺省 false
- onSelect(回调):(node: TreeNode) => void —— 选中回调:点击标签或 Enter/空格触发
- multi:boolean —— 是否显示勾选框并开启多选;缺省 false
- checked:Signal<string[]> —— 勾选 key 集合(multi 时生效);不传则内部维护
@@ -1156,6 +1157,7 @@ const sel = signal(''); tree({ nodes: [{ key: 'src', label: 'src', children: [{
5. defaultExpandAll 首屏展开全部分支(外部已给 expanded 初值时以外部为准)
6. multi: true 时每行前置勾选框:勾父级下钻全选子孙(跳过禁用项),子孙全选后父级自动上勾、部分勾选显示半选
7. 勾选框点击不触发选中(stopPropagation),键盘在多选模式下 Enter/空格改为切换勾选
8. 节点多时开 searchable:输入即过滤,命中节点连同父链一起显示,隐藏的枝不参与方向键遍历
现场演示:apps/docs/demo.html#sec-tree
+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 {