@@ -1,55 +1,85 @@
import { effect , h , signal } from '@ui-arch/frame' ;
import type { Signal } from '@ui-arch/frame' ;
import type { TreeProps , TreeNode } from './types.js' ;
/*** 树形:递归渲染节点,箭头展开/收起,点击 标签选中;键盘 Enter 选中、→ 展开、← 收起,被禁用的节点不响应 ***/
/*** 树形:递归渲染节点,箭头展开/收起、点 标签选中;multi 时带勾选框且父子级联,键盘 Enter/空格选中或勾选 ***/
export function tree ( props : TreeProps ) : HTMLElement {
// —— 状态集中声明 ——
const expanded = props . expanded ? ? signal < string [ ] > ( [ ] ) ; // 展开的 key 集合,外部不传则内部维护
const selected = props . selected ? ? signal ( '' ) ; // 当前选中 key
const rows = new Map < string , HTMLElement > ( ) ; // key → 行元素,供响应式同步
const toggles = new Map < string , HTMLElement > ( ) ; // key → 展开箭头 ,供响应式同步
const selected = props . selected ? ? signal ( '' ) ; // 当前选中 key
const checked : Signal < string [ ] > = props . checked ? ? signal < string [ ] > ( [ ] ) ; // 勾选 key 集合(multi 生效)
const rows = new Map < string , HTMLElement > ( ) ; // key → 行元素 ,供响应式同步
const toggles = new Map < string , HTMLElement > ( ) ; // key → 展开箭头,供响应式同步
const boxes = new Map < string , HTMLInputElement > ( ) ; // key → 勾选框
const selfOf = new Map < string , TreeNode > ( ) ; // key → 节点对象
const parentOf = new Map < string , string | null > ( ) ; // key → 父 key(根为 null)
/*** 子孙 key 列表(含被禁用项,勾选时再按禁用过滤) ***/
const descOf = ( node : TreeNode ) : string [ ] = > ( node . children ? ? [ ] ) . flatMap ( ( c ) = > [ c . key , . . . descOf ( c ) ] ) ;
/*** 展开/收起一个节点(写回信号后由 effect 统一刷新 DOM) ***/
const toggle = ( key : string ) = > {
const toggle = ( key : string ) : void = > {
const cur = expanded ( ) ;
expanded ( cur . includes ( key ) ? cur . filter ( ( k ) = > k !== key ) : [ . . . cur , key ] ) ;
} ;
/*** 选中节点:写信号并回调 ***/
const pick = ( node : TreeNode ) = > {
const pick = ( node : TreeNode ) : void = > {
selected ( node . key ) ;
props . onSelect ? . ( node ) ;
} ;
/*** 递归构建节点行:父行 + 子容器(子容器缩进一级 ) ***/
const build = ( node : TreeNode , depth : number ) : HTMLElement = > {
/*** 勾选级联:向下整套写入,向上回算父级(子全选才勾上父 ) ***/
const setCheck = ( node : TreeNode , on : boolean ) : void = > {
const next = new Set ( checked ( ) ) ;
for ( const key of [ node . key , . . . descOf ( node ) ] ) {
if ( selfOf . get ( key ) ? . disabled ) continue ; // 禁用项不参与级联
on ? next . add ( key ) : next . delete ( key ) ;
}
let up = parentOf . get ( node . key ) ? ? null ;
while ( up ) {
const kids = ( selfOf . get ( up ) ? . children ? ? [ ] ) . filter ( ( c ) = > ! c . disabled ) ;
if ( on && kids . length && kids . every ( ( c ) = > next . has ( c . key ) ) ) next . add ( up ) ; // 子全选才上勾
else if ( ! on ) next . delete ( up ) ; // 子有未选:父必取消
up = parentOf . get ( up ) ? ? null ;
}
checked ( [ . . . next ] ) ;
props . onCheck ? . ( [ . . . next ] ) ;
} ;
/*** 递归构建节点行:父行 + 子容器(子容器缩进一级),multi 时行首插入勾选框 ***/
const build = ( node : TreeNode , depth : number , parent : string | null ) : HTMLElement = > {
const on = expanded ( ) . includes ( node . key ) ;
const caret = h ( 'span' , { class : 'ua-tree-caret' , 'aria-hidden' : 'true' } , '› ' ) ;
const box = props . multi
? ( h ( 'input' , { type : 'checkbox' , class : 'ua-checkbox ua-tree-box' , 'aria-label' : node . label , disabled : node.disabled } ) as HTMLInputElement )
: null ;
if ( box ) {
box . addEventListener ( 'click' , ( e ) = > { e . stopPropagation ( ) ; setCheck ( node , box . checked ) ; } ) ; // 勾选不触发选中
boxes . set ( node . key , box ) ;
}
const row = h (
'div' ,
{
class : 'ua-tree-row' ,
role : 'treeitem' ,
'aria-level' : String ( depth + 1 ) ,
'aria-expanded' : String ( on ) ,
tabindex : '0' ,
onClick : ( e : Event ) = > {
if ( node . disabled ) return ; // 卫语句:禁用节点不响应
if ( ( e . target as HTMLElement ) === caret ) toggle ( node . key ) ; // 点箭头只展开/收起
else pick ( node ) ;
} ,
} ,
{ class : 'ua-tree-row' , role : 'treeitem' , 'aria-level' : String ( depth + 1 ) , 'aria-expanded' : String ( on ) , tabindex : '0' } ,
box ,
caret ,
h ( 'span' , { class : 'ua-tree-label' } , node . label ) ,
) ;
row . addEventListener ( 'click' , ( e : Event ) = > {
if ( node . disabled ) return ; // 卫语句:禁用节点不响应
if ( ( e . target as HTMLElement ) === caret ) toggle ( node . key ) ; // 点箭头只展开/收起
else pick ( node ) ;
} ) ;
rows . set ( node . key , row ) ;
toggles . set ( node . key , caret ) ;
if ( ! node . children ? . length ) caret . classList . add ( 'ua-tree-caret-leaf' ) ; // 叶子节点箭头置空位占位
const box = h ( 'div' , { class : 'ua-tree-children' , role : 'group' } , . . . ( node . children ? ? [ ] ) . map ( ( c ) = > build ( c , depth + 1 ) ) ) ;
return h ( 'div' , { class : 'ua-tree-node' } , row , box ) ;
selfOf . set ( node . key , node ) ;
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 root = h ( 'div' , { class : 'ua-tree' , role : 'tree' } , . . . props . nodes . map ( ( n ) = > build ( n , 0 ) ) ) as HTMLElement ;
const root = h ( 'div' , { class : 'ua-tree' , role : 'tree' } , . . . props . nodes . map ( ( n ) = > build ( n , 0 , null ) ) ) as HTMLElement ;
if ( props . defaultExpandAll ) {
const all : string [ ] = [ ] ;
const walk = ( list : TreeNode [ ] ) = > list . forEach ( ( n ) = > { if ( n . children ? . length ) { all . push ( n . key ) ; walk ( n . children ) ; } } ) ;
@@ -60,6 +90,7 @@ export function tree(props: TreeProps): HTMLElement {
effect ( ( ) = > {
const open = expanded ( ) ;
const sel = selected ( ) ;
const picked = checked ( ) ;
for ( const [ key , row ] of rows ) {
const on = open . includes ( key ) ;
row . classList . toggle ( 'ua-tree-open' , on ) ;
@@ -67,8 +98,14 @@ export function tree(props: TreeProps): HTMLElement {
row . setAttribute ( 'aria-selected' , String ( key === sel ) ) ;
row . setAttribute ( 'aria-expanded' , String ( on ) ) ;
toggles . get ( key ) ? . classList . toggle ( 'ua-tree-caret-open' , on ) ; // 箭头随展开态旋转
const box = row . nextElementSibling as HTMLElement | null ;
if ( box ) box . hidden = ! on ; // 收起即隐藏子容器
const kids = row . nextElementSibling as HTMLElement | null ;
if ( kids ) kids . hidden = ! on ; // 收起即隐藏子容器
const box = boxes . get ( key ) ;
if ( ! box ) continue ; // 卫语句:非多选模式没有勾选框
const self = selfOf . get ( key ) ;
const hit = picked . includes ( key ) ;
box . checked = hit ;
box . indeterminate = ! hit && ! ! self && descOf ( self ) . some ( ( k ) = > picked . includes ( k ) ) ; // 半选:子级部分勾选
}
} ) ;
@@ -89,23 +126,17 @@ export function tree(props: TreeProps): HTMLElement {
return ;
}
const key = [ . . . rows . entries ( ) ] . find ( ( [ , el ] ) = > el === row ) ? . [ 0 ] ;
if ( ! key ) return ; // 卫语句:找不到对应节点
const node = findNode ( props . nodes , key ) ;
if ( ! node || node . disabled ) return ; // 卫语句:禁用节点不接管键盘
if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault ( ) ; pick ( node ) ; return ; }
const node = key ? selfOf . get ( key ) : null ;
if ( ! node || node . disabled || ! key ) return ; // 卫语句:找不到或禁用节点
if ( e . key === 'Enter' || e . key === ' ' ) {
e . preventDefault ( ) ;
if ( props . multi ) setCheck ( node , ! checked ( ) . includes ( key ) ) ; // 多选:回车/空格切换勾选
else pick ( node ) ;
return ;
}
if ( e . key === 'ArrowRight' && ! expanded ( ) . includes ( key ) ) { e . preventDefault ( ) ; toggle ( key ) ; return ; } // → 展开
if ( e . key === 'ArrowLeft' && expanded ( ) . includes ( key ) ) { e . preventDefault ( ) ; toggle ( key ) ; } // ← 收起
} ) ;
return root ;
}
/*** 按 key 深度查找节点(键盘操作需要拿到原始节点对象) ***/
function findNode ( nodes : TreeNode [ ] , key : string ) : TreeNode | null {
for ( const n of nodes ) {
if ( n . key === key ) return n ;
const hit = n . children ? findNode ( n . children , key ) : null ;
if ( hit ) return hit ;
}
return null ;
}