tree —— ツリー
ツリーは階層データの汎用コンテナです:ファイルディレクトリ、組織図、権限ツリー、カスケードメニュー。難しさは再帰構造の状態管理にあります——展開、チェック、遅延読み込みはすべてサブツリー全体への操作で、さらに循環参照データ、無効ノード、中間選択状態を扱わなければなりません。core/tree は純関数 + イミュータブルな更新でこの一連のアルゴリズムを収束させます。
- 各端末で
LhTreeを実装するケース:ディレクトリの閲覧、権限の割り当て、組織図の表示、分類での絞り込み。 - 階層データの展開 / 畳み込み、チェック(親子の連動を含む)、遅延読み込みが必要なシーン。
- 使うべきでないケース:2 階層以内のフラットな分類(カスケードセレクトかセグメントコントローラーの方が手間がかからない);線形のリスト(
virtual-listを使う)。
インポート方法
Section titled “インポート方法”import { isTreeNodeLeaf, flattenTree, getDescendantKeys, findTreeNode, getNodeCheckState, collectLeafKeys, toggleCheckKeys, findParentNode, moveTreeFocus, mergeLoadedChildren, isNodeLoadable, getTreeClassNames, getTreeNodeClassNames, getTreeAria, getTreeNodeAria,} from '@lh-ui/share/core/tree'import type { LhTreeNode, LhFlatTreeNode, LhTreeCheckState,} from '@lh-ui/share/core/tree'レンダリングすなわちフラット化:ツリーは UI 上、本質的にインデントレベルを持ったリストです。flattenTree は深さ優先で走査し、expandedKeys にヒットしたサブツリーだけを展開して、フラットな配列(各項目に level / parentKey / ancestorKeys を付ける)を出力します。UI 層は 1 次元配列に v-for を回すだけです——再帰コンポーネントよりはるかにシンプルで、仮想スクロールとも自然に互換します。
祖先チェーンの重複判定 vs グローバルな重複判定:このモジュールの再帰関数は総じて祖先チェーンの seen(ancestors / nextSeen を階層ごとに引き渡す)を使い、グローバルな Set は使いません。代償は保護が単一の呼び出し内でしか有効でないこと、利点は DAG で合法的に共有されるサブツリーが誤ってスキップされないことと、インスタンスをまたぐ状態の漏れがないことです。これは table の flattenLeafColumns(グローバルな saw を使う)とは異なるトレードオフであることに注意してください——テーブルの DAG 共有は一度だけ送出されなければならず(列数の整合)、ツリーの DAG 共有はパスが異なればそれぞれを数えるべきです。
無効ノードは全選択の分母に入れない:collectCheckableLeafKeys は無効なサブツリーをスキップします——そうしないと、親をチェックしたあと無効な子孫の欠落のせいで永遠に indeterminate になり、全選択に到達できなくなります。これは実際に起きる体験バグの一类です。
未読み込みの非葉ノード:isLeaf !== true で children がないとき、collectCheckableLeafKeys は空の配列を返しますが、toggleCheckKeys はその自身のキーを checkedKeys に書き込みます——遅延読み込みのシーンでは、ユーザーはまだ子を読み込んでいないノードを先にチェックでき、読み込み後にカスケードするかは UI 層が決めます。
moveTreeFocus は @deprecated:ナビゲーションのアルゴリズムは core/shared/keyboard.getNextListIndex に収束しました。この外殻は旧シグネチャと「現在値が無効なときは先頭項目にフォールバック」という既存のセマンティクスを保持します。
| 関数 | シグネチャ | 戻り値 | 役割 |
|---|---|---|---|
isTreeNodeLeaf |
(node) => boolean |
葉かどうか | isLeaf を優先、さもなくば children から推断 |
flattenTree |
(nodes, expandedKeys, …) => LhFlatTreeNode[] |
フラットなリスト | ヒットしたサブツリーだけ展開 |
getDescendantKeys |
(node, seen?) => string[] |
子孫のキー | 祖先チェーンの重複判定 |
findTreeNode |
(nodes, key, seen?) => LhTreeNode | null |
ノード | 深さ優先の検索 |
getNodeCheckState |
(node, checkedKeys, checkStrictly?) => LhTreeCheckState |
三状態 | 無効サブツリーは分母に数えない |
collectLeafKeys |
(node, seen?) => string[] |
チェック可能な葉のキー | 無効をスキップ |
toggleCheckKeys |
(roots, checkedKeys, key, options?) => string[] |
新しいチェックキー | カスケード + 祖先の遡り |
findParentNode |
(nodes, key, …) => LhTreeNode | null |
親ノード | トップレベルは null を返す |
moveTreeFocus |
(flat, currentKey, direction) => string | null |
目標キー | @deprecated |
mergeLoadedChildren |
(nodes, loadedMap, seen?) => LhTreeNode[] |
新しいツリー | 遅延読み込みのイミュータブルなマージ |
isNodeLoadable |
(node, lazy, loadedKeys) => boolean |
読み込み可能か | 葉宣言がなく未読み込み |
getTreeClassNames |
(state: { showCheckbox }) => string[] |
クラス名 | チェックモード |
getTreeNodeClassNames |
(state) => string[] |
クラス名 | 展開 / 選択 / 無効 / 葉 |
getTreeAria |
(id?) => LhAriaAttrs |
aria 属性バッグ | role=tree |
getTreeNodeAria |
(options) => LhAriaAttrs |
aria 属性バッグ | treeitem の完全なセマンティクス |
export interface LhTreeNode { key: string label: string children?: LhTreeNode[] disabled?: boolean isLeaf?: boolean // 指定しない場合は children から推断}
export interface LhFlatTreeNode { node: LhTreeNode key: string level: number // 0 から parentKey: string | null ancestorKeys: string[] // 祖先チェーン(自身を含まない) isLeaf: boolean}
export type LhTreeCheckState = 'checked' | 'indeterminate' | 'unchecked'アクセシビリティ
Section titled “アクセシビリティ”getTreeAria / getTreeNodeAria は APG treeview パターンの完全なセマンティクスを出力します:
getTreeAria('lh-tree-1') // { role: 'tree', id: 'lh-tree-1' }
getTreeNodeAria({ level: 2, expanded: true, selected: true, checked: 'indeterminate' })// { role: 'treeitem', 'aria-level': 2, 'aria-expanded': true,// 'aria-selected': true, 'aria-checked': 'indeterminate' }
getTreeNodeAria({ level: 1, leaf: true })// { role: 'treeitem', 'aria-level': 1 } ← 葉ノードは aria-expanded を出力しないaria-levelは 1 から始まります(flattenTreeのlevelは 0 から始まるため、aria に渡すときは +1 します)。- 葉ノードは
aria-expandedを出力しません——無意味なaria-expanded="false"を避けるためで、これは APG の明確な要件です。 aria-checkedの 3 つの値:true/false/'indeterminate'(文字列)。- キーボードインタラクション(APG treeview):↑/↓ で可視ノード間を移動、→ で展開 / 子ノードへ进入、← で畳む / 親ノードへ戻る、Home/End で先頭 / 末尾、Enter で選択をトリガー。
- フラット化後の
aria-levelの来源:flattenTreeのlevel + 1です。DOM のインデントの深さから逆推しないでください。
import { flattenTree, toggleCheckKeys, getNodeCheckState } from '@lh-ui/share/core/tree'
const tree = [ { key: '1', label: '開発部', children: [ { key: '1-1', label: 'フロントエンドチーム', children: [{ key: '1-1-1', label: 'lh-ui' }] }, { key: '1-2', label: 'バックエンドチーム' }, ]}, { key: '2', label: 'プロダクトチーム' },]
flattenTree(tree, ['1'])// [開発部(l0), フロントエンドチーム(l1), バックエンドチーム(l1), プロダクトチーム(l0)]// 注意:1-1-1 は現れない。'1-1' が expandedKeys にないため
flattenTree(tree, ['1', '1-1'])// [開発部, フロントエンドチーム, lh-ui(l2), バックエンドチーム, プロダクトチーム]
toggleCheckKeys(tree, [], '1')// ['1', '1-1', '1-1-1', '1-2'](子孫をカスケード全選択、自身を含む)
toggleCheckKeys(tree, ['1', '1-1', '1-1-1', '1-2'], '1-1-1')// ['1-2'] —— '1-1-1' の解除後にカスケードで遡る:'1-1' はもう全選択でないため取り除かれ、// '1' も全選択でないため取り除かれる(遡りは根までずっと走る)
toggleCheckKeys(tree, ['1-1'], '1-2')// ['1-1', '1-2'] —— 祖先を遡るだけで、他のブランチには触れない
getNodeCheckState(tree[0], ['1', '1-1', '1-1-1', '1-2']) // 'checked'(チェック可能な葉が全てヒット)getNodeCheckState(tree[0], ['1-1-1']) // 'indeterminate'(2 つのチェック可能な葉のうち 1 つ)getNodeCheckState(tree[0], ['1-1']) // 'unchecked'('1-1' は葉でないためカウントしない)getNodeCheckState(tree[0], []) // 'unchecked'<!-- Vue3:フラット化 + 仮想スクロール対応のレンダリング --><div :class="getTreeClassNames({ showCheckbox })" v-bind="getTreeAria(treeId)"> <div v-for="item in flatNodes" :key="item.key" class="lh-tree-node-row" :style="{ paddingLeft: item.level * 18 + 'px' }" > <div :class="getTreeNodeClassNames({ expanded: expandedKeys.includes(item.key), selected: selectedKey === item.key, disabled: item.node.disabled, leaf: item.isLeaf, })" role="treeitem" :aria-level="item.level + 1" :aria-expanded="item.isLeaf ? undefined : expandedKeys.includes(item.key)" @click="onSelect(item)" > <span v-if="!item.isLeaf" class="lh-tree-node__arrow" @click.stop="onToggle(item)"> {{ expandedKeys.includes(item.key) ? '▾' : '▸' }} </span> <input v-if="showCheckbox" type="checkbox" :checked="checkState(item) === 'checked'" :indeterminate.prop="checkState(item) === 'indeterminate'" :disabled="item.node.disabled" @change="onCheck(item)" /> <span class="lh-tree-node__label">{{ item.node.label }}</span> </div> </div></div>// チェックの配線function onCheck(item: LhFlatTreeNode) { checkedKeys.value = toggleCheckKeys(props.data, checkedKeys.value, item.key, { checkStrictly })}function checkState(item: LhFlatTreeNode) { return getNodeCheckState(item.node, checkedKeys.value, checkStrictly)}// 遅延読み込みasync function onLoad(item: LhFlatTreeNode) { if (!isNodeLoadable(item.node, lazy, loadedKeys.value)) return loadingKeys.value = [...loadingKeys.value, item.key] const children = await fetchChildren(item.key) loadedMap.value = { ...loadedMap.value, [item.key]: children } loadedKeys.value = [...loadedKeys.value, item.key] // イミュータブルにマージしたあと再フラット化}const merged = computed(() => mergeLoadedChildren(props.data, loadedMap.value))// React:expandedKeys / checkedKeys / selectedKey はすべて受控状態;// flatNodes = useMemo(() => flattenTree(merged, expandedKeys), [merged, expandedKeys])// uniapp:<view> + インデントの padding;core の全関数は端末非依存でそのまま使えるスタイルとトークン
Section titled “スタイルとトークン”| クラス名 | 意味 |
|---|---|
.lh-tree |
基本クラス |
.lh-tree--checkbox |
チェックモード(ノードのパディングに checkbox のスペースを確保) |
.lh-tree-node |
ノード |
.lh-tree-node--expanded |
展開(矢印の回転) |
.lh-tree-node--selected |
選択(主色の背景) |
.lh-tree-node--disabled |
無効 |
.lh-tree-node--leaf |
葉(矢印のプレースホルダーを畳む) |
.lh-tree-node__arrow / __label |
展開矢印 / テキスト |
インデントは UI 層の paddingLeft: level * 18px で実装します(core はピクセルに関与しない)。選択の背景 --lh-primary-color-light、矢印 --lh-text-color-secondary。
levelは 0 から、aria-levelは 1 から——このモジュールで最も間違いやすいポイントです。aria-levelをバインドするときは必ず+1してください。- 祖先チェーンの重複判定の有効範囲:保護は単一の呼び出し内でしか有効でありません。同じツリーに対する 2 回の独立した
findTreeNode呼び出しはそれぞれ重複判定を行い、共有サブツリーは 2 回走査されます——これは意図的なもの(DAG の合法的な共有)で、バグではありません。 checkStrictly(親子の連動なし):getNodeCheckStateは二状態になり、toggleCheckKeysは自身のキーだけを変更し、祖先を遡りません。権限割り当てのシーンでよく使われます。- 無効ノードのカスケードの不変条件:
toggleCheckKeysの祖先の遡りは無効な親をスキップします——そのキーはカスケードによって書き換えられません。「無効な親ノードもプログラム的に選択可能」なビジネスが必要なら、この関数ではなくcheckedKeysを直接操作してください。 isLeafはchildrenより優先:isLeaf: trueが宣言されたノードはchildrenがあっても葉として扱われます(遅延読み込みのシーンでよく使う:葉を宣言して展開を阻止する)。mergeLoadedChildrenのイミュータビリティ:新しいツリーを返し、渡されたnodesを変更しません——props.data は使用側が保持し、直接変更すると単方向データフローが壊れます。flattenTreeのパフォーマンス:expandedKeys が変化するたびに全量を再計算します。階層が深くデータ量が大きい場合はサブツリーのメモ化を検討してください(UI 層が parentKey ごとにグループ化してキャッシュ)。- 循環参照データ:すべての再帰関数に祖先チェーンの重複判定による保護があり、無限再帰しません——しかしデータ自体に閉路があるのはもともとエラーです。重複判定はクラッシュさせないためのものにすぎません。
@lh-ui/share/core/tree → getNodeCheckState( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue