# Tree select

- Entry point: `@koobiq/components/tree-select`
- NgModule: `KbqTreeSelectModule`
- Page: https://koobiq.io/en/components/tree-select/overview

## Overview

Tree Select lets users search and select one or more values from hierarchical data while preserving the tree structure.

### With default parameters

**Example: Tree-select** (`tree-select-overview`)

```ts tree-select-overview-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqIconModule } from '@koobiq/components/icon';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: {
                'aria describer': {
                    'aria-describer': 'ts',
                    'aria-describer.spec': 'ts',
                    'aria-reference': 'ts',
                    'aria-reference.spec': 'ts'
                },
                'focus monitor': {
                    'focus-monitor': 'ts',
                    'focus-monitor.spec': 'ts'
                }
            }
        },
        documentation: {
            source: '',
            tools: ''
        },
        mosaic: {
            autocomplete: '',
            button: '',
            'button-toggle': '',
            index: 'ts',
            package: 'json',
            version: 'ts'
        },
        'components-dev': {
            alert: '',
            badge: ''
        },
        'koobiq-examples': '',
        'koobiq-moment-adapter': '',
        README: 'md',
        'tsconfig.build': 'json'
    },
    scripts: {
        deploy: {
            'cleanup-preview': 'ts',
            'publish-artifacts': 'sh',
            'publish-docs': 'sh',
            'publish-docs-preview': 'ts'
        },
        'tsconfig.deploy': 'json'
    },
    tests: ''
};

@Component({
    selector: 'tree-select-overview-example',
    imports: [FormsModule, KbqTreeModule, KbqTreeSelectModule, KbqIconModule],
    template: `
        <kbq-form-field>
            <kbq-tree-select [(ngModel)]="selected">
                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>

                <kbq-cleaner />
            </kbq-tree-select>
        </kbq-form-field>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectOverviewExample {
    selected = '';

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FileFlatNode): string => {
        return `${node.name} view`;
    };
}
```

### Multiple tree-select

**Example: Tree-select multiple** (`tree-select-multiple-overview`)

```ts tree-select-multiple-overview-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqIconModule } from '@koobiq/components/icon';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectChange, KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: {
                'aria describer': {
                    'aria-describer': 'ts',
                    'aria-describer.spec': 'ts',
                    'aria-reference': 'ts',
                    'aria-reference.spec': 'ts'
                },
                'focus monitor': {
                    'focus-monitor': 'ts',
                    'focus-monitor.spec': 'ts'
                }
            }
        },
        documentation: {
            source: '',
            tools: ''
        },
        mosaic: {
            autocomplete: '',
            button: '',
            'button-toggle': '',
            index: 'ts',
            package: 'json',
            version: 'ts'
        },
        'components-dev': {
            alert: '',
            badge: ''
        },
        'koobiq-examples': '',
        'koobiq-moment-adapter': '',
        README: 'md',
        'tsconfig.build': 'json'
    },
    scripts: {
        deploy: {
            'cleanup-preview': 'ts',
            'publish-artifacts': 'sh',
            'publish-docs': 'sh',
            'publish-docs-preview': 'ts'
        },
        'tsconfig.deploy': 'json'
    },
    tests: ''
};

