From 673cbb0e98eef1b74952400eeaac5bf33e39651b Mon Sep 17 00:00:00 2001 From: Nazir T Date: Mon, 26 Oct 2026 16:53:02 +0300 Subject: [PATCH 1/4] feat(tree): add trackBy to tree controls to keep the expansion of rebuilt nodes --- .../tree/control/base-tree-control.ts | 33 ++++-- .../tree/control/flat-tree-control.filters.ts | 12 +- .../tree/control/flat-tree-control.spec.ts | 98 ++++++++++++++++ .../tree/control/flat-tree-control.ts | 17 ++- .../tree/control/nested-tree-control.spec.ts | 24 ++++ .../tree/control/nested-tree-control.ts | 12 +- .../components/tree/control/tree-control.ts | 12 +- .../tree/data-source/flat-data-source.spec.ts | 23 ++++ .../tree/data-source/flat-data-source.ts | 8 +- packages/components/tree/toggle.ts | 4 +- packages/components/tree/tree-base.ts | 22 +++- .../tree/tree-selection.component.spec.ts | 73 ++++++++++++ .../tree/tree-selection.component.ts | 43 +++---- packages/components/tree/tree.en.mdx | 21 ++++ packages/components/tree/tree.ru.mdx | 22 ++++ .../components/filter-bar.api.md | 2 +- .../components/tree-select.api.md | 2 +- tools/public_api_guard/components/tree.api.md | 105 ++++++++++-------- 18 files changed, 422 insertions(+), 111 deletions(-) diff --git a/packages/components/tree/control/base-tree-control.ts b/packages/components/tree/control/base-tree-control.ts index a2b14dfabc..936980cf42 100644 --- a/packages/components/tree/control/base-tree-control.ts +++ b/packages/components/tree/control/base-tree-control.ts @@ -3,11 +3,19 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { TreeControl } from './tree-control'; /** Base tree control. It has basic toggle/expand/collapse operations on a single data node. */ -export abstract class BaseTreeControl implements TreeControl { +export abstract class BaseTreeControl implements TreeControl { dataNodes: T[]; - /** A selection model with multi-selection to track expansion status. */ - expansionModel: SelectionModel = new SelectionModel(true); + /** A selection model with multi-selection to track expansion status: it holds the key of every expanded node. */ + expansionModel: SelectionModel = new SelectionModel(true); + + /** + * Gives the key a data node is expanded by. Without it the node object is its own key, so a node + * rebuilt as a new object — as a flattener does whenever its data changes — comes back collapsed. + * + * Set it before anything is expanded: keys stored under another function no longer match. + */ + trackBy?: (dataNode: T) => K; filterModel: SelectionModel = new SelectionModel(true); @@ -37,7 +45,7 @@ export abstract class BaseTreeControl implements TreeControl { return; } - this.expansionModel.toggle(dataNode); + this.expansionModel.toggle(this.expansionKey(dataNode)); } /** Expands one single data node. */ @@ -46,7 +54,7 @@ export abstract class BaseTreeControl implements TreeControl { return; } - this.expansionModel.select(dataNode); + this.expansionModel.select(this.expansionKey(dataNode)); } /** Collapses one single data node. */ @@ -55,17 +63,17 @@ export abstract class BaseTreeControl implements TreeControl { return; } - this.expansionModel.deselect(dataNode); + this.expansionModel.deselect(this.expansionKey(dataNode)); } /** Whether a given data node is expanded or not. Returns true if the data node is expanded. */ isExpanded(dataNode: T): boolean { - return this.expansionModel.isSelected(dataNode); + return this.expansionModel.isSelected(this.expansionKey(dataNode)); } /** Toggles a subtree rooted at `node` recursively. */ toggleDescendants(dataNode: T): void { - if (this.expansionModel.isSelected(dataNode)) { + if (this.isExpanded(dataNode)) { this.collapseDescendants(dataNode); } else { this.expandDescendants(dataNode); @@ -82,7 +90,7 @@ export abstract class BaseTreeControl implements TreeControl { const toBeProcessed = [dataNode]; toBeProcessed.push(...this.getDescendants(dataNode)); - this.expansionModel.select(...toBeProcessed); + this.expansionModel.select(...toBeProcessed.map((node) => this.expansionKey(node))); } /** Collapses a subtree rooted at given data node recursively. */ @@ -90,6 +98,11 @@ export abstract class BaseTreeControl implements TreeControl { const toBeProcessed = [dataNode]; toBeProcessed.push(...this.getDescendants(dataNode)); - this.expansionModel.deselect(...toBeProcessed); + this.expansionModel.deselect(...toBeProcessed.map((node) => this.expansionKey(node))); + } + + /** The key `expansionModel` holds for a data node. */ + protected expansionKey(dataNode: T): K { + return this.trackBy ? this.trackBy(dataNode) : (dataNode as unknown as K); } } diff --git a/packages/components/tree/control/flat-tree-control.filters.ts b/packages/components/tree/control/flat-tree-control.filters.ts index 9e23dac2e0..37bf007d67 100644 --- a/packages/components/tree/control/flat-tree-control.filters.ts +++ b/packages/components/tree/control/flat-tree-control.filters.ts @@ -12,10 +12,10 @@ export interface FlatTreeControlFilter { * Default first stage of the filter pipeline: keeps the nodes whose view value matches the query. * @docs-private */ -export class FilterByViewValue implements FlatTreeControlFilter { +export class FilterByViewValue implements FlatTreeControlFilter { result: T[]; - constructor(private control: FlatTreeControl) {} + constructor(private control: FlatTreeControl) {} handle(value: string | null): T[] { const viewValue = value || null; @@ -39,10 +39,10 @@ export class FilterByViewValue implements FlatTreeControlFilter { * matches stay reachable in the rendered hierarchy. * @docs-private */ -export class FilterParentsForNodes implements FlatTreeControlFilter { +export class FilterParentsForNodes implements FlatTreeControlFilter { result: T[]; - constructor(private control: FlatTreeControl) {} + constructor(private control: FlatTreeControl) {} handle(_, prevFilter: FlatTreeControlFilter): T[] { const result: Set = new Set(); @@ -61,12 +61,12 @@ export class FilterParentsForNodes implements FlatTreeControlFilter { } } -export class FilterByValues implements FlatTreeControlFilter { +export class FilterByValues implements FlatTreeControlFilter { result: T[]; private values: string[] = []; - constructor(private control: FlatTreeControl) {} + constructor(private control: FlatTreeControl) {} handle(_, prevFilter?: FlatTreeControlFilter): T[] { const found = this.control.dataNodes.filter((node) => this.values.includes(this.control.getValue(node))); diff --git a/packages/components/tree/control/flat-tree-control.spec.ts b/packages/components/tree/control/flat-tree-control.spec.ts index a313e02fb0..645132ed35 100644 --- a/packages/components/tree/control/flat-tree-control.spec.ts +++ b/packages/components/tree/control/flat-tree-control.spec.ts @@ -253,6 +253,104 @@ describe('FlatTreeControl', () => { }); }); }); + + describe('with trackBy', () => { + let control: FlatTreeControl; + let nodes: FlatNode[]; + + beforeEach(() => { + nodes = buildFlatNodes(); + control = new FlatTreeControl( + (node) => node.level, + (node) => node.expandable, + (node) => node.name, + (node) => node.name, + undefined, + undefined, + undefined, + { trackBy: (node) => node.name } + ); + control.dataNodes = nodes; + }); + + it('should keep a node expanded once the node is rebuilt as a new object', () => { + control.expand(byName(nodes, 'documents')); + + control.dataNodes = buildFlatNodes(); + + expect(control.isExpanded(byName(control.dataNodes, 'documents'))).toBe(true); + }); + + it('should collapse a node through an object rebuilt after it was expanded', () => { + control.expand(byName(nodes, 'documents')); + + control.collapse(byName(buildFlatNodes(), 'documents')); + + expect(control.expansionModel.selected).toEqual([]); + }); + + it('should hold the keys rather than the nodes in the expansion model', () => { + control.toggle(byName(nodes, 'root')); + + expect(control.expansionModel.selected).toEqual(['root']); + }); + + it('should expand and collapse all the nodes by key', () => { + control.expandAll(); + + expect(control.expansionModel.selected).toEqual(nodes.map((node) => node.name)); + + control.collapseAll(); + + expect(control.expansionModel.selected).toEqual([]); + }); + + it('should toggle a subtree by key, also once the subtree is rebuilt', () => { + control.toggleDescendants(byName(nodes, 'documents')); + + expect(control.expansionModel.selected).toEqual(['documents', 'specs', 'draft', 'readme']); + + control.dataNodes = buildFlatNodes(); + control.toggleDescendants(byName(control.dataNodes, 'documents')); + + expect(control.expansionModel.selected).toEqual([]); + }); + + it('should expand the expandable nodes a filter keeps by key', () => { + control.filterNodes('draft'); + + expect(control.expansionModel.selected).toEqual(['root', 'documents', 'specs']); + }); + + it('should restore the keys expanded before a filter once the query is dropped', async () => { + control.expand(byName(nodes, 'documents')); + + control.filterNodes('draft'); + await Promise.resolve(); + + control.filterNodes(''); + await Promise.resolve(); + + expect(control.expansionModel.selected).toEqual(['documents']); + }); + + it('should not carry the expansion over to a rebuilt node without trackBy', () => { + const plain = new FlatTreeControl( + (node) => node.level, + (node) => node.expandable, + (node) => node.name, + (node) => node.name + ); + + plain.dataNodes = nodes; + plain.expand(byName(nodes, 'documents')); + + plain.dataNodes = buildFlatNodes(); + + expect(plain.isExpanded(byName(plain.dataNodes, 'documents'))).toBe(false); + expect(plain.expansionModel.selected).toEqual([byName(nodes, 'documents')]); + }); + }); }); /** Flat node in the depth-first order the flattener produces, with 0-based levels. */ diff --git a/packages/components/tree/control/flat-tree-control.ts b/packages/components/tree/control/flat-tree-control.ts index 4a0fbb9319..be7688a4ec 100644 --- a/packages/components/tree/control/flat-tree-control.ts +++ b/packages/components/tree/control/flat-tree-control.ts @@ -1,6 +1,7 @@ import { escapeRegExp } from '@koobiq/components/core'; import { BaseTreeControl } from './base-tree-control'; import { FilterByViewValue, FilterParentsForNodes, FlatTreeControlFilter } from './flat-tree-control.filters'; +import { KbqTreeControlOptions } from './tree-control'; export function defaultCompareValues(firstValue: string, secondValue: string): boolean { return firstValue === secondValue; @@ -11,8 +12,8 @@ export function defaultCompareViewValues(firstViewValue: string, secondViewValue } /** Flat tree control. Able to expand/collapse a subtree recursively for flattened tree. */ -export class FlatTreeControl extends BaseTreeControl { - expandedItemsBeforeFiltration: T[]; +export class FlatTreeControl extends BaseTreeControl { + expandedItemsBeforeFiltration: K[]; private filters: FlatTreeControlFilter[]; @@ -29,11 +30,13 @@ export class FlatTreeControl extends BaseTreeControl { /** compareValues will be used to comparing values. */ public compareViewValues: (firstViewValue, secondViewValue) => boolean = defaultCompareViewValues, /** isDisabled will be used to determine if the node is disabled. */ - public isDisabled: (dataNode: T) => boolean = () => false + public isDisabled: (dataNode: T) => boolean = () => false, + options?: KbqTreeControlOptions ) { super(); - this.setFilters(new FilterByViewValue(this), new FilterParentsForNodes(this)); + this.trackBy = options?.trackBy; + this.setFilters(new FilterByViewValue(this), new FilterParentsForNodes(this)); } // TODO: nothing in the library uses it; kept as public API at the DS-2196 review. Document or deprecate it. @@ -79,7 +82,7 @@ export class FlatTreeControl extends BaseTreeControl { * data nodes of the tree. */ expandAll(): void { - this.expansionModel.select(...this.dataNodes); + this.expansionModel.select(...this.dataNodes.map((node) => this.expansionKey(node))); } /** @@ -135,7 +138,9 @@ export class FlatTreeControl extends BaseTreeControl { this.filterModel.select(...result); // set current expansion state according to filtered tree - this.expansionModel.setSelection(...result.filter((node) => this.isExpandable(node))); + this.expansionModel.setSelection( + ...result.filter((node) => this.isExpandable(node)).map((node) => this.expansionKey(node)) + ); this.updateFilterValue(value || result); diff --git a/packages/components/tree/control/nested-tree-control.spec.ts b/packages/components/tree/control/nested-tree-control.spec.ts index 7889559f9a..2e2862747b 100644 --- a/packages/components/tree/control/nested-tree-control.spec.ts +++ b/packages/components/tree/control/nested-tree-control.spec.ts @@ -84,6 +84,30 @@ describe('CdkNestedTreeControl', () => { expect(treeControl.expansionModel.selected.length).toBe(totalNumber); }); }); + + describe('with trackBy', () => { + let keyedControl: NestedTreeControl; + + beforeEach(() => { + keyedControl = new NestedTreeControl(getChildren, { trackBy: (node) => node.a }); + }); + + it('should keep a node expanded once the node is rebuilt as a new object', () => { + keyedControl.expand(generateData(1, 1)[0]); + + expect(keyedControl.isExpanded(generateData(1, 1)[0])).toBe(true); + }); + + it('should expand all the nodes by key', () => { + keyedControl.dataNodes = generateData(2, 2, 1); + + keyedControl.expandAll(); + + expect([...keyedControl.expansionModel.selected].sort()).toEqual( + Array.from({ length: 10 }, (_, index) => `a_${index}`) + ); + }); + }); }); export class TestData { diff --git a/packages/components/tree/control/nested-tree-control.ts b/packages/components/tree/control/nested-tree-control.ts index 1d0f6c88b4..9d735249fe 100644 --- a/packages/components/tree/control/nested-tree-control.ts +++ b/packages/components/tree/control/nested-tree-control.ts @@ -1,9 +1,10 @@ import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { BaseTreeControl } from './base-tree-control'; +import { KbqTreeControlOptions } from './tree-control'; /** Nested tree control. Able to expand/collapse a subtree recursively for NestedNode type. */ -export class NestedTreeControl extends BaseTreeControl { +export class NestedTreeControl extends BaseTreeControl { /** * Construct with nested tree function getChildren. * @@ -11,8 +12,13 @@ export class NestedTreeControl extends BaseTreeControl { * `getDescendants` walks the tree with `take(1)` and returns as soon as the recursion unwinds, so * an asynchronous source yields an incomplete — usually empty — descendant list. */ - constructor(public getChildren: (dataNode: T) => Observable) { + constructor( + public getChildren: (dataNode: T) => Observable, + options?: KbqTreeControlOptions + ) { super(); + + this.trackBy = options?.trackBy; } /** @@ -28,7 +34,7 @@ export class NestedTreeControl extends BaseTreeControl { [] ); - this.expansionModel.select(...allNodes); + this.expansionModel.select(...allNodes.map((node) => this.expansionKey(node))); } /** Gets a list of descendant dataNodes of a subtree rooted at given data node recursively. */ diff --git a/packages/components/tree/control/tree-control.ts b/packages/components/tree/control/tree-control.ts index 3d91a545c3..509d989d0b 100644 --- a/packages/components/tree/control/tree-control.ts +++ b/packages/components/tree/control/tree-control.ts @@ -1,17 +1,23 @@ import { SelectionModel } from '@angular/cdk/collections'; import { BehaviorSubject, Observable } from 'rxjs'; +/** Options of `FlatTreeControl` and `NestedTreeControl`. */ +export interface KbqTreeControlOptions { + /** Initial value of the control's `trackBy`. */ + trackBy?: (dataNode: T) => K; +} + /** * Tree control interface. User can implement TreeControl to expand/collapse dataNodes in the tree. * The Tree will use this TreeControl to expand/collapse a node. * User can also use it outside the `` to control the expansion status of the tree. */ -export interface TreeControl { +export interface TreeControl { /** The saved tree nodes data for `expandAll` action. */ dataNodes: T[]; - /** The expansion model */ - expansionModel: SelectionModel; + /** The expansion model: the keys of the expanded data nodes, which are the nodes themselves by default. */ + expansionModel: SelectionModel; filterModel: SelectionModel; diff --git a/packages/components/tree/data-source/flat-data-source.spec.ts b/packages/components/tree/data-source/flat-data-source.spec.ts index d06d7dcbdc..0f670d47fc 100644 --- a/packages/components/tree/data-source/flat-data-source.spec.ts +++ b/packages/components/tree/data-source/flat-data-source.spec.ts @@ -202,6 +202,29 @@ describe('KbqTreeFlatDataSource', () => { expect(emissions[emissions.length - 1]).toEqual(['root', 'documents', 'images', 'other']); }); + it('should keep the expanded nodes once the data is rebuilt, given a tree control with trackBy', () => { + const keyedControl = new FlatTreeControl( + getLevel, + isExpandable, + getValue, + getValue, + undefined, + undefined, + undefined, + { trackBy: getValue } + ); + const keyedDataSource = new KbqTreeFlatDataSource(keyedControl, flattener); + const emissions: string[][] = []; + + keyedDataSource.data = buildData(); + keyedDataSource.connect(collectionViewer).subscribe((nodes) => emissions.push(names(nodes))); + keyedControl.expand(byName(keyedControl.dataNodes, 'root')); + + keyedDataSource.data = buildData(); + + expect(emissions[emissions.length - 1]).toEqual(['root', 'documents', 'images', 'other']); + }); + it('should emit the filter result while a filter is active', async () => { const emissions: string[][] = []; diff --git a/packages/components/tree/data-source/flat-data-source.ts b/packages/components/tree/data-source/flat-data-source.ts index bc8a3ebf31..2720d7de8f 100644 --- a/packages/components/tree/data-source/flat-data-source.ts +++ b/packages/components/tree/data-source/flat-data-source.ts @@ -102,7 +102,7 @@ export class KbqTreeFlattener { * Expand flattened node with current expansion status. * The returned list may have different length. */ - expandFlattenedNodes(nodes: F[], treeControl: TreeControl): F[] { + expandFlattenedNodes(nodes: F[], treeControl: TreeControl): F[] { const results: F[] = []; const currentExpand: boolean[] = []; @@ -140,7 +140,7 @@ enum KbqTreeDataSourceChangeTypes { * The nested tree nodes of type `T` are flattened through `KbqTreeFlattener`, and converted * to type `F` for `KbqTree` to consume. */ -export class KbqTreeFlatDataSource extends DataSource { +export class KbqTreeFlatDataSource extends DataSource { flattenedData = new BehaviorSubject([]); expandedData = new BehaviorSubject([]); @@ -161,7 +161,7 @@ export class KbqTreeFlatDataSource extends DataSource { private readonly _data = signal([]); constructor( - private treeControl: FlatTreeControl, + private treeControl: FlatTreeControl, private treeFlattener: KbqTreeFlattener, initialData: T[] = [] ) { @@ -204,7 +204,7 @@ export class KbqTreeFlatDataSource extends DataSource { return this.filteredData.value; } - expansionHandler(_change: SelectionChange): F[] { + expansionHandler(_change: SelectionChange): F[] { const expandedNodes = this.treeFlattener.expandFlattenedNodes(this.flattenedData.value, this.treeControl); this.expandedData.next(expandedNodes); diff --git a/packages/components/tree/toggle.ts b/packages/components/tree/toggle.ts index d315a7dc7e..9c10d7c930 100644 --- a/packages/components/tree/toggle.ts +++ b/packages/components/tree/toggle.ts @@ -44,7 +44,9 @@ export class KbqTreeNodeToggleBaseDirective { } get iconState(): boolean { - return this.tree.treeControl.isExpanded(this.node()); + // The node `toggle()` acts on when `node` is not bound: an unbound `node` would reach the control's + // `trackBy` as `undefined`. + return this.tree.treeControl.isExpanded(this.node() ?? this.treeNode.data); } toggle(event: Event): void { diff --git a/packages/components/tree/tree-base.ts b/packages/components/tree/tree-base.ts index 959dd2cc2e..3908ffa311 100644 --- a/packages/components/tree/tree-base.ts +++ b/packages/components/tree/tree-base.ts @@ -65,9 +65,11 @@ const normalizeTreeState = (parsed: unknown): KbqTreeState | null => { * ported by hand. * * `C` narrows the tree control a concrete tree accepts, and so the type its `treeControl` binding is checked against. + * The bound leaves out `expansionModel`, the one member typed by the control's key: the model makes a control + * invariant in that type, so a bound that kept it would admit only controls without a `trackBy`. */ @Directive() -export class KbqTreeBase = TreeControl> +export class KbqTreeBase, 'expansionModel'> = TreeControl> implements AfterContentChecked, AfterContentInit, CollectionViewer, OnDestroy, OnInit { protected differs = inject(IterableDiffers); @@ -88,10 +90,10 @@ export class KbqTreeBase = TreeControl> } /** {@link treeControl} as state saving reads it: by node value, which only a `FlatTreeControl` has. */ - private get flatTreeControl(): FlatTreeControl { - const treeControl: TreeControl = this.treeControl; + private get flatTreeControl(): FlatTreeControl { + const treeControl: Omit, 'expansionModel'> = this.treeControl; - return treeControl as FlatTreeControl; + return treeControl as FlatTreeControl; } /** @@ -505,7 +507,10 @@ export class KbqTreeBase = TreeControl> // Straight at the model rather than through `expand()`, which is a no-op while a filter is // active — restoring must not depend on what the user happens to be searching for. if (found.length) { - this.stateSaving!.applying(() => treeControl.expansionModel.select(...found)); + const { trackBy } = treeControl; + const keys = trackBy ? found.map((node) => trackBy(node)) : found; + + this.stateSaving!.applying(() => treeControl.expansionModel.select(...keys)); } } @@ -515,7 +520,12 @@ export class KbqTreeBase = TreeControl> const expanded: string[] = []; let skipped = false; - for (const node of treeControl.expansionModel.selected) { + // With `trackBy` the model holds keys, which have no value: only the current nodes map back to them. + const expandedNodes = treeControl.trackBy + ? (treeControl.dataNodes ?? []).filter((node) => treeControl.isExpanded(node)) + : (treeControl.expansionModel.selected as T[]); + + for (const node of expandedNodes) { const value = treeControl.getValue(node); if (typeof value === 'string') { diff --git a/packages/components/tree/tree-selection.component.spec.ts b/packages/components/tree/tree-selection.component.spec.ts index c2783dcda9..3a461c8ba6 100644 --- a/packages/components/tree/tree-selection.component.spec.ts +++ b/packages/components/tree/tree-selection.component.spec.ts @@ -4961,6 +4961,46 @@ class TreeStateSaving extends TreeParams { stateSavingKey = 'tree-key'; } +/** A tree control that keys the expansion by node name, so it survives the nodes being rebuilt. */ +@Component({ + imports: [KbqTreeModule], + template: ` + + {{ node.name }} + + + {{ node.name }} + + + ` +}) +class TreeKeyedExpansion extends TreeParams { + // Beside the inherited control rather than in its place: that one is typed to hold nodes, this one names. + readonly keyedControl = new FlatTreeControl( + this.getLevel, + this.isExpandable, + this.getValue, + this.getValue, + undefined, + undefined, + undefined, + { trackBy: this.getValue } + ); + + readonly keyedDataSource = new KbqTreeFlatDataSource(this.keyedControl, this.treeFlattener); + + constructor() { + super(); + + this.keyedDataSource.data = this.treeData; + } +} + /** Models a tree whose data arrives after it has initialized — a lazily loaded one. */ @Component({ imports: [KbqTreeModule], @@ -5338,6 +5378,39 @@ describe('KbqTreeSelection state saving', () => { expect(expandedNames(fixture.componentInstance)).toEqual([]); }); }); + + describe('with a tree control keyed by trackBy', () => { + const expandedKeys = (component: TreeKeyedExpansion): string[] => + [...component.keyedControl.expansionModel.selected].sort(); + + it('keeps the expanded rows once the data is rebuilt into new nodes', () => { + const fixture = create(TreeKeyedExpansion); + + clickToggle(fixture, 1); + + fixture.componentInstance.keyedDataSource.data = buildFileTree(DATA_OBJECT, 0); + fixture.detectChanges(); + + // The five roots plus the three children of `Pictures`. + expect(getNodes(fixture.nativeElement.querySelector('kbq-tree-selection')).length).toBe(8); + }); + + it('restores the nodes that were expanded on the previous visit', () => { + store.setState('keyed-key', ['Pictures', 'Documents']); + + const fixture = create(TreeKeyedExpansion); + + expect(expandedKeys(fixture.componentInstance)).toEqual(['Documents', 'Pictures']); + }); + + it('persists the values of the expanded nodes', () => { + const fixture = create(TreeKeyedExpansion); + + clickToggle(fixture, 1); + + expect(store.getState('keyed-key')).toEqual(['Pictures']); + }); + }); }); @Component({ diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index bcb97393e1..a9a2067c88 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -87,9 +87,9 @@ export const KBQ_SELECTION_TREE_VALUE_ACCESSOR: Provider = { /** How long the key manager collects keystrokes before matching them against the option labels. */ const typeAheadDebounce = 200; -export class KbqTreeSelectAllEvent { +export class KbqTreeSelectAllEvent { constructor( - public source: KbqTreeSelection, + public source: KbqTreeSelection, public options: T[] ) {} } @@ -102,24 +102,24 @@ export class KbqTreeSelectAllEvent { * @param option - instance of KbqTreeOption * @param event - original keyboard event (optional) that triggered the copy */ -export class KbqTreeCopyEvent { +export class KbqTreeCopyEvent { constructor( - public source: KbqTreeSelection, + public source: KbqTreeSelection, public option: T, public event?: KeyboardEvent ) {} } -export class KbqTreeNavigationChange { +export class KbqTreeNavigationChange { constructor( - public source: KbqTreeSelection, + public source: KbqTreeSelection, public option: T ) {} } -export class KbqTreeSelectionChange { +export class KbqTreeSelectionChange { constructor( - public source: KbqTreeSelection, + public source: KbqTreeSelection, public option: T, public options?: T[] ) {} @@ -190,8 +190,8 @@ export class KbqTreeSelectionChange { ], exportAs: 'kbqTreeSelection' }) -export class KbqTreeSelection - extends KbqTreeBase> +export class KbqTreeSelection + extends KbqTreeBase> implements ControlValueAccessor, AfterContentInit, AfterViewInit, OnDestroy { private elementRef = inject>(ElementRef); @@ -247,7 +247,8 @@ export class KbqTreeSelection * a subscription taken on the model directly is left behind on the discarded instance. * * `T` is the type of the data nodes, inferred from `[treeControl]` in a template. A query names it: - * `viewChild.required>(KbqTreeSelection)`. + * `viewChild.required>(KbqTreeSelection)`, and a tree whose control has a `trackBy` + * names the key type too: `KbqTreeSelection`. */ selectionModel: SelectionModel; @@ -274,21 +275,21 @@ export class KbqTreeSelection return this.unorderedOptionsList(); } - readonly navigationChange = output, T>>(); + readonly navigationChange = output, T, K>>(); /** * Emits when the selection changes. A stream rather than an `output()`: `KbqTreeSelect` pipes it with rxjs. */ - readonly selectionChange = new EventEmitter, T>>(); + readonly selectionChange = new EventEmitter, T, K>>(); /** @docs-private */ readonly selectionChangeOutput = outputFromObservable(this.selectionChange, { alias: 'selectionChange' }); /** Emits after "select all" ran, with the options it could act on. */ - readonly selectAllChange = output, T>>(); + readonly selectAllChange = output, T, K>>(); /** @deprecated Use `selectAllChange` instead. Will be removed in 22.0.0. */ - readonly onSelectAll = output, T>>({ alias: 'onSelectAll' }); + readonly onSelectAll = output, T, K>>({ alias: 'onSelectAll' }); /** * Emits when the active option is copied with Ctrl/Cmd + C. Subscribing replaces the built-in @@ -296,13 +297,13 @@ export class KbqTreeSelection * `observed`, which `OutputEmitterRef` does not have. A template listener subscribes to it through * the output below. */ - readonly copyChange = new EventEmitter, T>>(); + readonly copyChange = new EventEmitter, T, K>>(); /** @docs-private */ readonly copyChangeOutput = outputFromObservable(this.copyChange, { alias: 'copyChange' }); /** @deprecated Use `copyChange` instead. Will be removed in 22.0.0. */ - readonly onCopy = new EventEmitter, T>>(); + readonly onCopy = new EventEmitter, T, K>>(); /** @docs-private */ readonly onCopyOutput = outputFromObservable(this.onCopy, { alias: 'onCopy' }); @@ -1250,8 +1251,8 @@ export class KbqTreeSelection /** @docs-private */ readonly selectAllHandlerInput = input< - ((event: KeyboardEvent, tree: KbqTreeSelection) => void) | undefined, - ((event: KeyboardEvent, tree: KbqTreeSelection) => void) | undefined + ((event: KeyboardEvent, tree: KbqTreeSelection) => void) | undefined, + ((event: KeyboardEvent, tree: KbqTreeSelection) => void) | undefined >(undefined, { alias: 'selectAllHandler', transform: kbqSelectAllHandlerTransform @@ -1261,11 +1262,11 @@ export class KbqTreeSelection * Function for handling the combination Ctrl + A (select all). By default, the internal handler is used, * which toggles the selection of all non-disabled, selectable options. */ - get selectAllHandler(): (event: KeyboardEvent, tree: KbqTreeSelection) => void { + get selectAllHandler(): (event: KeyboardEvent, tree: KbqTreeSelection) => void { return this.selectAllHandlerInput() ?? this.defaultSelectAllHandler; } - private defaultSelectAllHandler(event: KeyboardEvent, tree: KbqTreeSelection): void { + private defaultSelectAllHandler(event: KeyboardEvent, tree: KbqTreeSelection): void { event.preventDefault(); tree.selectAllOptions(); diff --git a/packages/components/tree/tree.en.mdx b/packages/components/tree/tree.en.mdx index ca48bb45bc..d5cebec9c1 100644 --- a/packages/components/tree/tree.en.mdx +++ b/packages/components/tree/tree.en.mdx @@ -138,3 +138,24 @@ collapsed or narrow the data with the search field. Nodes are tracked by identity by default, so a data source that rebuilds its node objects on every emission — a flattener among them — rebuilds every row. Pass a `trackBy` function returning a stable key to have the rows re-used across data updates instead. + +The tree control tracks the expanded nodes by identity too, so a rebuilt node comes back collapsed. +Give the control a `trackBy` of its own in the options, the last argument of `FlatTreeControl` and +`NestedTreeControl`. It takes the node alone and returns the key the node is expanded by: + +```ts +treeControl = new FlatTreeControl( + (node) => node.level, + (node) => node.expandable, + (node) => node.id, + (node) => node.name, + undefined, + undefined, + undefined, + { trackBy: (node) => node.id } +); +``` + +`expansionModel` then holds the keys rather than the nodes, and a type that names the tree names the key +type after the node type: `KbqTreeSelection`, +`KbqTreeSelectionChange, FileFlatNode, string>`. diff --git a/packages/components/tree/tree.ru.mdx b/packages/components/tree/tree.ru.mdx index ae4b2cd38c..73a5ea6af9 100644 --- a/packages/components/tree/tree.ru.mdx +++ b/packages/components/tree/tree.ru.mdx @@ -137,3 +137,25 @@ По умолчанию узлы отслеживаются по идентичности, поэтому источник данных, который пересоздаёт объекты узлов на каждой эмиссии — например, flattener, — пересоздаёт и все строки. Чтобы строки переиспользовались между обновлениями данных, передайте функцию `trackBy`, возвращающую стабильный ключ. + +Раскрытые узлы контрол дерева тоже отслеживает по идентичности, поэтому пересозданный узел снова +оказывается закрытым. Передайте контролу собственный `trackBy` в опциях — последнем аргументе +`FlatTreeControl` и `NestedTreeControl`. Он принимает только узел и возвращает ключ, по которому узел +раскрывается: + +```ts +treeControl = new FlatTreeControl( + (node) => node.level, + (node) => node.expandable, + (node) => node.id, + (node) => node.name, + undefined, + undefined, + undefined, + { trackBy: (node) => node.id } +); +``` + +Тогда `expansionModel` хранит ключи, а не узлы, а тип, в котором названо дерево, указывает тип ключа после +типа узла: `KbqTreeSelection`, +`KbqTreeSelectionChange, FileFlatNode, string>`. diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index fa149042c8..1341811434 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -634,7 +634,7 @@ export class KbqPipeMultiTreeSelectComponent extends KbqTreeSelectPipeBase>; + readonly tree: _angular_core.Signal>; updateTemplates: (templates: KbqPipeTemplate[] | null) => void; // (undocumented) static ɵcmp: _angular_core.ɵɵComponentDeclaration; diff --git a/tools/public_api_guard/components/tree-select.api.md b/tools/public_api_guard/components/tree-select.api.md index 96736a5758..d2ea5004ab 100644 --- a/tools/public_api_guard/components/tree-select.api.md +++ b/tools/public_api_guard/components/tree-select.api.md @@ -213,7 +213,7 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterVi // (undocumented) toggle(): void; // (undocumented) - readonly tree: _angular_core.Signal | undefined>; + readonly tree: _angular_core.Signal | undefined>; readonly trigger: _angular_core.Signal>; triggerFontSize: number; // (undocumented) diff --git a/tools/public_api_guard/components/tree.api.md b/tools/public_api_guard/components/tree.api.md index 6018329a7f..99528d83bc 100644 --- a/tools/public_api_guard/components/tree.api.md +++ b/tools/public_api_guard/components/tree.api.md @@ -49,7 +49,7 @@ import { TrackByFunction } from '@angular/core'; import { ViewContainerRef } from '@angular/core'; // @public -export abstract class BaseTreeControl implements TreeControl { +export abstract class BaseTreeControl implements TreeControl { collapse(dataNode: T): void; collapseAll(): void; collapseDescendants(dataNode: T): void; @@ -58,7 +58,8 @@ export abstract class BaseTreeControl implements TreeControl { expand(dataNode: T): void; abstract expandAll(): void; expandDescendants(dataNode: T): void; - expansionModel: SelectionModel; + protected expansionKey(dataNode: T): K; + expansionModel: SelectionModel; // (undocumented) filterModel: SelectionModel; // (undocumented) @@ -70,6 +71,7 @@ export abstract class BaseTreeControl implements TreeControl { isExpanded(dataNode: T): boolean; toggle(dataNode: T): void; toggleDescendants(dataNode: T): void; + trackBy?: (dataNode: T) => K; } // @public (undocumented) @@ -79,8 +81,8 @@ export function defaultCompareValues(firstValue: string, secondValue: string): b export function defaultCompareViewValues(firstViewValue: string, secondViewValue: string): boolean; // @public (undocumented) -export class FilterByValues implements FlatTreeControlFilter { - constructor(control: FlatTreeControl); +export class FilterByValues implements FlatTreeControlFilter { + constructor(control: FlatTreeControl); // (undocumented) getValues(): string[]; // (undocumented) @@ -92,8 +94,8 @@ export class FilterByValues implements FlatTreeControlFilter { } // @public -export class FilterByViewValue implements FlatTreeControlFilter { - constructor(control: FlatTreeControl); +export class FilterByViewValue implements FlatTreeControlFilter { + constructor(control: FlatTreeControl); // (undocumented) handle(value: string | null): T[]; // (undocumented) @@ -101,8 +103,8 @@ export class FilterByViewValue implements FlatTreeControlFilter { } // @public -export class FilterParentsForNodes implements FlatTreeControlFilter { - constructor(control: FlatTreeControl); +export class FilterParentsForNodes implements FlatTreeControlFilter { + constructor(control: FlatTreeControl); // (undocumented) handle(_: any, prevFilter: FlatTreeControlFilter): T[]; // (undocumented) @@ -110,18 +112,18 @@ export class FilterParentsForNodes implements FlatTreeControlFilter { } // @public -export class FlatTreeControl extends BaseTreeControl { +export class FlatTreeControl extends BaseTreeControl { constructor(getLevel: (dataNode: T) => number, isExpandable: (dataNode: T) => boolean, getValue: (dataNode: T) => any, getViewValue: (dataNode: T) => string, compareValues?: (firstValue: any, secondValue: any) => boolean, compareViewValues?: (firstViewValue: any, secondViewValue: any) => boolean, - isDisabled?: (dataNode: T) => boolean); + isDisabled?: (dataNode: T) => boolean, options?: KbqTreeControlOptions); compareValues: (firstValue: any, secondValue: any) => boolean; compareViewValues: (firstViewValue: any, secondViewValue: any) => boolean; expandAll(): void; // (undocumented) - expandedItemsBeforeFiltration: T[]; + expandedItemsBeforeFiltration: K[]; // (undocumented) filterNodes(value?: string | null): void; getDescendants(dataNode: T): T[]; @@ -164,7 +166,7 @@ export class KbqTree extends KbqTreeBase { } // @public -export class KbqTreeBase = TreeControl> implements AfterContentChecked, AfterContentInit, CollectionViewer, OnDestroy, OnInit { +export class KbqTreeBase, 'expansionModel'> = TreeControl> implements AfterContentChecked, AfterContentInit, CollectionViewer, OnDestroy, OnInit { constructor(); protected applyNodeChanges(data: T[] | ReadonlyArray, dataDiffer: IterableDiffer, viewContainer: ViewContainerRef, parentData?: T): boolean; // (undocumented) @@ -172,7 +174,7 @@ export class KbqTreeBase = TreeControl> implement clearSavedState(): void; protected dataDiffer: IterableDiffer; get dataSource(): DataSource | Observable | T[] | null; - readonly dataSourceInput: _angular_core.InputSignal | T[] | Observable | null | undefined>; + readonly dataSourceInput: _angular_core.InputSignal | Observable | null | undefined>; // (undocumented) protected readonly destroyRef: DestroyRef; // (undocumented) @@ -207,19 +209,24 @@ export class KbqTreeBase = TreeControl> implement } // @public -export class KbqTreeCopyEvent { - constructor(source: KbqTreeSelection, option: T, event?: KeyboardEvent | undefined); +export interface KbqTreeControlOptions { + trackBy?: (dataNode: T) => K; +} + +// @public +export class KbqTreeCopyEvent { + constructor(source: KbqTreeSelection, option: T, event?: KeyboardEvent | undefined); // (undocumented) event?: KeyboardEvent | undefined; // (undocumented) option: T; // (undocumented) - source: KbqTreeSelection; + source: KbqTreeSelection; } // @public -export class KbqTreeFlatDataSource extends DataSource { - constructor(treeControl: FlatTreeControl, treeFlattener: KbqTreeFlattener, initialData?: T[]); +export class KbqTreeFlatDataSource extends DataSource { + constructor(treeControl: FlatTreeControl, treeFlattener: KbqTreeFlattener, initialData?: T[]); // (undocumented) connect(collectionViewer: CollectionViewer): Observable; // (undocumented) @@ -230,7 +237,7 @@ export class KbqTreeFlatDataSource extends DataSource { // (undocumented) expandedData: BehaviorSubject; // (undocumented) - expansionHandler(_change: SelectionChange): F[]; + expansionHandler(_change: SelectionChange): F[]; // (undocumented) filteredData: BehaviorSubject; // (undocumented) @@ -242,7 +249,7 @@ export class KbqTreeFlatDataSource extends DataSource { // @public export class KbqTreeFlattener { constructor(transformFunction: (node: T, level: number, parent: F | null) => F, getLevel: (node: F) => number, isExpandable: (node: F) => boolean, getChildren: (node: T) => Observable | T[] | undefined | null); - expandFlattenedNodes(nodes: F[], treeControl: TreeControl): F[]; + expandFlattenedNodes(nodes: F[], treeControl: TreeControl): F[]; flattenChildren(children: T[], level: number, resultNodes: F[], parent: F | null): void; flattenNode(node: T, level: number, resultNodes: F[], parent: F | null): F[]; flattenNodes(structuredData: T[]): F[]; @@ -267,12 +274,12 @@ export class KbqTreeModule { } // @public (undocumented) -export class KbqTreeNavigationChange { - constructor(source: KbqTreeSelection, option: T); +export class KbqTreeNavigationChange { + constructor(source: KbqTreeSelection, option: T); // (undocumented) option: T; // (undocumented) - source: KbqTreeSelection; + source: KbqTreeSelection; } // @public @@ -309,7 +316,7 @@ export class KbqTreeNode implements IFocusableOption, OnDestroy { refresh(): void; readonly refreshed: Subject; // (undocumented) - tree: KbqTreeBase>; + tree: KbqTreeBase>; // (undocumented) static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "kbq-tree-node", ["kbqTreeNode"], {}, {}, never, never, true, never>; // (undocumented) @@ -366,7 +373,7 @@ export class KbqTreeNodePadding implements AfterViewInit { // (undocumented) paddingIndent(): string | null; // (undocumented) - protected tree: KbqTreeBase>; + protected tree: KbqTreeBase>; // (undocumented) protected treeNode: KbqTreeNode; // (undocumented) @@ -551,19 +558,19 @@ export interface KbqTreeOptionParent { } // @public (undocumented) -export class KbqTreeSelectAllEvent { - constructor(source: KbqTreeSelection, options: T[]); +export class KbqTreeSelectAllEvent { + constructor(source: KbqTreeSelection, options: T[]); // (undocumented) options: T[]; // (undocumented) - source: KbqTreeSelection; + source: KbqTreeSelection; } // @public (undocumented) export const kbqTreeSelectAllValue = "selectAll"; // @public (undocumented) -export class KbqTreeSelection extends KbqTreeBase> implements ControlValueAccessor, AfterContentInit, AfterViewInit, OnDestroy { +export class KbqTreeSelection extends KbqTreeBase> implements ControlValueAccessor, AfterContentInit, AfterViewInit, OnDestroy { constructor(); get allOptionsSelected(): boolean; readonly ariaLabel: _angular_core.InputSignal; @@ -575,8 +582,8 @@ export class KbqTreeSelection extends KbqTreeBase blur(): void; // (undocumented) copyActiveOption(event: KeyboardEvent): void; - readonly copyChange: EventEmitter, T>>; - readonly copyChangeOutput: _angular_core.OutputRef, T>>; + readonly copyChange: EventEmitter, T, K>>; + readonly copyChangeOutput: _angular_core.OutputRef, T, K>>; get disabled(): boolean; readonly disabledInput: _angular_core.InputSignalWithTransform; protected emitChangeEvent(option: KbqTreeOption): void; @@ -599,7 +606,7 @@ export class KbqTreeSelection extends KbqTreeBase get multipleMode(): MultipleMode | null; set multipleMode(value: MultipleMode | null); // (undocumented) - readonly navigationChange: _angular_core.OutputEmitterRef, T>>; + readonly navigationChange: _angular_core.OutputEmitterRef, T, K>>; // (undocumented) ngAfterContentChecked(): void; // (undocumented) @@ -614,11 +621,11 @@ export class KbqTreeSelection extends KbqTreeBase readonly noUnselectLastInput: _angular_core.InputSignalWithTransform; onChange: (value: any) => void; // @deprecated (undocumented) - readonly onCopy: EventEmitter, T>>; - readonly onCopyOutput: _angular_core.OutputRef, T>>; + readonly onCopy: EventEmitter, T, K>>; + readonly onCopyOutput: _angular_core.OutputRef, T, K>>; onKeyDown(event: KeyboardEvent): void; // @deprecated (undocumented) - readonly onSelectAll: _angular_core.OutputEmitterRef, T>>; + readonly onSelectAll: _angular_core.OutputEmitterRef, T, K>>; onTouched: () => void; // (undocumented) get optionBlurChanges(): Observable; @@ -641,9 +648,9 @@ export class KbqTreeSelection extends KbqTreeBase protected selectActiveOptions(): void; get selectAll(): boolean; set selectAll(value: boolean); - readonly selectAllChange: _angular_core.OutputEmitterRef, T>>; - get selectAllHandler(): (event: KeyboardEvent, tree: KbqTreeSelection) => void; - readonly selectAllHandlerInput: _angular_core.InputSignalWithTransform<((event: KeyboardEvent, tree: KbqTreeSelection) => void) | undefined, ((event: KeyboardEvent, tree: KbqTreeSelection) => void) | undefined>; + readonly selectAllChange: _angular_core.OutputEmitterRef, T, K>>; + get selectAllHandler(): (event: KeyboardEvent, tree: KbqTreeSelection) => void; + readonly selectAllHandlerInput: _angular_core.InputSignalWithTransform<((event: KeyboardEvent, tree: KbqTreeSelection) => void) | undefined, ((event: KeyboardEvent, tree: KbqTreeSelection) => void) | undefined>; readonly selectAllInput: _angular_core.InputSignalWithTransform; readonly selectAllOption: _angular_core.Signal | undefined>; // (undocumented) @@ -651,8 +658,8 @@ export class KbqTreeSelection extends KbqTreeBase get selectAllState(): KbqPseudoCheckboxState; protected get selectAllText(): string; readonly selectAllToggle: _angular_core.InputSignalWithTransform; - readonly selectionChange: EventEmitter, T>>; - readonly selectionChangeOutput: _angular_core.OutputRef, T>>; + readonly selectionChange: EventEmitter, T, K>>; + readonly selectionChangeOutput: _angular_core.OutputRef, T, K>>; selectionModel: SelectionModel; setDisabledState(isDisabled: boolean): void; setOptionsFromValues(values: any[]): void; @@ -679,28 +686,28 @@ export class KbqTreeSelection extends KbqTreeBase // (undocumented) writeValue(value: any): void; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration, "kbq-tree-selection", ["kbqTreeSelection"], { "ariaLabel": { "alias": "aria-label"; "required": false; "isSignal": true; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; "isSignal": true; }; "autoSelectInput": { "alias": "autoSelect"; "required": false; "isSignal": true; }; "multipleInput": { "alias": "multiple"; "required": false; "isSignal": true; }; "noUnselectLastInput": { "alias": "noUnselectLast"; "required": false; "isSignal": true; }; "typeAheadInput": { "alias": "typeAhead"; "required": false; "isSignal": true; }; "selectAllToggle": { "alias": "selectAllToggle"; "required": false; "isSignal": true; }; "selectAllInput": { "alias": "selectAll"; "required": false; "isSignal": true; }; "disabledInput": { "alias": "disabled"; "required": false; "isSignal": true; }; "tabIndexInput": { "alias": "tabIndex"; "required": false; "isSignal": true; }; "selectAllHandlerInput": { "alias": "selectAllHandler"; "required": false; "isSignal": true; }; }, { "navigationChange": "navigationChange"; "selectionChangeOutput": "selectionChange"; "selectAllChange": "selectAllChange"; "onSelectAll": "onSelectAll"; "copyChangeOutput": "copyChange"; "onCopyOutput": "onCopy"; }, ["unorderedOptionsQuery"], never, true, [{ directive: typeof i1.KbqStateSaving; inputs: { "useStateSaving": "useStateSaving"; "stateSavingKey": "stateSavingKey"; }; outputs: {}; }, { directive: typeof i1.KbqLocaleOverridesDirective; inputs: { "kbqLocaleOverrides": "localeOverrides"; }; outputs: {}; }]>; + static ɵcmp: _angular_core.ɵɵComponentDeclaration, "kbq-tree-selection", ["kbqTreeSelection"], { "ariaLabel": { "alias": "aria-label"; "required": false; "isSignal": true; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; "isSignal": true; }; "autoSelectInput": { "alias": "autoSelect"; "required": false; "isSignal": true; }; "multipleInput": { "alias": "multiple"; "required": false; "isSignal": true; }; "noUnselectLastInput": { "alias": "noUnselectLast"; "required": false; "isSignal": true; }; "typeAheadInput": { "alias": "typeAhead"; "required": false; "isSignal": true; }; "selectAllToggle": { "alias": "selectAllToggle"; "required": false; "isSignal": true; }; "selectAllInput": { "alias": "selectAll"; "required": false; "isSignal": true; }; "disabledInput": { "alias": "disabled"; "required": false; "isSignal": true; }; "tabIndexInput": { "alias": "tabIndex"; "required": false; "isSignal": true; }; "selectAllHandlerInput": { "alias": "selectAllHandler"; "required": false; "isSignal": true; }; }, { "navigationChange": "navigationChange"; "selectionChangeOutput": "selectionChange"; "selectAllChange": "selectAllChange"; "onSelectAll": "onSelectAll"; "copyChangeOutput": "copyChange"; "onCopyOutput": "onCopy"; }, ["unorderedOptionsQuery"], never, true, [{ directive: typeof i1.KbqStateSaving; inputs: { "useStateSaving": "useStateSaving"; "stateSavingKey": "stateSavingKey"; }; outputs: {}; }, { directive: typeof i1.KbqLocaleOverridesDirective; inputs: { "kbqLocaleOverrides": "localeOverrides"; }; outputs: {}; }]>; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; + static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; } // @public (undocumented) -export class KbqTreeSelectionChange { - constructor(source: KbqTreeSelection, option: T, options?: T[] | undefined); +export class KbqTreeSelectionChange { + constructor(source: KbqTreeSelection, option: T, options?: T[] | undefined); // (undocumented) option: T; // (undocumented) options?: T[] | undefined; // (undocumented) - source: KbqTreeSelection; + source: KbqTreeSelection; } // @public export type KbqTreeState = string[]; // @public -export class NestedTreeControl extends BaseTreeControl { - constructor(getChildren: (dataNode: T) => Observable); +export class NestedTreeControl extends BaseTreeControl { + constructor(getChildren: (dataNode: T) => Observable, options?: KbqTreeControlOptions); expandAll(): void; // (undocumented) getChildren: (dataNode: T) => Observable; @@ -708,7 +715,7 @@ export class NestedTreeControl extends BaseTreeControl { } // @public -export interface TreeControl { +export interface TreeControl { collapse(dataNode: T): void; collapseAll(): void; collapseDescendants(dataNode: T): void; @@ -716,7 +723,7 @@ export interface TreeControl { expand(dataNode: T): void; expandAll(): void; expandDescendants(dataNode: T): void; - expansionModel: SelectionModel; + expansionModel: SelectionModel; // (undocumented) filterModel: SelectionModel; // (undocumented) From aff6834f817158ec78e8ae54a2f79c4a639718e8 Mon Sep 17 00:00:00 2001 From: Nazir T Date: Mon, 26 Oct 2026 16:53:10 +0300 Subject: [PATCH 2/4] fix(tree): find a rebuilt node by its trackBy key in getDescendants and getParents --- .../tree/control/flat-tree-control.spec.ts | 17 +++++++++++++++++ .../tree/control/flat-tree-control.ts | 19 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/components/tree/control/flat-tree-control.spec.ts b/packages/components/tree/control/flat-tree-control.spec.ts index 645132ed35..8476d57001 100644 --- a/packages/components/tree/control/flat-tree-control.spec.ts +++ b/packages/components/tree/control/flat-tree-control.spec.ts @@ -316,6 +316,23 @@ describe('FlatTreeControl', () => { expect(control.expansionModel.selected).toEqual([]); }); + it('should find the descendants and parents of a node through an object it was rebuilt from', () => { + const stale = byName(nodes, 'documents'); + + control.dataNodes = buildFlatNodes(); + + expect(control.getDescendants(stale)).toEqual( + ['specs', 'draft', 'readme'].map((name) => byName(control.dataNodes, name)) + ); + expect(control.getParents(byName(nodes, 'draft'))).toEqual( + ['root', 'documents', 'specs'].map((name) => byName(control.dataNodes, name)) + ); + + control.expandDescendants(stale); + + expect(control.expansionModel.selected).toEqual(['documents', 'specs', 'draft', 'readme']); + }); + it('should expand the expandable nodes a filter keeps by key', () => { control.filterNodes('draft'); diff --git a/packages/components/tree/control/flat-tree-control.ts b/packages/components/tree/control/flat-tree-control.ts index be7688a4ec..64f3a27763 100644 --- a/packages/components/tree/control/flat-tree-control.ts +++ b/packages/components/tree/control/flat-tree-control.ts @@ -55,7 +55,7 @@ export class FlatTreeControl extends BaseTreeControl { * with correct levels. */ getDescendants(dataNode: T): T[] { - const startIndex = this.dataNodes.indexOf(dataNode); + const startIndex = this.indexOfNode(dataNode); const results: T[] = []; // Goes through flattened tree nodes in the `dataNodes` array, and get all descendants. @@ -97,7 +97,7 @@ export class FlatTreeControl extends BaseTreeControl { * an existing array; a fresh one is used when omitted. */ getParents(node: T, result: T[] = []): T[] { - const startIndex = this.dataNodes?.indexOf(node) ?? -1; + const startIndex = this.indexOfNode(node); if (startIndex === -1) { return result; @@ -162,4 +162,19 @@ export class FlatTreeControl extends BaseTreeControl { this.expansionModel.setSelection(...this.expandedItemsBeforeFiltration); } } + + /** + * Position of the node in `dataNodes`. With `trackBy` a node rebuilt as a new object since is found by its key, + * as the expansion methods accept it too. + */ + private indexOfNode(node: T): number { + const index = this.dataNodes?.indexOf(node) ?? -1; + const { trackBy } = this; + + if (index !== -1 || !trackBy) return index; + + const key = trackBy(node); + + return this.dataNodes.findIndex((candidate) => trackBy(candidate) === key); + } } From 30ddcb622822d0ebaf697d6f5556d396eabea1ce Mon Sep 17 00:00:00 2001 From: Nazir T Date: Mon, 26 Oct 2026 16:53:15 +0300 Subject: [PATCH 3/4] feat(tree): default the tree trackBy to the tree control's trackBy --- packages/components/tree/tree-base.ts | 8 +- .../tree/tree-selection.component.spec.ts | 79 +++++++++++++++++++ .../tree/tree-selection.component.ts | 17 ++++ packages/components/tree/tree.en.mdx | 14 ++-- packages/components/tree/tree.ru.mdx | 15 ++-- 5 files changed, 119 insertions(+), 14 deletions(-) diff --git a/packages/components/tree/tree-base.ts b/packages/components/tree/tree-base.ts index 3908ffa311..d52db733fb 100644 --- a/packages/components/tree/tree-base.ts +++ b/packages/components/tree/tree-base.ts @@ -104,6 +104,8 @@ export class KbqTreeBase, 'expansionModel'> = T * * Views are reused for nodes the function reports as unchanged, which a data source that rebuilds * its node objects on every emission — a flattener among them — otherwise cannot get. + * + * Defaults to the `trackBy` of the tree control, read when the tree initializes and when the data source changes. */ readonly trackBy = input>(undefined!); @@ -621,7 +623,11 @@ export class KbqTreeBase, 'expansionModel'> = T } private createDataDiffer(): IterableDiffer { - return this.differs.find([]).create(this.trackBy()); + // `TreeControl` does not declare `trackBy`: only the koobiq controls have it. + const controlTrackBy = (this.treeControl as { trackBy?: (dataNode: T) => unknown } | undefined)?.trackBy; + const trackBy = this.trackBy() ?? (controlTrackBy && ((_: number, node: T) => controlTrackBy(node))); + + return this.differs.find([]).create(trackBy); } } diff --git a/packages/components/tree/tree-selection.component.spec.ts b/packages/components/tree/tree-selection.component.spec.ts index 3a461c8ba6..49c483ff13 100644 --- a/packages/components/tree/tree-selection.component.spec.ts +++ b/packages/components/tree/tree-selection.component.spec.ts @@ -1655,6 +1655,46 @@ describe('KbqTreeSelection', () => { }); }); + describe('with a tree control keyed by trackBy and no [trackBy]', () => { + it('should reuse the rows of the same keys and keep the keyboard order of the new data', async () => { + vi.useFakeTimers(); + configureKbqTreeTestingModule(); + + const fixture = TestBed.createComponent(TreeWithKeyedControl); + const component = fixture.componentInstance; + const options = (): KbqTreeOption[] => + fixture.debugElement + .queryAll(By.directive(KbqTreeOption)) + .map((debugElement) => debugElement.componentInstance as KbqTreeOption); + + fixture.detectChanges(); + await vi.advanceTimersByTimeAsync(0); + fixture.detectChanges(); + + const [src, docs] = options(); + const nodes: TrackedNode[] = [ + { id: 2, name: 'docs', level: 0, expandable: false }, + { id: 1, name: 'source', level: 0, expandable: false } + ]; + + component.emit(nodes); + fixture.detectChanges(); + await vi.advanceTimersByTimeAsync(0); + fixture.detectChanges(); + + const rendered = component.tree().renderedOptions.toArray(); + + // Same instances: the rows were moved, not rebuilt. + expect(options()[0]).toBe(docs); + expect(options()[1]).toBe(src); + expect(src.getHostElement().textContent?.trim()).toBe('source'); + expect(rendered[0]).toBe(docs); + expect(rendered[1]).toBe(src); + expect(rendered[0].data).toBe(nodes[0]); + expect(rendered[1].data).toBe(nodes[1]); + }); + }); + describe('selection with CTRL + A', () => { let fixture: ComponentFixture; let component: KbqTreeAppMultiple; @@ -4029,6 +4069,45 @@ class TreeWithTrackedThirdStateCheckbox { } } +@Component({ + imports: [KbqTreeModule], + template: ` + + + {{ node.name }} + + + ` +}) +class TreeWithKeyedControl { + readonly tree = viewChild.required>(KbqTreeSelection); + + readonly dataSource = new BehaviorSubject([]); + + readonly treeControl = new FlatTreeControl( + (node) => node.level, + (node) => node.expandable, + (node) => node.name, + (node) => node.name, + undefined, + undefined, + undefined, + { trackBy: (node) => node.id } + ); + + constructor() { + this.emit([ + { id: 1, name: 'src', level: 0, expandable: false }, + { id: 2, name: 'docs', level: 0, expandable: false } + ]); + } + + emit(nodes: TrackedNode[]): void { + this.treeControl.dataNodes = nodes; + this.dataSource.next(nodes); + } +} + @Component({ imports: [KbqTreeModule], template: ` diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index a9a2067c88..3ab3cc6eb6 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -1204,6 +1204,10 @@ export class KbqTreeSelection this.changeDetectorRef.detectChanges(); + // The options query lists the options in the order their views were created, not in the order of the rows, so + // rows that only moved (reused through `trackBy`) never make it emit. + if (changed && this.renderedOptionsOutOfOrder()) this.updateRenderedOptions(); + this.scheduleCheckboxSync(false); // The "select all" row is rendered ahead of what its state reads: new rows bind their inputs after it and join @@ -1564,6 +1568,19 @@ export class KbqTreeSelection this.tabIndexState.set(this.userTabIndex || (this.renderedOptions.length === 0 ? -1 : 0)); } + /** + * Whether the same options as before now stand for the rows in another order. A different count means rows were + * added or removed, which the options query reports itself. + */ + private renderedOptionsOutOfOrder(): boolean { + const options = this.renderedOptions.toArray(); + const offset = this.selectAllOption() ? 1 : 0; + + if (options.length - offset !== this.sortedNodes.length) return false; + + return this.sortedNodes.some((node, index) => options[index + offset].data !== node); + } + private updateRenderedOptions = () => { const orderedOptions: KbqTreeOption[] = []; diff --git a/packages/components/tree/tree.en.mdx b/packages/components/tree/tree.en.mdx index d5cebec9c1..8d5e055910 100644 --- a/packages/components/tree/tree.en.mdx +++ b/packages/components/tree/tree.en.mdx @@ -136,12 +136,10 @@ costs a DOM row per item. A tree that shows thousands of rows at once should kee collapsed or narrow the data with the search field. Nodes are tracked by identity by default, so a data source that rebuilds its node objects on every -emission — a flattener among them — rebuilds every row. Pass a `trackBy` function returning a stable -key to have the rows re-used across data updates instead. - -The tree control tracks the expanded nodes by identity too, so a rebuilt node comes back collapsed. -Give the control a `trackBy` of its own in the options, the last argument of `FlatTreeControl` and -`NestedTreeControl`. It takes the node alone and returns the key the node is expanded by: +emission — a flattener among them — rebuilds every row, and a rebuilt node comes back collapsed. +Give the tree control a `trackBy` in the options, the last argument of `FlatTreeControl` and +`NestedTreeControl`. It takes the node alone and returns a stable key: the node is expanded by that key, +and the tree re-uses the row of the same key across data updates: ```ts treeControl = new FlatTreeControl( @@ -159,3 +157,7 @@ treeControl = new FlatTreeControl( `expansionModel` then holds the keys rather than the nodes, and a type that names the tree names the key type after the node type: `KbqTreeSelection`, `KbqTreeSelectionChange, FileFlatNode, string>`. + +The tree's own `trackBy` input, a `TrackByFunction` of the index and the node, overrides the key the rows +are re-used by; it does not change the expansion. Without a control `trackBy` it is the only way to +re-use the rows. diff --git a/packages/components/tree/tree.ru.mdx b/packages/components/tree/tree.ru.mdx index 73a5ea6af9..a7bdbed64f 100644 --- a/packages/components/tree/tree.ru.mdx +++ b/packages/components/tree/tree.ru.mdx @@ -135,13 +135,10 @@ данные поиском. По умолчанию узлы отслеживаются по идентичности, поэтому источник данных, который пересоздаёт объекты -узлов на каждой эмиссии — например, flattener, — пересоздаёт и все строки. Чтобы строки -переиспользовались между обновлениями данных, передайте функцию `trackBy`, возвращающую стабильный ключ. - -Раскрытые узлы контрол дерева тоже отслеживает по идентичности, поэтому пересозданный узел снова -оказывается закрытым. Передайте контролу собственный `trackBy` в опциях — последнем аргументе -`FlatTreeControl` и `NestedTreeControl`. Он принимает только узел и возвращает ключ, по которому узел -раскрывается: +узлов на каждой эмиссии — например, flattener, — пересоздаёт и все строки, а пересозданный узел снова +оказывается закрытым. Передайте контролу дерева `trackBy` в опциях — последнем аргументе +`FlatTreeControl` и `NestedTreeControl`. Он принимает только узел и возвращает стабильный ключ: по этому +ключу узел раскрывается, а дерево переиспользует строку с тем же ключом между обновлениями данных: ```ts treeControl = new FlatTreeControl( @@ -159,3 +156,7 @@ treeControl = new FlatTreeControl( Тогда `expansionModel` хранит ключи, а не узлы, а тип, в котором названо дерево, указывает тип ключа после типа узла: `KbqTreeSelection`, `KbqTreeSelectionChange, FileFlatNode, string>`. + +Собственный input дерева `trackBy` — `TrackByFunction` от индекса и узла — переопределяет ключ, по которому +переиспользуются строки; на раскрытие он не влияет. Без `trackBy` контрола только он позволяет +переиспользовать строки. From c0a1d6a752430a117492742e0d39b72dacfbf0ae Mon Sep 17 00:00:00 2001 From: Nazir T Date: Mon, 26 Oct 2026 16:53:19 +0300 Subject: [PATCH 4/4] feat(tree-select): type the projected tree by the tree control's trackBy key --- .../tree-select/tree-select.component.spec.ts | 10 +++++++++- .../tree-select/tree-select.component.ts | 17 +++++++++-------- .../components/filter-bar.api.md | 2 +- .../components/tree-select.api.md | 14 +++++++------- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/components/tree-select/tree-select.component.spec.ts b/packages/components/tree-select/tree-select.component.spec.ts index 86b6d47dbe..f7113eed65 100644 --- a/packages/components/tree-select/tree-select.component.spec.ts +++ b/packages/components/tree-select/tree-select.component.spec.ts @@ -9,6 +9,7 @@ import { InputSignalWithTransform, OnInit, Provider, + Signal, Type, effect, signal, @@ -5879,7 +5880,8 @@ describe('KbqTreeSelect', () => { }); // The types are checked by `check-spec-types`: a query that names the data node gets the model typed by it, - // the tree's included, one that does not keeps `any`. + // the tree's included, one that does not keeps `any`. One that names the key of the tree control gets the tree + // typed by it too. it('should share a selection model typed by the data node with the tree', () => { const select = fixture.componentInstance.select(); const tree = select.tree()!; @@ -5892,6 +5894,12 @@ describe('KbqTreeSelect', () => { expectTypeOf(select.selectionModel).toEqualTypeOf>(); expectTypeOf(select.selected).toEqualTypeOf(); expectTypeOf(tree.selectionModel).toEqualTypeOf>(); + expectTypeOf['tree']>().toEqualTypeOf< + Signal | undefined> + >(); + expectTypeOf['tree']>().toEqualTypeOf< + Signal | undefined> + >(); expect(tree.selectionModel).toBe(select.selectionModel); expect(select.selectionModel.selected.map(({ name }) => name).sort()).toEqual(['Downloads', 'rootNode_1']); }); diff --git a/packages/components/tree-select/tree-select.component.ts b/packages/components/tree-select/tree-select.component.ts index cf3192eef8..15bef9f8d2 100644 --- a/packages/components/tree-select/tree-select.component.ts +++ b/packages/components/tree-select/tree-select.component.ts @@ -259,7 +259,7 @@ export class KbqTreeSelectChange { ], exportAs: 'kbqTreeSelect' }) -export class KbqTreeSelect +export class KbqTreeSelect extends KbqAbstractSelect implements AfterViewChecked, @@ -319,7 +319,8 @@ export class KbqTreeSelect * Deals with the selection logic. The projected `kbq-tree-selection` shares the same instance. * * `T` is the type of the data nodes, the same as the projected tree's. A template infers it from `[clearPredicate]` - * alone, apart from the tree; a query names it: `viewChild.required>(KbqTreeSelect)`. + * alone, apart from the tree; a query names it: `viewChild.required>(KbqTreeSelect)`, and a + * select whose tree control has a `trackBy` names the key type too: `KbqTreeSelect`. */ selectionModel: SelectionModel; @@ -405,7 +406,7 @@ export class KbqTreeSelect readonly customTagTemplateRef = contentChild('kbqSelectTagContent', { read: TemplateRef }); - readonly tree = contentChild>(KbqTreeSelection); + readonly tree = contentChild>(KbqTreeSelection); readonly search = contentChild(KbqSelectSearch); @@ -467,7 +468,7 @@ export class KbqTreeSelect * Event emitted whenever the tree's `selectAllOptions()` runs — a click on the `selectAll` master * checkbox, the Ctrl/Cmd + A shortcut, or a custom `selectAllHandler` that delegates to it. */ - readonly onSelectAll = output, KbqTreeSelect>>(); + readonly onSelectAll = output, KbqTreeSelect>>(); /** Classes to be passed to the select panel. Supports the same syntax as the `[class]` binding. */ readonly panelClass = input< @@ -667,8 +668,8 @@ export class KbqTreeSelect /** @docs-private */ readonly selectAllHandlerInput = input< - ((event: KeyboardEvent, select: KbqTreeSelect) => void) | undefined, - ((event: KeyboardEvent, select: KbqTreeSelect) => void) | undefined + ((event: KeyboardEvent, select: KbqTreeSelect) => void) | undefined, + ((event: KeyboardEvent, select: KbqTreeSelect) => void) | undefined >(undefined, { alias: 'selectAllHandler', transform: kbqSelectAllHandlerTransform @@ -677,12 +678,12 @@ export class KbqTreeSelect /** * Function for handling the combination Ctrl + A (select all). By default, the internal handler is used. */ - get selectAllHandler(): (event: KeyboardEvent, select: KbqTreeSelect) => void { + get selectAllHandler(): (event: KeyboardEvent, select: KbqTreeSelect) => void { return this.selectAllHandlerInput() ?? this.defaultSelectAllHandler; } /** Function for handling the combination Ctrl + A (select all). By default, the internal handler is used. */ - private defaultSelectAllHandler(event: KeyboardEvent, select: KbqTreeSelect): void { + private defaultSelectAllHandler(event: KeyboardEvent, select: KbqTreeSelect): void { const searchInput = isInput(event) ? (event.target as HTMLInputElement) : null; if (shouldSelectSearchText(searchInput)) { diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index 1341811434..103722e3cd 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -843,7 +843,7 @@ export abstract class KbqTreeSelectPipeBase extends KbqBasePipe implements onOpen(): void; open(): void; readonly searchControl: FormControl; - readonly select: _angular_core.Signal>; + readonly select: _angular_core.Signal>; // (undocumented) protected transformer: (node: KbqTreeSelectNode, level: number, parent: KbqTreeSelectFlatNode | null) => KbqTreeSelectFlatNode; // (undocumented) diff --git a/tools/public_api_guard/components/tree-select.api.md b/tools/public_api_guard/components/tree-select.api.md index d2ea5004ab..abe3ea88e4 100644 --- a/tools/public_api_guard/components/tree-select.api.md +++ b/tools/public_api_guard/components/tree-select.api.md @@ -66,7 +66,7 @@ export const KBQ_TREE_SELECT_OPTIONS: InjectionToken>; // @public (undocumented) -export class KbqTreeSelect extends KbqAbstractSelect implements AfterViewChecked, AfterContentInit, AfterViewInit, OnDestroy, OnInit, DoCheck, ControlValueAccessor, KbqFormFieldControl, CanUpdateErrorState, KbqSiblingPopup { +export class KbqTreeSelect extends KbqAbstractSelect implements AfterViewChecked, AfterContentInit, AfterViewInit, OnDestroy, OnInit, DoCheck, ControlValueAccessor, KbqFormFieldControl, CanUpdateErrorState, KbqSiblingPopup { constructor(); readonly ariaLabel: InputSignal; readonly ariaLabelledby: InputSignal; @@ -149,7 +149,7 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterVi // (undocumented) onFocus(): void; onRemoveSelectedOption(selectedOption: KbqTreeSelectTriggerValue, $event: Event): void; - readonly onSelectAll: _angular_core.OutputEmitterRef, KbqTreeSelect>>; + readonly onSelectAll: _angular_core.OutputEmitterRef, KbqTreeSelect>>; onTouched: () => void; // (undocumented) open(): void; @@ -194,8 +194,8 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterVi readonly searchMinOptionsThreshold: _angular_core.Signal; readonly searchMinOptionsThresholdInput: InputSignal; readonly selectAll: _angular_core.InputSignalWithTransform; - get selectAllHandler(): (event: KeyboardEvent, select: KbqTreeSelect) => void; - readonly selectAllHandlerInput: _angular_core.InputSignalWithTransform<((event: KeyboardEvent, select: KbqTreeSelect) => void) | undefined, ((event: KeyboardEvent, select: KbqTreeSelect) => void) | undefined>; + get selectAllHandler(): (event: KeyboardEvent, select: KbqTreeSelect) => void; + readonly selectAllHandlerInput: _angular_core.InputSignalWithTransform<((event: KeyboardEvent, select: KbqTreeSelect) => void) | undefined, ((event: KeyboardEvent, select: KbqTreeSelect) => void) | undefined>; readonly selectAllToggle: _angular_core.InputSignalWithTransform; // (undocumented) get selected(): T | T[]; @@ -213,7 +213,7 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterVi // (undocumented) toggle(): void; // (undocumented) - readonly tree: _angular_core.Signal | undefined>; + readonly tree: _angular_core.Signal | undefined>; readonly trigger: _angular_core.Signal>; triggerFontSize: number; // (undocumented) @@ -230,9 +230,9 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterVi protected readonly viewportMargin: number; writeValue(value: any): void; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration, "kbq-tree-select", ["kbqTreeSelect"], { "hiddenItemsText": { "alias": "hiddenItemsText"; "required": false; "isSignal": true; }; "hiddenItemsTextFormatter": { "alias": "hiddenItemsTextFormatter"; "required": false; "isSignal": true; }; "panelClass": { "alias": "panelClass"; "required": false; "isSignal": true; }; "backdropClass": { "alias": "backdropClass"; "required": false; "isSignal": true; }; "ariaLabel": { "alias": "aria-label"; "required": false; "isSignal": true; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; "isSignal": true; }; "errorStateMatcher": { "alias": "errorStateMatcher"; "required": false; "isSignal": true; }; "sortComparator": { "alias": "sortComparator"; "required": false; "isSignal": true; }; "clearPredicate": { "alias": "clearPredicate"; "required": false; "isSignal": true; }; "multiline": { "alias": "multiline"; "required": false; "isSignal": true; }; "placeholder": { "alias": "placeholder"; "required": false; "isSignal": true; }; "required": { "alias": "required"; "required": false; "isSignal": true; }; "multipleInput": { "alias": "multiple"; "required": false; "isSignal": true; }; "autoSelectInput": { "alias": "autoSelect"; "required": false; "isSignal": true; }; "selectAllToggle": { "alias": "selectAllToggle"; "required": false; "isSignal": true; }; "selectAll": { "alias": "selectAll"; "required": false; "isSignal": true; }; "idInput": { "alias": "id"; "required": false; "isSignal": true; }; "hasBackdrop": { "alias": "hasBackdrop"; "required": false; "isSignal": true; }; "tabIndexInput": { "alias": "tabIndex"; "required": false; "isSignal": true; }; "disabledInput": { "alias": "disabled"; "required": false; "isSignal": true; }; "selectAllHandlerInput": { "alias": "selectAllHandler"; "required": false; "isSignal": true; }; "panelMinWidth": { "alias": "panelMinWidth"; "required": false; "isSignal": true; }; "panelWidth": { "alias": "panelWidth"; "required": false; "isSignal": true; }; "panelMaxWidth": { "alias": "panelMaxWidth"; "required": false; "isSignal": true; }; "panelMaxHeight": { "alias": "panelMaxHeight"; "required": false; "isSignal": true; }; "searchMinOptionsThresholdInput": { "alias": "searchMinOptionsThreshold"; "required": false; "isSignal": true; }; }, { "openedChangeOutput": "openedChange"; "openedOutput": "opened"; "closedOutput": "closed"; "selectionChange": "selectionChange"; "onSelectAll": "onSelectAll"; }, ["cleaner", "customTrigger", "customMatcher", "customTagTemplateRef", "tree", "search", "noOptionsMessage"], ["kbq-select-matcher, [kbq-select-matcher]", "kbq-select-trigger, [kbq-select-trigger]", "kbq-cleaner", "[kbqSelectSearch]", "[kbq-select-search-empty-result]", "kbq-select-no-options, [kbq-select-no-options]", "kbq-tree-selection", "kbq-select-footer,[kbq-tree-select-footer]"], true, [{ directive: typeof i4.KbqLocaleOverridesDirective; inputs: { "kbqLocaleOverrides": "localeOverrides"; }; outputs: {}; }]>; + static ɵcmp: _angular_core.ɵɵComponentDeclaration, "kbq-tree-select", ["kbqTreeSelect"], { "hiddenItemsText": { "alias": "hiddenItemsText"; "required": false; "isSignal": true; }; "hiddenItemsTextFormatter": { "alias": "hiddenItemsTextFormatter"; "required": false; "isSignal": true; }; "panelClass": { "alias": "panelClass"; "required": false; "isSignal": true; }; "backdropClass": { "alias": "backdropClass"; "required": false; "isSignal": true; }; "ariaLabel": { "alias": "aria-label"; "required": false; "isSignal": true; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; "isSignal": true; }; "errorStateMatcher": { "alias": "errorStateMatcher"; "required": false; "isSignal": true; }; "sortComparator": { "alias": "sortComparator"; "required": false; "isSignal": true; }; "clearPredicate": { "alias": "clearPredicate"; "required": false; "isSignal": true; }; "multiline": { "alias": "multiline"; "required": false; "isSignal": true; }; "placeholder": { "alias": "placeholder"; "required": false; "isSignal": true; }; "required": { "alias": "required"; "required": false; "isSignal": true; }; "multipleInput": { "alias": "multiple"; "required": false; "isSignal": true; }; "autoSelectInput": { "alias": "autoSelect"; "required": false; "isSignal": true; }; "selectAllToggle": { "alias": "selectAllToggle"; "required": false; "isSignal": true; }; "selectAll": { "alias": "selectAll"; "required": false; "isSignal": true; }; "idInput": { "alias": "id"; "required": false; "isSignal": true; }; "hasBackdrop": { "alias": "hasBackdrop"; "required": false; "isSignal": true; }; "tabIndexInput": { "alias": "tabIndex"; "required": false; "isSignal": true; }; "disabledInput": { "alias": "disabled"; "required": false; "isSignal": true; }; "selectAllHandlerInput": { "alias": "selectAllHandler"; "required": false; "isSignal": true; }; "panelMinWidth": { "alias": "panelMinWidth"; "required": false; "isSignal": true; }; "panelWidth": { "alias": "panelWidth"; "required": false; "isSignal": true; }; "panelMaxWidth": { "alias": "panelMaxWidth"; "required": false; "isSignal": true; }; "panelMaxHeight": { "alias": "panelMaxHeight"; "required": false; "isSignal": true; }; "searchMinOptionsThresholdInput": { "alias": "searchMinOptionsThreshold"; "required": false; "isSignal": true; }; }, { "openedChangeOutput": "openedChange"; "openedOutput": "opened"; "closedOutput": "closed"; "selectionChange": "selectionChange"; "onSelectAll": "onSelectAll"; }, ["cleaner", "customTrigger", "customMatcher", "customTagTemplateRef", "tree", "search", "noOptionsMessage"], ["kbq-select-matcher, [kbq-select-matcher]", "kbq-select-trigger, [kbq-select-trigger]", "kbq-cleaner", "[kbqSelectSearch]", "[kbq-select-search-empty-result]", "kbq-select-no-options, [kbq-select-no-options]", "kbq-tree-selection", "kbq-select-footer,[kbq-tree-select-footer]"], true, [{ directive: typeof i4.KbqLocaleOverridesDirective; inputs: { "kbqLocaleOverrides": "localeOverrides"; }; outputs: {}; }]>; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; + static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; } // @public