iter(g93): 树形组件支持按标签搜索
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 之外,语义不混
|
||||
}
|
||||
|
||||
@@ -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/空格)
|
||||
|
||||
@@ -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:输入即过滤,命中节点连同父链一起显示,隐藏的枝不参与方向键遍历"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user