diff --git a/.ua/backlog.json b/.ua/backlog.json index 86f6424..77b6fc5 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -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" } ] } diff --git a/.ua/index.json b/.ua/index.json index ce1a7a7..8d99832 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -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:输入即过滤,命中节点连同父链一起显示,隐藏的枝不参与方向键遍历" ] }, { diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 7d036ef..f91142b 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -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' }] }, diff --git a/llms-full.txt b/llms-full.txt index 6945e77..b098aab 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -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 —— 展开的 key 集合;不传则内部维护 - selected:Signal —— 选中 key 信号;不传则内部维护 - defaultExpandAll:boolean —— 首屏展开全部分支;缺省 false +- searchable:boolean —— 顶部搜索框:按标签过滤,命中节点保留父链并自动展开;缺省 false - onSelect(回调):(node: TreeNode) => void —— 选中回调:点击标签或 Enter/空格触发 - multi:boolean —— 是否显示勾选框并开启多选;缺省 false - checked:Signal —— 勾选 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 diff --git a/packages/skin/ui/src/tree.ts b/packages/skin/ui/src/tree.ts index 1236f95..237c30a 100644 --- a/packages/skin/ui/src/tree.ts +++ b/packages/skin/ui/src/tree.ts @@ -14,6 +14,8 @@ export function tree(props: TreeProps): HTMLElement { const selfOf = new Map(); // key → 节点对象 const parentOf = new Map(); // key → 父 key(根为 null) let tabStop: string | null = null; // roving tabindex:整棵树只保留一个 Tab 停点 + const keyword = signal(''); // 搜索词:非空时按标签过滤,命中节点保留父链 + const nodeEls = new Map(); // 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 => { + const q = keyword().trim().toLowerCase(); + const set = new Set(); + 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('.ua-tree-row')).filter((el) => !el.closest('.ua-tree-children[hidden]')); + Array.from(root.querySelectorAll('.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 之外,语义不混 } diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 2a24501..0db69a3 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -487,6 +487,7 @@ export interface TreeProps { expanded?: Signal; // 展开的 key 集合;不传则内部维护 selected?: Signal; // 选中 key 信号;不传则内部维护 defaultExpandAll?: boolean; // 首屏展开全部分支;缺省 false + searchable?: boolean; // 顶部搜索框:按标签过滤并保留命中节点的父链;缺省 false multi?: boolean; // 是否显示勾选框并开启多选;缺省 false checked?: Signal; // 勾选 key 集合(multi 时生效);不传则内部维护 onSelect?: (node: TreeNode) => void; // 选中回调(点击或 Enter/空格) diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 8d9e9f5..6a00f6f 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -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:输入即过滤,命中节点连同父链一起显示,隐藏的枝不参与方向键遍历" ] }, { diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 3ac9b36..f3b6f6e 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -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 {