@Component({
    selector: 'tree-select-multiple-overview-example',
    imports: [
        KbqTreeSelectModule,
        FormsModule,
        KbqTreeModule,
        KbqIconModule
    ],
    template: `
        <kbq-form-field>
            <kbq-tree-select
                [multiple]="true"
                [(ngModel)]="selected"
                (ngModelChange)="onModelChange($event)"
                (selectionChange)="onSelectionChange($event)"
            >
                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>

                <kbq-cleaner />
            </kbq-tree-select>
        </kbq-form-field>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectMultipleOverviewExample {
    selected = '';

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FileFlatNode): string => {
        return `${node.name} view`;
    };

    protected onSelectionChange($event: KbqTreeSelectChange) {
        console.log('selectionChange: ', $event);
    }

    protected onModelChange($event) {
        console.log('onModelChange: ', $event);
    }
}
```

### Reset selected value

The reset control clears everything except disabled nodes. Change that with the `clearPredicate` attribute: `[clearPredicate]="clearEverything"` and `clearEverything = () => true` clear the disabled ones too.

**Example: Tree-select cleaner with disabled nodes** (`tree-select-cleaner-with-disabled`)

```ts tree-select-cleaner-with-disabled-example.ts
import { ChangeDetectionStrategy, Component, model } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqIconModule } from '@koobiq/components/icon';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';
import {
    buildTree,
    FlatNode,
    getChildren,
    getLevel,
    getValue,
    isExpandable,
    Node,
    transformer,
    TREE_DATA
} from '../tree-select-cleaner-data';

/** Nodes the user is not allowed to deselect. */
const lockedNodes = ['Load balancer'];

@Component({
    selector: 'tree-select-cleaner-with-disabled-example',
    imports: [FormsModule, KbqIconModule, KbqTreeModule, KbqTreeSelectModule],
    template: `
        <kbq-form-field>
            <kbq-tree-select multiple placeholder="Kept" [(ngModel)]="kept">
                <kbq-tree-selection [dataSource]="keptDataSource" [treeControl]="keptTreeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding [disabled]="isLocked(node)">
                        {{ keptTreeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="keptTreeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ keptTreeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>

                <kbq-cleaner />
            </kbq-tree-select>
        </kbq-form-field>

        <kbq-form-field>
            <kbq-tree-select multiple placeholder="Cleared" [clearPredicate]="clearEverything" [(ngModel)]="cleared">
                <kbq-tree-selection [dataSource]="clearedDataSource" [treeControl]="clearedTreeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding [disabled]="isLocked(node)">
                        {{ clearedTreeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="clearedTreeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ clearedTreeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>

                <kbq-cleaner />
            </kbq-tree-select>
        </kbq-form-field>
    `,
    styles: `
        :host {
            display: flex;
            justify-content: center;
            gap: var(--kbq-size-l);
            padding: var(--kbq-size-l);
        }

        .kbq-form-field {
            width: 240px;
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectCleanerWithDisabledExample {
    protected readonly kept = model(['Database', 'Load balancer', 'Message queue']);
    protected readonly cleared = model(['Database', 'Load balancer', 'Message queue']);

    // Each tree-select needs its own control and data source: they share neither selection nor expansion.
    protected readonly keptTreeControl = new FlatTreeControl<FlatNode>(getLevel, isExpandable, getValue, getValue);
    protected readonly clearedTreeControl = new FlatTreeControl<FlatNode>(getLevel, isExpandable, getValue, getValue);

    protected readonly keptDataSource = this.createDataSource(this.keptTreeControl);
    protected readonly clearedDataSource = this.createDataSource(this.clearedTreeControl);

    /** Opts out of the default, which leaves the disabled nodes selected. */
    protected readonly clearEverything = (_node: FlatNode) => true;

    protected isLocked(node: FlatNode): boolean {
        return lockedNodes.includes(node.name);
    }

    protected hasChild(_: number, node: FlatNode): boolean {
        return node.expandable;
    }

    private createDataSource(treeControl: FlatTreeControl<FlatNode>): KbqTreeFlatDataSource<Node, FlatNode> {
        const dataSource = new KbqTreeFlatDataSource(
            treeControl,
            new KbqTreeFlattener(transformer, getLevel, isExpandable, getChildren)
        );

        dataSource.data = buildTree(TREE_DATA);

        return dataSource;
    }
}
```

```ts ../tree-select-cleaner-data.ts
/** Keeps the tree plumbing out of the cleaner example, which is about the reset control. */

export class Node {
    name: string;
    children: Node[];
}

/** Flat node with expandable and level information. */
export class FlatNode {
    name: string;
    level: number;
    expandable: boolean;
    parent: FlatNode | null;
}

/** A shallow service tree: every node the examples disable sits at the top level, where it is rendered. */
export const TREE_DATA = {
    Servers: {
        'web-01': null,
        'web-02': null
    },
    Database: null,
    'Load balancer': null,
    'Message queue': null
};

export const buildTree = (value: object): Node[] =>
    Object.entries(value).map(([name, children]) => {
        const node = new Node();

        node.name = name;

        if (children) {
            node.children = buildTree(children);
        }

        return node;
    });

export const getLevel = (node: FlatNode): number => node.level;

export const isExpandable = (node: FlatNode): boolean => node.expandable;

export const getChildren = (node: Node): Node[] => node.children;

export const getValue = (node: FlatNode): string => node.name;

export const transformer = (node: Node, level: number, parent: FlatNode | null): FlatNode => {
    const flatNode = new FlatNode();

    flatNode.name = node.name;
    flatNode.level = level;
    flatNode.parent = parent;
    flatNode.expandable = !!node.children;

    return flatNode;
};
```

For a node inside a collapsed branch the `[disabled]` attribute is not taken into account: its option is not
rendered. Such a node is made inactive through `treeControl.isDisabled`.

### Multiline tree-select (multiline)

**Example: tree-select-with-multiline-matcher** (`tree-select-with-multiline-matcher`)

```ts tree-select-with-multiline-matcher-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqIconModule } from '@koobiq/components/icon';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: {
                'aria describer': {
                    'aria-describer': 'ts',
                    'aria-describer.spec': 'ts',
                    'aria-reference': 'ts',
                    'aria-reference.spec': 'ts'
                },
                'focus monitor': {
                    'focus-monitor': 'ts',
                    'focus-monitor.spec': 'ts'
                }
            }
        },
        documentation: {
            source: '',
            tools: ''
        },
        mosaic: {
            autocomplete: '',
            button: '',
            'button-toggle': '',
            index: 'ts',
            package: 'json',
            version: 'ts'
        },
        'components-dev': {
            alert: '',
            badge: ''
        },
        'koobiq-examples': '',
        'koobiq-moment-adapter': '',
        README: 'md',
        'tsconfig.build': 'json'
    },
    scripts: {
        deploy: {
            'cleanup-preview': 'ts',
            'publish-artifacts': 'sh',
            'publish-docs': 'sh',
            'publish-docs-preview': 'ts'
        },
        'tsconfig.deploy': 'json'
    },
    tests: ''
};

@Component({
    selector: 'tree-select-with-multiline-matcher-example',
    imports: [FormsModule, KbqTreeModule, KbqTreeSelectModule, KbqIconModule],
    template: `
        <kbq-form-field>
            <kbq-tree-select [multiline]="true" [(ngModel)]="selected">
                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>

                <kbq-cleaner />
            </kbq-tree-select>
        </kbq-form-field>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectWithMultilineMatcherExample {
    selected = ['button-toggle', 'button', 'autocomplete', 'documentation', 'tools', 'source'];

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FileFlatNode): string => {
        return `${node.name} view`;
    };
}
```

A multiline trigger grows with every selected option, so a long selection can make it taller than the dropdown
itself. When that happens and the panel fits neither below the trigger nor above it, the panel is anchored to
the trigger's first row and drawn over the rest of them, opening one gap below that row. The first row, the
chevron and the cleaner stay visible and clickable.

### Multiple with child selection

**Example: Tree-select child selection** (`tree-select-child-selection-overview`)

```ts tree-select-child-selection-overview-example.ts
import { ChangeDetectionStrategy, Component, viewChild } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { KbqIconModule } from '@koobiq/components/icon';
import {
    FlatTreeControl,
    KbqTreeFlatDataSource,
    KbqTreeFlattener,
    KbqTreeModule,
    KbqTreeOption,
    KbqTreeSelection
} from '@koobiq/components/tree';
import { KbqTreeSelect, KbqTreeSelectChange, KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: {
                'aria describer': {
                    'aria-describer': 'ts',
                    'aria-describer.spec': 'ts',
                    'aria-reference': 'ts',
                    'aria-reference.spec': 'ts'
                },
                'focus monitor': {
                    'focus-monitor': 'ts',
                    'focus-monitor.spec': 'ts'
                }
            }
        },
        documentation: {
            source: '',
            tools: ''
        },
        mosaic: {
            autocomplete: '',
            button: '',
            'button-toggle': '',
            index: 'ts',
            package: 'json',
            version: 'ts'
        },
        'components-dev': {
            alert: '',
            badge: ''
        },
        'koobiq-examples': '',
        'koobiq-moment-adapter': '',
        README: 'md',
        'tsconfig.build': 'json'
    },
    scripts: {
        deploy: {
            'cleanup-preview': 'ts',
            'publish-artifacts': 'sh',
            'publish-docs': 'sh',
            'publish-docs-preview': 'ts'
        },
        'tsconfig.deploy': 'json'
    },
    tests: ''
};

@Component({
    selector: 'tree-select-child-selection-overview-example',
    imports: [ReactiveFormsModule, KbqTreeSelectModule, KbqTreeModule, KbqIconModule],
    template: `
        <kbq-form-field>
            <kbq-tree-select [formControl]="control" [multiple]="true" (selectionChange)="onSelectionChange($event)">
                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding [checkboxThirdState]="true">
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option
                        *kbqTreeNodeDef="let node; when: hasChild"
                        kbqTreeNodePadding
                        [checkboxThirdState]="true"
                    >
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>

                <kbq-cleaner />
            </kbq-tree-select>
        </kbq-form-field>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectChildSelectionOverviewExample {
    readonly select = viewChild.required(KbqTreeSelect);
    readonly tree = viewChild.required(KbqTreeSelection);

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    control = new FormControl(['Downloads', 'rootNode_1']);

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);
        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    onSelectionChange($event: KbqTreeSelectChange) {
        const option: KbqTreeOption = $event.value;

        if (option.isExpandable) {
            this.tree().setStateChildren(option, !option.selected);
        }

        this.toggleParents($event.value.data.parent);
    }

    private toggleParents(parent) {
        if (!parent) {
            return;
        }

        const descendants = this.treeControl.getDescendants(parent);
        const isParentSelected = this.select().selectionModel.selected.includes(parent);

        if (!isParentSelected && descendants.every((d: any) => this.select().selectionModel.selected.includes(d))) {
            this.select().selectionModel.select(parent);
            this.toggleParents(parent.parent);
        } else if (isParentSelected) {
            this.select().selectionModel.deselect(parent);
            this.toggleParents(parent.parent);
        }
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FileFlatNode): string => {
        return `${node.name} view`;
    };
}
```

### Counter of the hidden items

When the selected nodes do not fit on one line, the ones that are left out are replaced with a counter. Its text follows the active locale (`select.hiddenItemsText`) and can be overridden with the `hiddenItemsText` attribute — a value set there wins over the locale and survives a locale change. The number placeholder inside the text is replaced with the number of hidden items:

```ts
@Component({
    template: `
        <kbq-form-field>
            <kbq-tree-select multiple [hiddenItemsText]="hiddenItemsText">...</kbq-tree-select>
        </kbq-form-field>
    `
})
class Example {
    readonly hiddenItemsText = 'and {{ number }} more';
}
```

`hiddenItemsTextFormatter` is the function that performs the substitution, `(hiddenItemsText: string, hiddenItems: number) => string`. Replace it to build the label differently.

The counter is only rendered in multiple selection mode and only while `multiline` is off — a multiline trigger wraps the tags instead of hiding them.

### With search

**Example: Tree-select search** (`tree-select-search-overview`)

```ts tree-select-search-overview-example.ts
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { KbqHighlightModule } from '@koobiq/components/core';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqInputModule } from '@koobiq/components/input';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: {
                'aria describer': {
                    'aria-describer': 'ts',
                    'aria-describer.spec': 'ts',
                    'aria-reference': 'ts',
                    'aria-reference.spec': 'ts'
                },
                'focus monitor': {
                    'focus-monitor': 'ts',
                    'focus-monitor.spec': 'ts'
                }
            }
        },
        documentation: {
            source: '',
            tools: ''
        },
        mosaic: {
            autocomplete: '',
            button: '',
            'button-toggle': '',
            index: 'ts',
            package: 'json',
            version: 'ts'
        },
        'components-dev': {
            alert: '',
            badge: ''
        },
        'koobiq-examples': '',
        'koobiq-moment-adapter': '',
        README: 'md',
        'tsconfig.build': 'json'
    },
    scripts: {
        deploy: {
            'cleanup-preview': 'ts',
            'publish-artifacts': 'sh',
            'publish-docs': 'sh',
            'publish-docs-preview': 'ts'
        },
        'tsconfig.deploy': 'json'
    },
    tests: ''
};

@Component({
    selector: 'tree-select-search-overview-example',
    imports: [
        KbqTreeSelectModule,
        FormsModule,
        ReactiveFormsModule,
        KbqIconModule,
        KbqInputModule,
        KbqTreeModule,
        KbqHighlightModule
    ],
    template: `
        <kbq-form-field>
            <kbq-tree-select [multiple]="true" [(ngModel)]="selected">
                <kbq-form-field noBorders kbqSelectSearch>
                    <i kbq-icon="kbq-magnifying-glass_16" kbqPrefix></i>
                    <input kbqInput type="text" [formControl]="searchControl" />
                    <kbq-cleaner />
                </kbq-form-field>

                <div kbq-select-search-empty-result>Ничего не найдено</div>

                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        <span
                            [innerHTML]="
                                treeControl.getViewValue(node) | kbqHighlightBackground: treeControl.filterValue.value
                            "
                        ></span>
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        <span
                            [innerHTML]="
                                treeControl.getViewValue(node) | kbqHighlightBackground: treeControl.filterValue.value
                            "
                        ></span>
                    </kbq-tree-option>
                </kbq-tree-selection>

                <kbq-cleaner />
            </kbq-tree-select>
        </kbq-form-field>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectSearchOverviewExample implements OnInit {
    selected = '';

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    searchControl: FormControl = new FormControl();

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
    }

    ngOnInit(): void {
        this.searchControl.valueChanges.subscribe((value) => this.treeControl.filterNodes(value));
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FileFlatNode): string => {
        return `${node.name} view`;
    };
}
```

### Select all

In multiple selection mode all the nodes can be selected at once. The feature is off by default — turn it on with the `selectAll` attribute, and a master checkbox appears above the tree.

```html
<kbq-tree-select multiple selectAll>
    <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
        <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
            {{ treeControl.getViewValue(node) }}
        </kbq-tree-option>
    </kbq-tree-selection>
</kbq-tree-select>
```

The checkbox has three states: unchecked, indeterminate, and checked — clicking it while indeterminate selects the remaining nodes rather than clearing the selection. Without a search query it covers the whole data set, collapsed branches included; while a query is active it covers only the nodes on screen — the matches and the ancestors kept visible for them. Disabled nodes are ignored. The row takes part in keyboard navigation as the first item of the tree, and every action on it emits `onSelectAll`. The label comes from the locale (`select.selectAll`).

**Example: Tree-select select all** (`tree-select-select-all`)

```ts tree-select-select-all-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { KbqIconModule } from '@koobiq/components/icon';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: 'ts',
            'focus monitor': 'ts'
        },
        scripts: {
            deploy: '',
            build: ''
        }
    },
    tests: ''
};

@Component({
    selector: 'tree-select-select-all-example',
    imports: [
        KbqTreeSelectModule,
        KbqTreeModule,
        KbqIconModule
    ],
    template: `
        <kbq-form-field>
            <kbq-tree-select selectAll placeholder="Placeholder" [multiple]="true">
                <kbq-cleaner />

                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>
            </kbq-tree-select>
        </kbq-form-field>
    `,
    styles: `
        :host {
            display: flex;
            justify-content: center;
            padding: var(--kbq-size-l);
        }

        .kbq-form-field-type-select {
            width: 320px;
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectSelectAllExample {
    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };
}
```

Once everything is selected, the control can show a label of its own instead of literally listing the selected nodes. Project a `<kbq-select-trigger>` while the tree's `allOptionsSelected` is `true` — without it the tree-select falls back to its default trigger. Use the element (or an element carrying the `kbq-select-trigger` attribute) rather than an `<ng-container>`: the trigger is stretched to the full width of the control by a rule on that element, and an `ng-container` leaves nothing for it to apply to, so the label and the arrow end up bunched together.

**Example: Tree-select select all label** (`tree-select-select-all-label`)

```ts tree-select-select-all-label-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqIconModule } from '@koobiq/components/icon';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: 'ts',
            'focus monitor': 'ts'
        },
        scripts: {
            deploy: '',
            build: ''
        }
    },
    tests: ''
};

@Component({
    selector: 'tree-select-select-all-label-example',
    imports: [
        KbqTreeSelectModule,
        KbqTreeModule,
        KbqIconModule,
        FormsModule
    ],
    template: `
        <kbq-form-field>
            <kbq-tree-select selectAll placeholder="Placeholder" [multiple]="true" [ngModel]="selected">
                <!-- Projected only while every node is selected, so the rest of the time the tree-select
                     falls back to its default trigger and lists the selected nodes as usual. -->
                @if (tree.allOptionsSelected) {
                    <kbq-select-trigger>All nodes</kbq-select-trigger>
                }

                <kbq-tree-selection #tree="kbqTreeSelection" [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>
            </kbq-tree-select>
        </kbq-form-field>
    `,
    styles: `
        :host {
            display: flex;
            justify-content: center;
            padding: var(--kbq-size-l);
        }

        .kbq-form-field-type-select {
            width: 320px;
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectSelectAllLabelExample {
    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    /** Every node of `DATA_OBJECT`, so the example starts in the "all selected" state. */
    readonly selected: string[];

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
        this.selected = this.treeControl.dataNodes.map(this.getValue);
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };
}
```

### Selecting everything from the keyboard

`Ctrl`/`Cmd` + `A` selects all nodes in multiple selection mode. By default a repeated press keeps them selected; `selectAllToggle` makes it deselect them instead. With `selectAll` the shortcut always toggles both ways, so it and the master checkbox cannot disagree.

Inside a non-empty search field the first press selects the text of the field; the next one falls through to the nodes. The behaviour can be replaced wholesale with the `selectAllHandler` input.

### Lazy loading tree-select

**Example: Tree-select lazyload** (`tree-select-lazyload`)

```ts tree-select-lazyload-example.ts
import { SelectionChange } from '@angular/cdk/collections';
import { ChangeDetectionStrategy, Component, Injectable, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqProgressSpinnerModule } from '@koobiq/components/progress-spinner';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';
import { BehaviorSubject, Observable, timer } from 'rxjs';

interface INodeResponse {
    id: string;
    name: string;
    hasChildren: boolean;
}

class FlatNode {
    id: string;
    name: string;
    expandable: boolean;
    level: number;
    parent: FlatNode;
    loading: boolean;
}

class LazyLoadNode {
    childrenChange = new BehaviorSubject<LazyLoadNode[]>([]);

    get children(): LazyLoadNode[] {
        return this.childrenChange.value;
    }

    constructor(
        public id: string,
        public name: string,
        public hasChildren = false,
        public loading = false,
        public loaded: boolean = false
    ) {}
}

@Injectable()
export class LazyLoadDataService {
    dataChange = new BehaviorSubject<LazyLoadNode[]>([]);
    nodeMap = new Map<string, LazyLoadNode>();

    initialize(): void {
        // example with http-request
        /*this.http.get<INodeResponse[]>('/api/getTree').subscribe((data) => {
            this.dataChange.next(
                data.map((nodeResponse: INodeResponse) => {
                        const result = new LazyLoadNode(nodeResponse.id, nodeResponse.name, nodeResponse.hasChildren);
                        this.nodeMap.set(nodeResponse.id, result);

                        return result;
                    }
                )
            );
        });*/

        this.dataChange.next(
            Array(10)
                .fill({})
                .map((value, index) => {
                    const id = index.toString();

                    return {
                        ...value,
                        hasChildren: !(index % 2),
                        name: `node-${id}`,
                        id
                    };
                })
                .map((nodeResponse: INodeResponse) => {
                    const result = new LazyLoadNode(nodeResponse.id, nodeResponse.name, nodeResponse.hasChildren);

                    this.nodeMap.set(nodeResponse.id, result);

                    return result;
                })
        );
    }

    loadChildren(id: string): void {
        const parent = this.nodeMap.get(id)!;

        if (parent.loading || (parent.hasChildren && parent.children.length)) {
            return;
        }

        parent.loading = true;
        this.nodeMap.set(parent.id, parent);
        this.dataChange.next(this.dataChange.value);

        // example with http-request
        /* this.http.get<INodeResponse[]>(`/api/getChildren/${ id }`)
            .pipe(
                finalize(() => {
                    parent.loading = false;
                    this.nodeMap.set(parent.id, parent);
                    this.dataChange.next(this.dataChange.value);
                })
            )
            .subscribe((data) => {
                const children = data.map((nodeResponse: INodeResponse) => {
                        const result = new LazyLoadNode(nodeResponse.id, nodeResponse.name, nodeResponse.hasChildren);
                        this.nodeMap.set(nodeResponse.id, result);

                        return result;
                    }
                );
                parent.loaded = true;
                parent.childrenChange.next(children);
            });
        */

        timer(5000).subscribe(() => {
            parent.childrenChange.next(
                Array(5)
                    .fill({})
                    .map((value, index) => {
                        const childId = `${parent.id}-${index}`.toString();

                        return {
                            ...value,
                            hasChildren: !(index % 2),
                            name: `node-${childId}`,
                            id: childId
                        };
                    })
                    .map((nodeResponse: INodeResponse) => {
                        const result = new LazyLoadNode(nodeResponse.id, nodeResponse.name, nodeResponse.hasChildren);

                        this.nodeMap.set(nodeResponse.id, result);

                        return result;
                    })
            );
            parent.loading = false;
            parent.loaded = true;
            this.nodeMap.set(parent.id, parent);
            this.dataChange.next(this.dataChange.value);
        });
    }
}

export class LazyLoadDataSource<T, F> extends KbqTreeFlatDataSource<T, F> {
    constructor(
        treeControl: FlatTreeControl<F>,
        treeFlattener: KbqTreeFlattener<T, F>,
        private dataService: LazyLoadDataService
    ) {
        super(treeControl, treeFlattener, []);
    }

    expansionHandler(change: SelectionChange<F>): F[] {
        if (change?.added?.length) {
            this.dataService.loadChildren((change.added[0] as any).id);
        }

        return super.expansionHandler(change);
    }
}

@Component({
    selector: 'tree-select-lazyload-example',
    imports: [
        KbqTreeSelectModule,
        FormsModule,
        KbqTreeModule,
        KbqIconModule,
        KbqProgressSpinnerModule
    ],
    template: `
        <kbq-form-field>
            <kbq-tree-select [(ngModel)]="selected">
                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        @if (node.loading) {
                            <kbq-progress-spinner mode="indeterminate" />
                        }
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>

                <kbq-cleaner />
            </kbq-tree-select>
        </kbq-form-field>
    `,
    providers: [LazyLoadDataService],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectLazyloadExample {
    private dataService = inject(LazyLoadDataService);

    selected = '';
    treeControl: FlatTreeControl<FlatNode>;
    treeFlattener: KbqTreeFlattener<LazyLoadNode, FlatNode>;

    dataSource: LazyLoadDataSource<LazyLoadNode, FlatNode>;

    nodeMap = new Map<string, FlatNode>();

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue
        );
        this.dataSource = new LazyLoadDataSource(this.treeControl, this.treeFlattener, this.dataService);

        this.dataSource.data = [];

        this.dataService.dataChange.subscribe((data: LazyLoadNode[]) => {
            this.dataSource.data = data;
        });

        this.dataService.initialize();
    }

    hasChild(_: number, nodeData: FlatNode): boolean {
        return nodeData.expandable;
    }

    private transformer = (node: LazyLoadNode, level: number, parent: any): FlatNode => {
        const existingNode = this.nodeMap.get(node.id);

        const flatNode = new FlatNode();

        flatNode.id = node.id;
        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.level = level;
        flatNode.expandable = node.hasChildren;
        flatNode.loading = node.loading;

        if (existingNode && existingNode.id === flatNode.id) {
            return existingNode;
        }

        this.nodeMap.set(node.id, flatNode);

        if (node.loaded) {
            this.treeControl.expand(flatNode);
        }

        return flatNode;
    };

    private getLevel = (node: FlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: LazyLoadNode): Observable<LazyLoadNode[]> => {
        return node.childrenChange;
    };

    private getValue = (node: FlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FlatNode): string => {
        return node.name;
    };
}
```

### With custom footer

**Example: Tree-select footer** (`tree-select-footer-overview`)

```ts tree-select-footer-overview-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqLink } from '@koobiq/components/link';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: {
                'aria describer': {
                    'aria-describer': 'ts',
                    'aria-describer.spec': 'ts',
                    'aria-reference': 'ts',
                    'aria-reference.spec': 'ts'
                },
                'focus monitor': {
                    'focus-monitor': 'ts',
                    'focus-monitor.spec': 'ts'
                }
            }
        },
        documentation: {
            source: '',
            tools: ''
        },
        mosaic: {
            autocomplete: '',
            button: '',
            'button-toggle': '',
            index: 'ts',
            package: 'json',
            version: 'ts'
        },
        'components-dev': {
            alert: '',
            badge: ''
        },
        'koobiq-examples': '',
        'koobiq-moment-adapter': '',
        README: 'md',
        'tsconfig.build': 'json'
    },
    scripts: {
        deploy: {
            'cleanup-preview': 'ts',
            'publish-artifacts': 'sh',
            'publish-docs': 'sh',
            'publish-docs-preview': 'ts'
        },
        'tsconfig.deploy': 'json'
    },
    tests: ''
};

@Component({
    selector: 'tree-select-footer-overview-example',
    imports: [KbqTreeSelectModule, FormsModule, KbqTreeModule, KbqIconModule, KbqLink],
    template: `
        <kbq-form-field>
            <kbq-tree-select [(ngModel)]="selected">
                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>

                <kbq-cleaner />

                <kbq-select-footer>
                    <a href="https://koobiq.io/en/components/tree-select" kbqLink external target="_blank">Ссылка</a>
                </kbq-select-footer>
            </kbq-tree-select>
        </kbq-form-field>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectFooterOverviewExample {
    selected = '';

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FileFlatNode): string => {
        return `${node.name} view`;
    };
}
```

### Accessibility

The control is a `role="combobox"`: it reports `aria-expanded`, points `aria-controls` at the dropdown while it is open, and mirrors its validity and its required state into `aria-invalid`/`aria-required`. Opening the dropdown moves the focus into it — onto the node the keyboard is on, or into the search field when one is projected — so while the dropdown is open a screen reader reads the panel rather than the control.

A combobox has to be named, and `<label for>` only names native form controls. Inside a `kbq-form-field` the label names the control through `aria-labelledby`, which the control wires up on its own. Without a form-field label, prefer `aria-labelledby` pointing at text that is already on screen, and reach for `aria-label` when nothing on the page holds the caption. Left unnamed, the control falls back to its `placeholder`:

```html
<kbq-form-field>
    <kbq-label>Files</kbq-label>
    <kbq-tree-select>...</kbq-tree-select>
</kbq-form-field>

<kbq-form-field>
    <kbq-tree-select aria-label="Files">...</kbq-tree-select>
</kbq-form-field>

<h3 id="files-caption">Files</h3>
<kbq-form-field>
    <kbq-tree-select aria-labelledby="files-caption">...</kbq-tree-select>
</kbq-form-field>
```

From the keyboard, `Escape` and `Alt` + arrow close the dropdown and return the focus to the control, while `Tab` closes it and moves on to the next control the way it does everywhere else.

### Dropdown height

By default, the maximum height of the list is 256px, and the list scrolls once the tree does not fit. Use the `panelMaxHeight` attribute with a value in pixels to change it.

**Example: Tree-select height** (`tree-select-height`)

```ts tree-select-height-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqIconModule } from '@koobiq/components/icon';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: {
                'aria describer': {
                    'aria-describer': 'ts',
                    'aria-describer.spec': 'ts',
                    'aria-reference': 'ts',
                    'aria-reference.spec': 'ts'
                },
                'focus monitor': {
                    'focus-monitor': 'ts',
                    'focus-monitor.spec': 'ts'
                }
            }
        },
        documentation: {
            source: '',
            tools: ''
        },
        mosaic: {
            autocomplete: '',
            button: '',
            'button-toggle': '',
            index: 'ts',
            package: 'json',
            version: 'ts'
        },
        'components-dev': {
            alert: '',
            badge: ''
        },
        'koobiq-examples': '',
        'koobiq-moment-adapter': '',
        README: 'md',
        'tsconfig.build': 'json'
    },
    scripts: {
        deploy: {
            'cleanup-preview': 'ts',
            'publish-artifacts': 'sh',
            'publish-docs': 'sh',
            'publish-docs-preview': 'ts'
        },
        'tsconfig.deploy': 'json'
    },
    tests: ''
};

@Component({
    selector: 'tree-select-height-example',
    imports: [FormsModule, KbqTreeModule, KbqTreeSelectModule, KbqIconModule],
    template: `
        <kbq-form-field>
            <kbq-tree-select [panelMaxHeight]="400" [(ngModel)]="selected">
                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>
            </kbq-tree-select>
        </kbq-form-field>
    `,
    styles: `
        :host {
            display: flex;
            justify-content: center;
            padding: var(--kbq-size-l);
        }

        .kbq-form-field {
            width: 320px;
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectHeightExample {
    selected = '';

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);

        this.treeControl.expandAll();
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FileFlatNode): string => {
        return `${node.name} view`;
    };
}
```

`panelMaxHeight` caps the scrollable list. A search field and a footer are rendered beside the list, so they add to the total height of the panel. A value larger than the space left in the viewport is clipped by the overlay rather than scrolled.

For all dropdowns in a module, using the `kbqTreeSelectOptionsProvider` provider:

```ts
import { kbqTreeSelectOptionsProvider } from '@koobiq/components/tree-select';

@NgModule({
    providers: [
        kbqTreeSelectOptionsProvider({ panelMaxHeight: 400 })
    ]
})
```

For theming, the same height is available as the `--kbq-select-panel-size-max-height` token — set it on `:root` to change every panel at once, or on a class passed through `panelClass` to change a single one.

### Dropdown width

By default, the dropdown width equals the select width and grows when the list contains long text. This behavior can be changed using the `panelWidth` attribute:

- `auto` — the dropdown matches the select width, but never gets narrower than `panelMinWidth`.
- a number or a CSS value — used as an exact width, so `panelMinWidth` is not applied to it.

The dropdown never gets narrower than `panelMinWidth`, which is 200px by default. Set `panelMinWidth` to `0` to remove that limit.

Growth by content stops at 640px. The cap is soft — it never makes the dropdown narrower than the select, and never clamps an explicit `panelWidth`. Change it with `panelMaxWidth`, or globally through the `--kbq-panel-size-width-max` token.

For a specific dropdown:

```ts
@Component({
    template: `
        <kbq-form-field>
            <kbq-tree-select [panelWidth]="700">...</kbq-tree-select>
        </kbq-form-field>
    `
})
```

For all dropdowns in a module, using _Dependency Injection_ with the `kbqTreeSelectOptionsProvider` provider:

```ts
import { kbqTreeSelectOptionsProvider } from '@koobiq/components/tree-select';

@NgModule({
    providers: [
        kbqTreeSelectOptionsProvider({ panelWidth: 'auto' })
    ]
})
```

## API

### Components

#### KbqTreeSelect (component)

```ts
@Component({ selector: 'kbq-tree-select', exportAs: 'kbqTreeSelect' })
class KbqTreeSelect<T = any, K = T> extends KbqAbstractSelect implements ControlValueAccessor, KbqFormFieldControl<KbqTreeOption>, CanUpdateErrorState, KbqSiblingPopup {
    readonly hiddenItemsText = input<string | undefined>();
    readonly hiddenItemsTextFormatter = input<(hiddenItemsText: string, hiddenItems: number) => string>(
        (hiddenItemsText, hiddenItems) => hiddenItemsText.replace('{{ number }}', hiddenItems.toString())
    );
    readonly panelClass = input<| string | string[] | Set<string> | { [key: string]: any; }>();
    readonly backdropClass = input<string>(
        'cdk-overlay-transparent-backdrop'
    );
    readonly ariaLabel = input<string | null>(null, {
        alias: 'aria-label'
    });
    readonly ariaLabelledby = input<string | null>(null, {
        alias: 'aria-labelledby'
    });
    readonly errorStateMatcher = input<ErrorStateMatcher>();
    readonly sortComparator = input<KbqTreeSelectSortComparator>();
    readonly clearPredicate = input<(node: T) => boolean>(
        this.defaultOptions?.clearPredicate ?? ((node) => !this.isNodeDisabled(node))
    );
    readonly multiline = input<boolean>(false);
    readonly placeholder = input<string>();
    readonly required = input<boolean>(false);
    multiple = input<boolean>(false);
    autoSelect = input<boolean>(true);
    readonly selectAllToggle = input<boolean>(false);
    readonly selectAll = input<boolean>(false);
    readonly id = input<string | undefined>();
    readonly hasBackdrop = input<boolean>(false);
    readonly disabled = input<boolean>(false);
    selectAllHandler = input<((event: KeyboardEvent, select: KbqTreeSelect<T, K>) => void) | undefined>();
    readonly panelMinWidth = input<KbqPanelMinWidth>(
        this.defaultOptions?.panelMinWidth === undefined
            ? KBQ_PANEL_DEFAULT_MIN_WIDTH
            : this.defaultOptions.panelMinWidth
    );
    readonly panelWidth = input<KbqPanelWidth>(
        this.defaultOptions?.panelWidth ?? null
    );
    readonly panelMaxWidth = input<KbqPanelMaxWidth>(
        this.defaultOptions?.panelMaxWidth === undefined ? null : this.defaultOptions.panelMaxWidth
    );
    readonly panelMaxHeight = input<KbqPanelMaxHeight>(
        this.defaultOptions?.panelMaxHeight === undefined ? null : this.defaultOptions.panelMaxHeight
    );
    readonly searchMinOptionsThreshold = input<'auto' | number | undefined>();
    readonly selectionChange = output<KbqTreeSelectChange>();
    readonly onSelectAll = output<KbqSelectAllEvent<KbqTreeOption<T>, KbqTreeSelect<T, K>>>();
    elementRef: ElementRef<HTMLElement>;
    defaultErrorStateMatcher: ErrorStateMatcher;
    parentForm: NgForm | null;
    parentFormGroup: FormGroupDirective | null;
    ngControl: NgControl | null;
    readonly errorState: Signal<boolean>;
    controlType: string;
    readonly isNativeLabelSupported: false;
    readonly hiddenItems: WritableSignal<number>;
    triggerFontSize: number;
    selectionModel: SelectionModel<T>;
    scrollStrategy: ScrollStrategy;
    positions: ConnectedPosition[];
    readonly trigger: Signal<ElementRef<any>>;
    readonly panel: Signal<ElementRef<any> | undefined>;
    readonly customTrigger: Signal<KbqSelectTrigger | undefined>;
    readonly customMatcher: Signal<KbqSelectMatcher | undefined>;
    readonly customTagTemplateRef: Signal<TemplateRef<any> | undefined>;
    readonly tree: Signal<KbqTreeSelection<T, K> | undefined>;
    readonly search: Signal<KbqSelectSearch | undefined>;
    readonly openedChange: EventEmitter<boolean>;
    readonly openedStream: Observable<void>;
    readonly closedStream: Observable<void>;
    readonly optionSelectionChanges: Observable<KbqTreeSelectChange>;
    readonly userInteractionChanges: Observable<void>;
    readonly value: Signal<any>;
    readonly focused: Signal<boolean>;
    get multiSelection(): boolean;
    get panelOpen(): boolean;
    get isAttached(): boolean;
    isEmptySearchResult: boolean;
    triggerValues: KbqTreeSelectTriggerValue[];
    get selected(): T | T[];
    get selectedValues(): any;
    get triggerValue(): string;
    readonly empty: Signal<boolean>;
    updateErrorState(): void;
    toggle(): void;
    open(): void;
    close(): void;
    onFocus(): void;
    onBlur(): void;
    onAttached(): void;
    focus(): void;
    onRemoveSelectedOption(
        selectedOption: KbqTreeSelectTriggerValue,
        $event: Event
    ): void;
    triggerKeydownHandler(event: KeyboardEvent): void;
    panelKeydownHandler(event: KeyboardEvent): void;
}
```

- `[hiddenItemsText]`: `string | undefined` — Text of the counter of the selected items that do not fit into the trigger. `{{ number }}` is
  replaced with their number.

  Left unset, the text follows the active locale (`select.hiddenItemsText`); set, it wins over the
  locale and survives a locale change.
- `[hiddenItemsTextFormatter]`: `(hiddenItemsText: string, hiddenItems: number) => string` — Formats the counter of the items that do not fit into the trigger.
- `[panelClass]`: `| string | string[] | Set<string> | { [key: string]: any; }` — Classes to be passed to the select panel. Supports the same syntax as the `[class]` binding.
- `[aria-label]`: `string | null` — Name of the control, for when no element on the page carries it. Defaults to the placeholder.

  Prefer `aria-labelledby` and point it at visible text: a name that duplicates a visible label is one
  more string to keep in sync, and a name with no visible counterpart cannot be spoken back by voice
  control (WCAG 2.5.3).
- `[aria-labelledby]`: `string | null` — Id of the element that names the control. Defaults to the `kbq-label` of a wrapping `kbq-form-field`.

  A `role="combobox"` element takes its name from the author only, so neither the placeholder nor the
  selected values name it, and the `<label for>` of the form field names labelable elements only, which
  a custom control is not. Without a form-field label, point this at whatever visible text names the
  control.
- `[errorStateMatcher]`: `ErrorStateMatcher` — Object used to control when error messages are shown.
- `[sortComparator]`: `KbqTreeSelectSortComparator` — Function used to sort the values in a select in multiple mode, by default their order in the panel.
  Follows the same logic as `Array.prototype.sort`. Receives two selected data nodes and every data node of the
  tree in panel order, collapsed branches included — a node in a collapsed branch has no option.
- `[clearPredicate]`: `(node: T) => boolean` — Decides which selected nodes the projected `KbqCleaner` removes: return `true` to clear the node,
  `false` to keep it. Receives the data node, not the option — a node in a collapsed branch has no
  option. Disabled nodes are kept by default. Bind a stable reference — a new function on every
  change detection re-runs the predicate over the whole selection.

  Not consulted by `writeValue` / `reset()`, which always clear everything. The parameter type becomes the select's
  `T` in a template: type it with the tree's node, not with an option.
- `[multiline]`: `boolean` — Whether to use a multiline matcher or not. Default is false
- `[placeholder]`: `string` — Placeholder shown in the trigger while nothing is selected.
- `[required]`: `boolean` — Whether the select is required.
- `[multiple]`: `boolean` — Whether multiple nodes can be selected. Cannot be changed once the selection model exists.
- `[autoSelect]`: `boolean` — Whether moving the active node selects it. Always off in multiple selection.
- `[selectAllToggle]`: `boolean` — When `true`, a repeated Ctrl/Cmd+A deselects all options. Off by default (Ctrl+A only selects).
- `[selectAll]`: `boolean` — Whether to render the "select all" master checkbox above the tree. Multiple selection only.

  The row acts on the nodes the user can actually toggle — enabled and selectable. Without a search
  query that covers the whole data set, collapsed branches included; while a query is active it
  covers only the matches. Enabling it also makes Ctrl/Cmd + A a two-way toggle, so the shortcut and
  the checkbox never disagree (`selectAllToggle` is implied).
- `[id]`: `string | undefined` — Unique identifier of the select, generated when not provided.
- `[hasBackdrop]`: `boolean` — Whether the overlay panel is rendered on top of a backdrop.
- `[disabled]`: `boolean` — Whether the select is disabled. Also set by the bound form control.
- `[selectAllHandler]`: `((event: KeyboardEvent, select: KbqTreeSelect<T, K>) => void) | undefined` — Function for handling the combination Ctrl + A (select all). By default, the internal handler is used.
- `[panelMinWidth]`: `KbqPanelMinWidth` — Minimum width of the panel.
  If minWidth is larger than window width, it will be ignored.
- `[panelWidth]`: `KbqPanelWidth` — Width of the panel. If set to `auto`, the panel will match the trigger width, but will never be
  narrower than `panelMinWidth`. If set to null or an empty string, the panel will grow to match the
  longest option's text. Any other value is used as an exact width, and `panelMinWidth` is not applied.
- `[panelMaxWidth]`: `KbqPanelMaxWidth` — Maximum width of the panel in pixels. Caps how far the panel grows with its content — it never makes
  the panel narrower than the trigger, and never clamps an explicit `panelWidth`.
  When null, the `--kbq-panel-size-width-max` token applies.
- `[panelMaxHeight]`: `KbqPanelMaxHeight` — Maximum height of the panel's scrollable option list, in pixels. Applied as the
  `--kbq-select-panel-size-max-height` custom property on the panel.

  The search field and the footer sit outside the scrollable area and add to the panel's total height.
  When null, the token default (256px) applies.
- `[searchMinOptionsThreshold]`: `'auto' | number | undefined` — Controls when the search functionality is displayed based on the number of available options.

  Automatically enables search hiding if value provided, even if `defaultOptions.searchMinOptionsThreshold` is provided.
  Defaults to `undefined`.
- `[localeOverrides]`: `KbqPartialLocaleData | undefined` — Localized strings for this element and its subtree, keyed by locale section. Every key is optional at
  every depth.
- `(selectionChange)`: `KbqTreeSelectChange` — Event emitted when the selected value has been changed by the user.
- `(onSelectAll)`: `KbqSelectAllEvent<KbqTreeOption<T>, KbqTreeSelect<T, K>>` — 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.
- `errorState`: `Signal<boolean>` — Whether the component is in an error state.
- `controlType`: `string` — A name for this control that can be used by `kbq-form-field`.
- `isNativeLabelSupported`: `false` — A `kbq-tree-select` is not a native labelable element, so the form-field's `<label for>` cannot
  associate with it. The control is named through `aria-labelledby` instead.
- `hiddenItems`: `WritableSignal<number>` — Number of the selected items that do not fit into the trigger.
- `triggerFontSize`: `number` — The cached font-size of the trigger element.
- `selectionModel`: `SelectionModel<T>` — 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<Node>>(KbqTreeSelect)`, and a
  select whose tree control has a `trackBy` names the key type too: `KbqTreeSelect<Node, string>`.
- `scrollStrategy`: `ScrollStrategy` — Strategy that will be used to handle scrolling while the select panel is open.
- `positions`: `ConnectedPosition[]` — Opens the panel below the trigger, falling back to above it when it does not fit.

  A third position is appended at runtime by `updatePanelAnchor`: a multiline trigger that has grown
  taller than the panel gets it anchored to its first row and drawn over the rest of it.
- `trigger`: `Signal<ElementRef<any>>` — Trigger - is a clickable field to open select dropdown panel
- `panel`: `Signal<ElementRef<any> | undefined>` — Reference to the overlay panel element.
- `customTrigger`: `Signal<KbqSelectTrigger | undefined>` — User-supplied override of the trigger element.
- `openedChange`: `EventEmitter<boolean>` — Event emitted when the select panel has been toggled.
  Also serves as the `openedChange` member of the `KbqSiblingPopup` contract — a tooltip sharing
  this element's host reacts to it, so its emission timing (gated on `panelDoneAnimatingStream`, see
  `ngOnInit`) matters beyond this output's original consumers.
- `openedStream`: `Observable<void>` — Event emitted when the select has been opened.
- `closedStream`: `Observable<void>` — Event emitted when the select has been closed.
- `optionSelectionChanges`: `Observable<KbqTreeSelectChange>` — Combined stream of all of the child options' change events.
- `userInteractionChanges`: `Observable<void>` — Combined stream of all of the child options userInteraction events.
- `value`: `Signal<any>` — Values of the selected nodes: an array in multiple mode.
- `focused`: `Signal<boolean>` — Whether the select is focused.
- `multiSelection`: `boolean` — Whether multiple choice is enabled or not. True if multiple or multiline
- `isAttached`: `boolean` — Whether the overlay panel is currently on screen. Part of the `KbqSiblingPopup` contract.
- `empty`: `Signal<boolean>` — Whether no node is selected.
- `close()`: `void` — Closes the overlay panel and focuses the host element.
- `onBlur()`: `void` — Calls the touched callback only if the panel is closed. Otherwise, the trigger will
  "blur" to the panel when it opens, causing a false positive.
- `onAttached()`: `void` — Callback that is invoked when the overlay panel has been attached.
- `onRemoveSelectedOption()`: `void` — Removes an option from the selection through the trailing icon of its tag in the trigger.

### Classes

#### KbqTreeSelectChange (class)

Change event object that is emitted when the select value has changed.

```ts
class KbqTreeSelectChange<T = any> {
    source: KbqTreeSelect<any, any>;
    value: T;
    isUserInput: boolean;
    values?: unknown[];
    constructor(
        source: KbqTreeSelect<any, any>,
        value: T,
        isUserInput?: boolean,
        values?: unknown[]
    );
}
```

- `source`: `KbqTreeSelect<any, any>` — Select the change comes from.
- `value`: `T` — Option the change is about — a `KbqTreeOption` whenever the changed node is rendered, and the
  raw value of the node when it is not. `null` when the change is not about a single option,
  which today means the cleaner was activated; `values` then carries the nodes it removed.
- `isUserInput`: `boolean` — Whether the change was made by the user rather than written to the model.
- `values?`: `unknown[]` — Every option the change is about, when it covers more than one.
- `constructor()`
  - `source`: `KbqTreeSelect<any, any>` — Select the change comes from.
  - `value`: `T` — Option the change is about — a `KbqTreeOption` whenever the changed node is rendered, and the
    raw value of the node when it is not. `null` when the change is not about a single option,
    which today means the cleaner was activated; `values` then carries the nodes it removed.
  - `isUserInput`: `boolean` — Whether the change was made by the user rather than written to the model.
  - `values`: `unknown[] | undefined` — Every option the change is about, when it covers more than one.

### Types

#### KbqTreeSelectOptions (type)

Options for the `kbq-tree-select` that can be configured using the `KBQ_TREE_SELECT_OPTIONS` injection token.

```ts
type KbqTreeSelectOptions = Partial<{
    panelWidth: KbqPanelWidth;
    panelMinWidth: KbqPanelMinWidth;
    panelMaxWidth: KbqPanelMaxWidth;
    panelMaxHeight: KbqPanelMaxHeight;
    searchMinOptionsThreshold: 'auto' | number;
    clearPredicate: (node: any) => boolean;
}>;
```

- `panelWidth?`: `KbqPanelWidth` — Width of the panel. If set to `auto`, the panel will match the trigger width.
  If set to null or an empty string, the panel will grow to match the longest option's text.
- `panelMinWidth?`: `KbqPanelMinWidth` — Minimum width of the panel. If minWidth is larger than window width or property set to null, it will be ignored.
- `panelMaxWidth?`: `KbqPanelMaxWidth` — Maximum width of the panel. Caps growth by content only — it never overrides the trigger width or an
  explicit `panelWidth`. If null, the `--kbq-panel-size-width-max` token applies.
- `panelMaxHeight?`: `KbqPanelMaxHeight` — Maximum height of the panel's scrollable option list. Does not include the search field or the
  footer. If null, the `--kbq-select-panel-size-max-height` token applies.
- `searchMinOptionsThreshold?`: `'auto' | number` — Whether to enable hiding search by default if options is less than minimum.

  - `'auto'` uses `KBQ_SELECT_SEARCH_MIN_OPTIONS_THRESHOLD` as min value.
  - number - will enables search hiding and uses value as min.
- `clearPredicate?`: `(node: any) => boolean` — Decides which selected nodes the projected `KbqCleaner` removes. Disabled nodes are kept when this
  is not set. Overridden per instance by the `clearPredicate` attribute.

#### KbqTreeSelectSortComparator (type)

Orders the values of a tree select in multiple mode, as `Array.prototype.sort` does. Receives two selected data
nodes and every data node of the tree in panel order.

```ts
type KbqTreeSelectSortComparator<T = any> = (a: T, b: T, nodes: T[]) => number;
```

#### KbqTreeSelectTriggerValue (type)

Tree select trigger value type.

```ts
type KbqTreeSelectTriggerValue = {
    disabled: boolean;
    value: string;
    viewValue: string;
};
```

### Constants

#### KBQ_TREE_SELECT_OPTIONS (const)

Injection token that can be used to provide the default options for the `kbq-tree-select`.

```ts
const KBQ_TREE_SELECT_OPTIONS: InjectionToken<KbqTreeSelectOptions>;
```

#### kbqTreeSelectOptionsProvider (const)

Utility provider for `KBQ_TREE_SELECT_OPTIONS`.

```ts
const kbqTreeSelectOptionsProvider: (
    options: KbqTreeSelectOptions
) => Provider;
```

## Examples

### kbq-select-trigger example

kbq-select-trigger - allows you to override the block that displays the selected value. Without affecting the placeholder, cleaner and icon.

**Example: tree-select-custom-trigger** (`tree-select-custom-trigger`)

```ts tree-select-custom-trigger-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import { KbqIconModule } from '@koobiq/components/icon';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: {
                'aria describer': {
                    'aria-describer': 'ts',
                    'aria-describer.spec': 'ts',
                    'aria-reference': 'ts',
                    'aria-reference.spec': 'ts'
                },
                'focus monitor': {
                    'focus-monitor': 'ts',
                    'focus-monitor.spec': 'ts'
                }
            }
        },
        documentation: {
            source: '',
            tools: ''
        },
        mosaic: {
            autocomplete: '',
            button: '',
            'button-toggle': '',
            index: 'ts',
            package: 'json',
            version: 'ts'
        },
        'components-dev': {
            alert: '',
            badge: ''
        },
        'koobiq-examples': '',
        'koobiq-moment-adapter': '',
        README: 'md',
        'tsconfig.build': 'json'
    },
    scripts: {
        deploy: {
            'cleanup-preview': 'ts',
            'publish-artifacts': 'sh',
            'publish-docs': 'sh',
            'publish-docs-preview': 'ts'
        },
        'tsconfig.deploy': 'json'
    },
    tests: ''
};

@Component({
    selector: 'tree-select-custom-trigger-example',
    imports: [FormsModule, KbqTreeModule, KbqTreeSelectModule, KbqIconModule, KbqButtonModule],
    template: `
        <kbq-tree-select #select="kbqTreeSelect" [(ngModel)]="selected">
            <ng-container kbq-select-trigger>custom trigger {{ select.triggerValue }}</ng-container>

            <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                    {{ treeControl.getViewValue(node) }}
                </kbq-tree-option>

                <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                    <i
                        kbq-icon="kbq-chevron-down-s_16"
                        kbqTreeNodeToggle
                        [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                    ></i>
                    {{ treeControl.getViewValue(node) }}
                </kbq-tree-option>
            </kbq-tree-selection>
        </kbq-tree-select>
    `,
    styles: `
        :host {
            display: flex;
            justify-content: center;
            padding: var(--kbq-size-l);
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectCustomTriggerExample {
    selected = 'docs';

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FileFlatNode): string => {
        return `${node.name} view`;
    };
}
```

### kbq-select-matcher example

kbq-select-matcher - allows you to replace the entire contents of kbq-select, including placeholder, cleaner and icon

**Example: tree-select-custom-matcher** (`tree-select-custom-matcher`)

```ts tree-select-custom-matcher-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import { KbqIconModule } from '@koobiq/components/icon';
import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule } from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: {
                'aria describer': {
                    'aria-describer': 'ts',
                    'aria-describer.spec': 'ts',
                    'aria-reference': 'ts',
                    'aria-reference.spec': 'ts'
                },
                'focus monitor': {
                    'focus-monitor': 'ts',
                    'focus-monitor.spec': 'ts'
                }
            }
        },
        documentation: {
            source: '',
            tools: ''
        },
        mosaic: {
            autocomplete: '',
            button: '',
            'button-toggle': '',
            index: 'ts',
            package: 'json',
            version: 'ts'
        },
        'components-dev': {
            alert: '',
            badge: ''
        },
        'koobiq-examples': '',
        'koobiq-moment-adapter': '',
        README: 'md',
        'tsconfig.build': 'json'
    },
    scripts: {
        deploy: {
            'cleanup-preview': 'ts',
            'publish-artifacts': 'sh',
            'publish-docs': 'sh',
            'publish-docs-preview': 'ts'
        },
        'tsconfig.deploy': 'json'
    },
    tests: ''
};

@Component({
    selector: 'tree-select-custom-matcher-example',
    imports: [FormsModule, KbqTreeModule, KbqTreeSelectModule, KbqIconModule, KbqButtonModule],
    template: `
        <kbq-tree-select
            #select="kbqTreeSelect"
            [class]="{ 'kbq-tree-select': false, 'my-custom-select': true }"
            [tabIndex]="-1"
            [(ngModel)]="selected"
        >
            <button kbq-button kbq-select-matcher>
                {{ select.triggerValue }}

                <i class="layout-padding-left-3xs" kbq-icon="kbq-chevron-down-s_16"></i>
            </button>

            <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                    {{ treeControl.getViewValue(node) }}
                </kbq-tree-option>

                <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                    <i
                        kbq-icon="kbq-chevron-down-s_16"
                        kbqTreeNodeToggle
                        [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                    ></i>
                    {{ treeControl.getViewValue(node) }}
                </kbq-tree-option>
            </kbq-tree-selection>
        </kbq-tree-select>
    `,
    styles: `
        :host {
            display: flex;
            justify-content: center;
            padding: var(--kbq-size-l);
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectCustomMatcherExample {
    selected = 'docs';

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FileFlatNode): string => {
        return `${node.name} view`;
    };
}
```

### Input field example

An input inside `kbq-select-matcher`. `[useDefaultHandlers]="false"` turns off the tree select's own click, keyboard and focus handlers, which would otherwise toggle the panel on every click and swallow `Space` and `Enter` typed into the input. The example opens the panel and passes the keys to it by itself.

**Example: tree-select-custom-matcher-with-input** (`tree-select-custom-matcher-with-input`)

```ts tree-select-custom-matcher-with-input-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqInput, KbqInputModule } from '@koobiq/components/input';
import {
    FlatTreeControl,
    KbqTreeFlatDataSource,
    KbqTreeFlattener,
    KbqTreeModule,
    KbqTreeOption
} from '@koobiq/components/tree';
import { KbqTreeSelect, KbqTreeSelectChange, KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: {
                'aria describer': {
                    'aria-describer': 'ts',
                    'aria-describer.spec': 'ts',
                    'aria-reference': 'ts',
                    'aria-reference.spec': 'ts'
                },
                'focus monitor': {
                    'focus-monitor': 'ts',
                    'focus-monitor.spec': 'ts'
                }
            }
        },
        documentation: {
            source: '',
            tools: ''
        },
        mosaic: {
            autocomplete: '',
            button: '',
            'button-toggle': '',
            index: 'ts',
            package: 'json',
            version: 'ts'
        },
        'components-dev': {
            alert: '',
            badge: ''
        },
        'koobiq-examples': '',
        'koobiq-moment-adapter': '',
        README: 'md',
        'tsconfig.build': 'json'
    },
    scripts: {
        deploy: {
            'cleanup-preview': 'ts',
            'publish-artifacts': 'sh',
            'publish-docs': 'sh',
            'publish-docs-preview': 'ts'
        },
        'tsconfig.deploy': 'json'
    },
    tests: ''
};

@Component({
    selector: 'tree-select-custom-matcher-with-input-example',
    imports: [FormsModule, KbqTreeModule, KbqTreeSelectModule, KbqIconModule, KbqButtonModule, KbqInputModule],
    template: `
        <kbq-form-field>
            <kbq-tree-select
                #select="kbqTreeSelect"
                [tabIndex]="null"
                [class]="{ 'kbq-tree-select': false, 'my-custom-select': true }"
                [(ngModel)]="selected"
                (closed)="input.focus()"
            >
                <div kbq-select-matcher style="width: 100%" [useDefaultHandlers]="false">
                    <input
                        #input="kbqInput"
                        autocomplete="off"
                        kbqInput
                        type="text"
                        [(ngModel)]="selected"
                        (click)="handleClick(input, select)"
                        (keydown)="handleKeydown($event, select)"
                    />
                </div>

                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>
            </kbq-tree-select>
        </kbq-form-field>
    `,
    styles: `
        :host {
            display: flex;
            justify-content: center;
            padding: var(--kbq-size-l);
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectCustomMatcherWithInputExample {
    selected = 'docs';

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
    }

    onSelectionChange($event: KbqTreeSelectChange) {
        const option: KbqTreeOption = $event.value;

        console.log(`onSelectionChange: ${option.value}`);

        this.selected = option.value;
    }

    handleClick(input: KbqInput, treeSelect: KbqTreeSelect) {
        treeSelect.toggle();
        setTimeout(() => input.focus());
        console.log('input.focus();: ');
    }

    handleKeydown(event, treeSelect: KbqTreeSelect) {
        console.log('handleKeydown: ', event);

        if (!treeSelect.disabled()) {
            if (treeSelect.panelOpen) {
                treeSelect.panelKeydownHandler(event);
            } else {
                treeSelect.triggerKeydownHandler(event);
            }
        }
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FileFlatNode): string => {
        return `${node.name} view`;
    };
}
```

### Deleted nodes example

Shows how to handle selected values whose nodes were deleted on the backend: the deleted nodes are hidden from the dropdown but stay in the selection as removable error tags, the field is shown in an error state while any deleted value is selected, and a hint explains why.

**Example: tree-select-deleted-nodes** (`tree-select-deleted-nodes`)

```ts tree-select-deleted-nodes-example.ts
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject } from '@angular/core';
import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@koobiq/components/core';
import { KbqFormFieldModule } from '@koobiq/components/form-field';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqTagsModule } from '@koobiq/components/tags';
import {
    defaultCompareValues,
    defaultCompareViewValues,
    FlatTreeControl,
    KbqTreeFlatDataSource,
    KbqTreeFlattener,
    KbqTreeModule
} from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    id: string;
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information. */
export class FileFlatNode {
    id: string;
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: FileFlatNode | null;
}

/**
 * Builds the file structure tree. Each node gets a stable, unique `id` (its path) that is used as
 * the node value/identity — unlike the display name, which may be duplicated across branches.
 */
export function buildFileTree(value: any, level: number, parentId: string = ''): FileNode[] {
    return Object.keys(value).map((key) => {
        const node = new FileNode();

        node.id = parentId ? `${parentId}/${key}` : key;
        node.name = key;

        const childValue = value[key];

        if (childValue !== null && typeof childValue === 'object') {
            node.children = buildFileTree(childValue, level + 1, node.id);
        } else {
            node.type = childValue;
        }

        return node;
    });
}

export const DATA_OBJECT = {
    Documents: {
        report: 'pdf',
        budget: 'xlsx'
    },
    Downloads: {
        Tutorial: 'html',
        November: 'pdf',
        October: 'pdf'
    },
    Applications: {
        Chrome: 'app',
        Calendar: 'app'
    }
};

@Component({
    selector: 'tree-select-deleted-nodes-example',
    imports: [
        ReactiveFormsModule,
        KbqFormFieldModule,
        KbqTreeModule,
        KbqTreeSelectModule,
        KbqTagsModule,
        KbqIconModule
    ],
    template: `
        <kbq-form-field>
            <kbq-tree-select
                multiple="true"
                multiline="true"
                [formControl]="control"
                [errorStateMatcher]="deletedNodesErrorStateMatcher"
            >
                <ng-template #kbqSelectTagContent let-option let-select="select">
                    <kbq-tag
                        [selectable]="false"
                        [disabled]="option.disabled || select.disabled()"
                        [color]="deletedIds.has(option.value) ? 'error' : ''"
                    >
                        {{ option.viewValue }}
                        <!-- remove control; hidden when the tag or the whole select is disabled -->
                        @if (!select.disabled() && !option.disabled) {
                            <i
                                kbq-icon="kbq-xmark-s_16"
                                kbqTagRemove
                                [color]="deletedIds.has(option.value) ? 'error' : ''"
                                (click)="select.onRemoveSelectedOption(option, $event)"
                            ></i>
                        }
                    </kbq-tag>
                </ng-template>

                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <!--
                        Deleted nodes (and the whole subtree of a deleted parent): an empty template, so they
                        don't render in the panel and never enter renderedOptions, yet stay in the data and
                        therefore still produce a tag in the matcher.
                    -->
                    <ng-container *kbqTreeNodeDef="let node; when: isDeleted" />

                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        {{ treeControl.getViewValue(node) }}
                    </kbq-tree-option>
                </kbq-tree-selection>
            </kbq-tree-select>

            <!-- Visible textual reason for the error state: shown even if a red tag was collapsed into the overflow counter -->
            @if (deletedCount > 0) {
                <kbq-hint>Удалено элементов: {{ deletedCount }} — удалите их из выбора</kbq-hint>
            }
        </kbq-form-field>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectDeletedNodesExample {
    private readonly changeDetectorRef = inject(ChangeDetectorRef);
    private readonly defaultErrorStateMatcher = inject(ErrorStateMatcher);

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;
    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    // Selected values, by id. Two of them reference nodes that the backend reports as deleted.
    control = new UntypedFormControl([
        'Applications/Calendar',
        'Downloads/November',
        'Downloads/October'
    ]);

    // Single source of truth for "deleted", rebuilt from the backend list on every (re)load.
    deletedIds = new Set<string>();

    // Forces the field into an error (visual) state while any deleted node is selected — this only
    // drives the displayed error, it does not change FormControl validity (no validator is added).
    // It ORs the default matcher so real Validators (required/pattern/server) are not masked; it is
    // keyed by id and clears reactively.
    deletedNodesErrorStateMatcher: ErrorStateMatcher = {
        isErrorState: (control, form): boolean =>
            this.defaultErrorStateMatcher.isErrorState(control as any, form) ||
            (Array.isArray(control?.value) && control!.value.some((id: string) => this.deletedIds.has(id)))
    };

    // The explicit list of deleted ids as it would arrive from the backend.
    private readonly backendDeletedIds = ['Downloads/November', 'Downloads/October'];

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        // The tree resolves nodes by their stable id (not the display name).
        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue,
            defaultCompareValues,
            defaultCompareViewValues
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.setTreeData(buildFileTree(DATA_OBJECT, 0));
    }

    /**
     * (Re)loads the tree. Safe to call on every backend refresh: it rebuilds `deletedIds` from the
     * backend list BEFORE assigning the data (so the node-defs read the fresh set on re-render),
     * which is why a runtime reload never "loses" the deleted state the way a per-node flag would.
     */
    setTreeData(raw: FileNode[]): void {
        this.deletedIds = new Set(this.backendDeletedIds);
        this.dataSource.data = raw;
        this.changeDetectorRef.markForCheck();
    }

    /** Count of currently-selected values that are deleted — drives the explanatory hint. */
    get deletedCount(): number {
        return ((this.control.value as string[]) || []).filter((id) => this.deletedIds.has(id)).length;
    }

    // Hide a deleted node (and the whole subtree of a deleted parent) from the dropdown.
    isDeleted = (_: number, node: FileFlatNode): boolean => this.isDeletedNode(node);

    // Expandable-but-not-deleted. Making these mutually exclusive means a deleted expandable node
    // always falls into the empty `isDeleted` def regardless of node-def declaration order.
    hasChild = (_: number, node: FileFlatNode): boolean => node.expandable && !this.isDeletedNode(node);

    private isDeletedNode(node: FileFlatNode | null): boolean {
        for (let current = node; current; current = current.parent) {
            if (this.deletedIds.has(current.id)) {
                return true;
            }
        }

        return false;
    }

    private transformer = (node: FileNode, level: number, parent: FileFlatNode | null) => {
        const flatNode = new FileFlatNode();

        flatNode.id = node.id;
        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => node.level;

    private isExpandable = (node: FileFlatNode) => node.expandable;

    private getChildren = (node: FileNode): FileNode[] => node.children;

    // Identity is the stable id, not the display name.
    private getValue = (node: FileFlatNode): string => node.id;

    private getViewValue = (node: FileFlatNode): string => node.name;
}
```

### Smart search

Search splits a multi-word query into parts and searches for them independently, trims leading and trailing spaces, is case-insensitive, and folds diacritics. The algorithm is described in the [Smart search guide](https://koobiq.io/en/other/search-smart.md).

**Example: Tree-select smart search** (`tree-select-search-smart`)

```ts tree-select-search-smart-example.ts
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { createSearchPredicate, KbqHighlightModule, tokenizeSearchQuery } from '@koobiq/components/core';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqInputModule } from '@koobiq/components/input';
import {
    defaultCompareValues,
    FlatTreeControl,
    KbqTreeFlatDataSource,
    KbqTreeFlattener,
    KbqTreeModule
} from '@koobiq/components/tree';
import { KbqTreeSelectModule } from '@koobiq/components/tree-select';

export class FileNode {
    children: FileNode[];
    name: string;
    type: any;
}

/** Flat node with expandable and level information */
export class FileFlatNode {
    name: string;
    type: any;
    level: number;
    expandable: boolean;
    parent: any;
}

/**
 * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object.
 * The return value is the list of `FileNode`.
 */
export function buildFileTree(value: any, level: number): FileNode[] {
    const data: any[] = [];

    for (const k of Object.keys(value)) {
        const v = value[k];
        const node = new FileNode();

        node.name = `${k}`;

        if (v === null || v === undefined) {
            // no action
        } else if (typeof v === 'object') {
            node.children = buildFileTree(v, level + 1);
        } else {
            node.type = v;
        }

        data.push(node);
    }

    return data;
}

export const DATA_OBJECT = {
    docs: 'app',
    src: {
        cdk: {
            a11ly: {
                'aria describer': {
                    'aria-describer': 'ts',
                    'aria-describer.spec': 'ts',
                    'aria-reference': 'ts',
                    'aria-reference.spec': 'ts'
                },
                'focus monitor': {
                    'focus-monitor': 'ts',
                    'focus-monitor.spec': 'ts'
                }
            }
        },
        documentation: {
            source: '',
            tools: ''
        },
        mosaic: {
            autocomplete: '',
            button: '',
            'button-toggle': '',
            index: 'ts',
            package: 'json',
            version: 'ts'
        },
        'components-dev': {
            alert: '',
            badge: ''
        },
        'koobiq-examples': '',
        'koobiq-moment-adapter': '',
        README: 'md',
        'tsconfig.build': 'json'
    },
    scripts: {
        deploy: {
            'cleanup-preview': 'ts',
            'publish-artifacts': 'sh',
            'publish-docs': 'sh',
            'publish-docs-preview': 'ts'
        },
        'tsconfig.deploy': 'json'
    },
    tests: ''
};

@Component({
    selector: 'tree-select-search-smart-example',
    imports: [
        KbqTreeSelectModule,
        FormsModule,
        ReactiveFormsModule,
        KbqIconModule,
        KbqInputModule,
        KbqTreeModule,
        KbqHighlightModule
    ],
    template: `
        <kbq-form-field>
            <kbq-tree-select [multiple]="true" [(ngModel)]="selected">
                <kbq-form-field noBorders kbqSelectSearch>
                    <i kbq-icon="kbq-magnifying-glass_16" kbqPrefix></i>
                    <input kbqInput type="text" [formControl]="searchControl" />
                    <kbq-cleaner />
                </kbq-form-field>

                <div kbq-select-search-empty-result>Ничего не найдено</div>

                <kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
                    <kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding>
                        <span
                            [innerHTML]="treeControl.getViewValue(node) | kbqHighlightBackground: searchTokens : true"
                        ></span>
                    </kbq-tree-option>

                    <kbq-tree-option *kbqTreeNodeDef="let node; when: hasChild" kbqTreeNodePadding>
                        <i
                            kbq-icon="kbq-chevron-down-s_16"
                            kbqTreeNodeToggle
                            [style.transform]="treeControl.isExpanded(node) ? '' : 'rotate(-90deg)'"
                        ></i>
                        <span
                            [innerHTML]="treeControl.getViewValue(node) | kbqHighlightBackground: searchTokens : true"
                        ></span>
                    </kbq-tree-option>
                </kbq-tree-selection>

                <kbq-cleaner />
            </kbq-tree-select>
        </kbq-form-field>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeSelectSearchSmartExample implements OnInit {
    selected = '';

    treeControl: FlatTreeControl<FileFlatNode>;
    treeFlattener: KbqTreeFlattener<FileNode, FileFlatNode>;

    dataSource: KbqTreeFlatDataSource<FileNode, FileFlatNode>;

    searchControl: FormControl = new FormControl();

    protected searchTokens: string[] = [];

    private lastQuery: string | null = null;
    private lastPredicate: (value: string) => boolean = () => true;

    constructor() {
        this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);

        this.treeControl = new FlatTreeControl<FileFlatNode>(
            this.getLevel,
            this.isExpandable,
            this.getValue,
            this.getViewValue,
            defaultCompareValues,
            this.compareViewValues
        );
        this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener);

        this.dataSource.data = buildFileTree(DATA_OBJECT, 0);
    }

    ngOnInit(): void {
        this.searchControl.valueChanges.subscribe((value) => this.treeControl.filterNodes(value));
    }

    hasChild(_: number, nodeData: FileFlatNode) {
        return nodeData.expandable;
    }

    /**
     * `compareViewValues` is invoked once per node on every filter pass, so both the predicate and
     * `searchTokens` are rebuilt only when the query actually changes, not for every node — the latter
     * also keeps the reference stable between change-detection passes, which the highlight pipes rely
     * on for memoization.
     */
    private compareViewValues = (viewValue: string, query: string): boolean => {
        if (query !== this.lastQuery) {
            this.lastQuery = query;
            this.lastPredicate = createSearchPredicate(query ?? '');
            this.searchTokens = tokenizeSearchQuery(query ?? '');
        }

        return this.lastPredicate(viewValue);
    };

    private transformer = (node: FileNode, level: number, parent: any) => {
        const flatNode = new FileFlatNode();

        flatNode.name = node.name;
        flatNode.parent = parent;
        flatNode.type = node.type;
        flatNode.level = level;
        flatNode.expandable = !!node.children;

        return flatNode;
    };

    private getLevel = (node: FileFlatNode) => {
        return node.level;
    };

    private isExpandable = (node: FileFlatNode) => {
        return node.expandable;
    };

    private getChildren = (node: FileNode): FileNode[] => {
        return node.children;
    };

    private getValue = (node: FileFlatNode): string => {
        return node.name;
    };

    private getViewValue = (node: FileFlatNode): string => {
        return `${node.name} view`;
    };
}
```
