# Filter Bar

- Entry point: `@koobiq/components/filter-bar`
- NgModule: `KbqFilterBarModule`
- Page: https://koobiq.io/en/components/filter-bar/overview

## Overview

A composite component for filtering data in a table or list.

**Example: filter bar** (`filter-bar-overview`)

```ts filter-bar-overview-example.ts
import { ChangeDetectionStrategy, Component, computed, linkedSignal } from '@angular/core';
import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter';
import { DateFormatter } from '@koobiq/components/core';
import {
    KbqFilter,
    KbqFilterBar,
    KbqFilterBarModule,
    KbqPipe,
    KbqPipeTemplate,
    KbqPipeTypes,
    KbqSaveFilterEvent,
    KbqSaveFilterStatuses
} from '@koobiq/components/filter-bar';
import { injectLocalizedPeriods, injectLocalizedText } from '../localized-data';

/** Text search is the first pipe in every filter: always present, never removable. */
const createSearchPipe = (name: string): KbqPipe => ({
    name,
    type: KbqPipeTypes.Input,
    value: null,

    cleanable: true,
    removable: false,
    disabled: false
});

@Component({
    selector: 'filter-bar-overview-example',
    imports: [KbqFilterBarModule, LuxonDateModule],
    template: `
        <kbq-filter-bar
            [pipeTemplates]="pipeTemplates()"
            [filter]="activeFilter()"
            (filterChange)="onFilterChange($event)"
        >
            <kbq-filters
                [filters]="filters()"
                (onSave)="onSaveFilter($event)"
                (onResetFilterChanges)="onResetFilterChanges($event)"
                (onRemoveFilter)="onDeleteFilter($event)"
            />

            @for (pipe of activeFilter()?.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }

            <kbq-pipe-add />

            @if (activeFilter()?.name !== defaultFilter()?.name || activeFilter()?.changed) {
                <kbq-filter-reset (onResetFilter)="onResetFilter()" />
            }
        </kbq-filter-bar>
    `,
    providers: [DateFormatter],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarOverviewExample {
    /**
     * The bar translates its own strings, never the data it is handed: period labels are the
     * application's to produce, and these follow the active locale.
     */
    protected readonly periods = injectLocalizedPeriods();

    /** Text the example owns: it has no counterpart in the library's locale data. */
    protected readonly text = injectLocalizedText({
        'ru-RU': { search: 'Поиск' },
        default: { search: 'Search' }
    });

    // Everything below is derived from the period labels, so it is rebuilt whenever the locale changes:
    // `*kbqPipe` builds a pipe component once from the object it is given and ignores later changes to
    // that binding, so a relabelled period only reaches the screen as a new pipe object. `linkedSignal`
    // for what the user also writes, `computed` for what is only ever read.
    readonly filters = linkedSignal(() => this.createFilters());
    readonly savedFilters = computed(() => structuredClone(this.createFilters()));

    readonly defaultFilter = computed<KbqFilter | null>(() => this.getDefaultFilter());
    readonly activeFilter = linkedSignal<KbqFilter | null>(() => this.getDefaultFilter());

    readonly pipeTemplates = computed<KbqPipeTemplate[]>(() => [
        {
            name: 'Date',
            type: KbqPipeTypes.Date,
            values: this.periods.date(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Datetime',
            type: KbqPipeTypes.Datetime,
            values: this.periods.datetime(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],

            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Text',
            type: KbqPipeTypes.Text,

            cleanable: false,
            removable: true,
            disabled: false
        }
    ]);

    onFilterChange(filter: KbqFilter | null) {
        // KbqFilterBar flips `changed` to true on any pipe edit but never back to false.
        // Re-derive it by diffing the pipes against the filter's initial (saved/default) state,
        // so reverting the pipes also clears `changed` and hides <kbq-filter-reset>.
        const initialFilter = filter ? this.getInitialFilter(filter) : null;

        this.activeFilter.set(
            filter && initialFilter && this.arePipesEqual(filter.pipes, initialFilter.pipes)
                ? { ...filter, changed: false }
                : filter
        );
    }

    onResetFilter() {
        console.log('onResetFilter: ');
        this.activeFilter.set(this.getDefaultFilter(true));
    }

    onResetFilterChanges(filter: KbqFilter | null) {
        console.log('onResetFilterChanges: ');
        const defaultFilter = this.getSavedFilter(filter);

        this.filters.update((filters) => filters.map((item) => (item.name === filter?.name ? defaultFilter : item)));

        this.activeFilter.set(defaultFilter);
    }

    onDeleteFilter(filter: KbqFilter) {
        this.filters.update((filters) => filters.filter(({ name }) => name !== filter?.name));

        this.activeFilter.set(this.getDefaultFilter());
    }

    onSaveFilter({ filter, filterBar, status }: KbqSaveFilterEvent) {
        setTimeout(() => {
            if (status === KbqSaveFilterStatuses.NewFilter) {
                this.saveNewFilter(filter, filterBar);
            } else if (status === KbqSaveFilterStatuses.NewName) {
                this.saveCurrentFilterWithNewName(filter, filterBar);
            } else if (status === KbqSaveFilterStatuses.OnlyChanges) {
                this.saveCurrentFilterWithChangesInPipes(filter, filterBar);
            }
        }, 5000);
    }

    saveNewFilter(filter: KbqFilter, filterBar: KbqFilterBar) {
        // This logic simulates the behavior of the backend
        if (!this.filters().some(({ name }) => name === filter.name)) {
            this.filters.update((filters) => [...filters, filter]);

            this.activeFilter.set(filter);

            filterBar.filters()?.filterSavedSuccessfully();
        } else {
            filterBar.filters()?.filterSavedUnsuccessfully({ nameAlreadyExists: true });
        }
    }

    saveCurrentFilterWithNewName(filter: KbqFilter, filterBar: KbqFilterBar) {
        // This logic simulates the behavior of the backend
        if (filterBar.filter()?.name === filter.name) {
            filterBar.filters()?.filterSavedUnsuccessfully({ nameAlreadyExists: true });

            return;
        }

        const currentName = filterBar.filter()?.name;

        // A filter the store does not know about cannot be renamed.
        if (!this.filters().some(({ name }) => name === currentName)) {
            filterBar.filters()?.filterSavedUnsuccessfully();

            return;
        }

        // Renaming writes the name only: the stored pipes stay as they were saved, so unsaved changes
        // in the bar are not persisted along with the new name.
        this.filters.update((filters) =>
            filters.map((item) => (item.name === currentName ? { ...item, name: filter.name } : item))
        );

        this.activeFilter.set(filter);

        filterBar.filters()?.filterSavedSuccessfully();
    }

    saveCurrentFilterWithChangesInPipes(filter: KbqFilter, filterBar: KbqFilterBar) {
        // This logic simulates the behavior of the backend
        this.filters.update((filters) => filters.map((item) => (item.name === filter.name ? filter : item)));

        this.activeFilter.set(filter);

        filterBar.filters()?.filterSavedSuccessfully();
    }

    getSavedFilter(filter: KbqFilter | null): KbqFilter {
        return structuredClone(this.savedFilters().find(({ name }) => name === filter?.name)!);
    }

    /** Pristine (saved or default) version of a filter — the baseline for change detection. */
    getInitialFilter(filter: KbqFilter): KbqFilter | null {
        return filter.name === this.defaultFilter()?.name
            ? this.defaultFilter()
            : (this.savedFilters().find(({ name }) => name === filter.name) ?? null);
    }

    /** Whether two pipe lists are equivalent (same name/type/value) — detects a return to the initial state. */
    arePipesEqual(a: KbqPipe[], b: KbqPipe[]): boolean {
        const serialize = (pipes: KbqPipe[]): string =>
            JSON.stringify(pipes.map(({ name, type, value }) => ({ name, type, value })));

        return serialize(a) === serialize(b);
    }

    createFilters(): KbqFilter[] {
        return [
            {
                name: 'Saved Filter 1',
                readonly: false,
                disabled: false,
                changed: false,
                saved: true,
                pipes: [
                    createSearchPipe(this.text().search),
                    {
                        name: 'Datetime',
                        value: this.periods.pick({ unit: 'days', amount: -7 }),
                        type: KbqPipeTypes.Datetime,

                        cleanable: false,
                        removable: false,
                        disabled: false
                    },
                    {
                        name: 'Select',
                        value: { name: 'Option 6', id: '6' },
                        type: KbqPipeTypes.Select,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    },
                    {
                        name: 'Text',
                        value: 'Angular Rules',
                        type: KbqPipeTypes.Text,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    }
                ]
            },
            {
                name: 'Saved Filter 2',
                readonly: false,
                disabled: false,
                changed: false,
                saved: true,
                pipes: [
                    createSearchPipe(this.text().search),
                    {
                        name: 'Datetime',
                        value: this.periods.pick({ unit: 'years', amount: -1 }),
                        type: KbqPipeTypes.Datetime,

                        cleanable: false,
                        removable: false,
                        disabled: false
                    },
                    {
                        name: 'MultiSelect',
                        value: [
                            { name: 'Option 1', id: '1' },
                            { name: 'Option 3', id: '3' },
                            { name: 'Option 4', id: '4' }
                        ],
                        type: KbqPipeTypes.MultiSelect,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    },
                    {
                        name: 'Date',
                        value: this.periods.pick({ unit: 'days', amount: -7 }),
                        type: KbqPipeTypes.Date,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    }
                ]
            },
            {
                name: 'Saved Filter 3',
                readonly: false,
                disabled: false,
                changed: false,
                saved: true,
                pipes: [
                    createSearchPipe(this.text().search),
                    {
                        name: 'Datetime',
                        value: this.periods.pick({ unit: 'days', amount: -3 }),
                        type: KbqPipeTypes.Datetime,

                        cleanable: false,
                        removable: false,
                        disabled: false
                    },
                    {
                        name: 'Select',
                        value: { name: 'Option 5', id: '5' },
                        type: KbqPipeTypes.Select,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    },
                    {
                        name: 'MultiSelect',
                        value: [
                            { name: 'Option 1', id: '1' },
                            { name: 'Option 2', id: '2' }
                        ],
                        type: KbqPipeTypes.MultiSelect,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    }
                ]
            }
        ];
    }

    /**
     * `openOnReset` is only for the filter the reset itself builds: the bar's reset stream stays latched
     * at `true`, so a pipe re-created for any other reason — a locale change, say — would replay it and
     * pop its panel open unprompted.
     */
    getDefaultFilter(openOnReset = false): KbqFilter {
        return {
            name: '',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [
                createSearchPipe(this.text().search),
                {
                    name: 'Datetime',
                    value: this.periods.pick({ unit: 'hours', amount: -24 }),
                    type: KbqPipeTypes.Datetime,

                    cleanable: false,
                    removable: false,
                    disabled: false,

                    openOnReset
                }
            ]
        };
    }
}
```

```ts ../localized-data.ts
import { computed, inject, Signal, untracked } from '@angular/core';
import {
    DateAdapter,
    DateFormatter,
    kbqInjectLocaleConfiguration,
    kbqInjectLocaleService
} from '@koobiq/components/core';
import { KBQ_TIME_RANGE_LOCALE_CONFIGURATION } from '@koobiq/components/time-range';

/**
 * A relative period offered by the `date` / `datetime` pipes, expressed as one negative duration
 * unit. The label is derived from it, so the examples carry no translated period names at all.
 */
export interface ExamplePeriod {
    unit: 'hours' | 'days' | 'years';
    amount: number;
}

/**
 * Value shape the `date` / `datetime` pipes read: `name` is what the chip and the period list show,
 * `start` is the offset applied to `today()` when the period is resolved to an actual range.
 */
export interface ExamplePeriodValue {
    name: string;
    start: Record<string, number>;
    end: null;
}

/** Periods offered by the `date` pipe in these examples. */
const DATE_PERIODS: ExamplePeriod[] = [
    { unit: 'days', amount: -1 },
    { unit: 'days', amount: -3 },
    { unit: 'days', amount: -7 },
    { unit: 'days', amount: -30 },
    { unit: 'days', amount: -90 },
    { unit: 'years', amount: -1 }
];

/** Periods offered by the `datetime` pipe in these examples. */
const DATETIME_PERIODS: ExamplePeriod[] = [
    { unit: 'hours', amount: -1 },
    { unit: 'hours', amount: -3 },
    { unit: 'hours', amount: -24 },
    { unit: 'days', amount: -3 },
    { unit: 'days', amount: -7 },
    { unit: 'days', amount: -30 },
    { unit: 'days', amount: -90 },
    { unit: 'years', amount: -1 }
];

/** Period lists whose labels follow the active locale. Call from an injection context. */
export interface LocalizedPeriods {
    date: Signal<ExamplePeriodValue[]>;
    datetime: Signal<ExamplePeriodValue[]>;
    /** The value of one period, for a pipe that starts with a period already selected. */
    pick: (period: ExamplePeriod) => ExamplePeriodValue;
}

/**
 * Builds the period lists of the `date` / `datetime` pipes with labels taken from the active locale.
 *
 * The filter bar translates its own strings but never the data it is handed, so labels like
 * "Last 24 hours" are the application's to produce. Rather than keeping a translation table, they are
 * formatted out of the period itself with `DateFormatter.duration()` and the duration templates of the
 * active locale — the same ones `kbq-time-range` renders its preset list with — which covers every
 * shipped locale and gets the plural forms right.
 *
 * Requires `DateAdapter` (from a date module such as `LuxonDateModule`) and `DateFormatter`; a component
 * that scopes its own locale must scope both next to it, or the labels resolve in a different locale
 * than the rest of the bar.
 */
export const injectLocalizedPeriods = (): LocalizedPeriods => {
    const adapter = inject(DateAdapter);
    const formatter = inject(DateFormatter);
    const timeRange = kbqInjectLocaleConfiguration('timeRange', KBQ_TIME_RANGE_LOCALE_CONFIGURATION);

    // The active locale is the only thing a label depends on. `adapter.today()` reads the timezone
    // signal, which would otherwise rebuild every list — and every filter derived from it — on a
    // timezone change that cannot alter a single string. One reference instant per list also keeps
    // the whole list anchored to the same moment.
    const toValues = (periods: ExamplePeriod[]): ExamplePeriodValue[] => {
        const option = timeRange().durationTemplate.option;

        return untracked(() => {
            const end = adapter.today();

            return periods.map(({ unit, amount }) => {
                const start = { [unit]: amount };

                return {
                    name: formatter.duration(adapter.addCalendarUnits(end, start), end, [unit], false, option),
                    start,
                    end: null
                };
            });
        });
    };

    return {
        date: computed(() => toValues(DATE_PERIODS)),
        datetime: computed(() => toValues(DATETIME_PERIODS)),
        // The period list highlights the selected option by comparing names, and a period always renders
        // the same name, so building the value here matches the entry of the list it stands for.
        pick: (period) => toValues([period])[0]
    };
};

/**
 * Picks the entry matching the active locale, falling back to `default`.
 *
 * For text the examples own — a pipe's name, a label in the page around the bar — that has no
 * counterpart in the library's locale data. Only the locales the record lists are translated; every
 * other one gets `default`.
 */
export const injectLocalizedText = <T>(data: Record<string, T> & { default: T }): Signal<T> => {
    const localeService = kbqInjectLocaleService({ optional: true });

    return computed(() => data[localeService?.localeId() ?? 'default'] ?? data.default);
};
```

### Operating modes

In the clear mode, filters remain on the panel after clicking the button with a cross, and in the delete mode, they disappear. Do not mix the two modes on one screen. If the buttons with the cross icon work differently for adjacent filters, this will confuse the user.

#### Cleaning

Use when the system has few filters or users often use several popular parameters. Filter buttons without selected values ​​are pre-showed on the screen, the filled filter is cleared by the cross, but the button itself does not disappear.

In the cleaning mode, you should not give the opportunity to add new filters from the menu, as this will conflict with the fact that the cross will either reset or delete the filter.

**Example: filter-bar-cleanable** (`filter-bar-cleanable`)

```ts filter-bar-cleanable-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter';
import { KbqFilterBarModule, KbqPipe, KbqPipeTemplate, KbqPipeTypes } from '@koobiq/components/filter-bar';

@Component({
    selector: 'filter-bar-cleanable-example',
    imports: [
        KbqFilterBarModule,
        LuxonDateModule
    ],
    template: `
        <kbq-filter-bar [pipeTemplates]="pipeTemplates">
            @for (pipe of pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarCleanableExample {
    pipes: KbqPipe[] = [
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            value: { name: 'Option 1', id: '1' },

            cleanable: true,
            removable: false,
            disabled: false
        },
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            value: null,

            cleanable: true,
            removable: false,
            disabled: false
        },
        {
            name: 'Text',
            type: KbqPipeTypes.Text,
            value: null,

            cleanable: true,
            removable: false,
            disabled: false
        }
    ];

    pipeTemplates: KbqPipeTemplate[] = [
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],
            cleanable: false,
            removable: false,
            disabled: false
        },
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],

            cleanable: false,
            removable: false,
            disabled: false
        }
    ];
}
```

#### Removal

In this mode, the user has the option to hide the component if there are no values ​​selected in the filter. When filled, the button with a cross resets the filter values ​​and hides it.
Filters with the ability to delete are usually hidden in the menu to avoid cluttering the screen. The user will select the desired parameter from the drop-down list, and then the corresponding element will be added to the filter panel.

**Example: filter-bar-removable** (`filter-bar-removable`)

```ts filter-bar-removable-example.ts
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter';
import { DateFormatter } from '@koobiq/components/core';
import { KbqFilter, KbqFilterBarModule, KbqPipeTemplate, KbqPipeTypes } from '@koobiq/components/filter-bar';
import { injectLocalizedPeriods } from '../localized-data';

@Component({
    selector: 'filter-bar-removable-example',
    imports: [
        KbqFilterBarModule,
        LuxonDateModule
    ],
    template: `
        <kbq-filter-bar [pipeTemplates]="pipeTemplates()" [(filter)]="activeFilter">
            @for (pipe of activeFilter?.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }

            <kbq-pipe-add />
        </kbq-filter-bar>
    `,
    providers: [DateFormatter],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarRemovableExample {
    activeFilter: KbqFilter | null = {
        name: 'Select',
        readonly: false,
        disabled: false,
        changed: false,
        saved: false,
        pipes: [
            {
                name: 'Select',
                type: KbqPipeTypes.Select,
                value: { name: 'Option 1', id: '1' },

                cleanable: false,
                removable: true,
                disabled: false
            },
            {
                name: 'MultiSelect',
                type: KbqPipeTypes.MultiSelect,
                value: [],

                cleanable: false,
                removable: true,
                disabled: false
            }
        ]
    };

    /** Period labels follow the active locale, so the templates are rebuilt whenever it changes. */
    protected readonly periods = injectLocalizedPeriods();

    readonly pipeTemplates = computed<KbqPipeTemplate[]>(() => [
        {
            name: 'Date',
            type: KbqPipeTypes.Date,
            values: this.periods.date(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Datetime',
            type: KbqPipeTypes.Datetime,
            values: this.periods.datetime(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],

            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Text',
            type: KbqPipeTypes.Text,

            cleanable: false,
            removable: true,
            disabled: false
        }
    ]);
}
```

### Required filter

A filter can be added to the filter panel regardless of the operating mode, which will always be filled and cannot be cleared. The user will only be able to change its value.

**Example: filter-bar-required** (`filter-bar-required`)

```ts filter-bar-required-example.ts
import { ChangeDetectionStrategy, Component, computed, linkedSignal } from '@angular/core';
import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter';
import { DateFormatter } from '@koobiq/components/core';
import { KbqFilter, KbqFilterBarModule, KbqPipe, KbqPipeTemplate, KbqPipeTypes } from '@koobiq/components/filter-bar';
import { injectLocalizedPeriods } from '../localized-data';

@Component({
    selector: 'filter-bar-required-example',
    imports: [
        KbqFilterBarModule,
        LuxonDateModule
    ],
    template: `
        <kbq-filter-bar
            [pipeTemplates]="pipeTemplates()"
            [filter]="activeFilter()"
            (filterChange)="onFilterChange($event)"
        >
            @for (pipe of activeFilter()?.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }

            <kbq-pipe-add />

            @if (activeFilter()?.name !== defaultFilter()?.name || activeFilter()?.changed) {
                <kbq-filter-reset (onResetFilter)="onReset()" />
            }
        </kbq-filter-bar>
    `,
    providers: [DateFormatter],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarRequiredExample {
    /** Period labels follow the active locale, so everything built out of them is rebuilt with it. */
    protected readonly periods = injectLocalizedPeriods();

    // Both are derived from the period labels, so both are rebuilt whenever the locale changes:
    // `*kbqPipe` builds a pipe component once from the object it is given and ignores later changes to
    // that binding, so a relabelled period only reaches the screen as a new pipe object.
    readonly activeFilter = linkedSignal<KbqFilter | null>(() => this.getDefaultFilter());
    readonly defaultFilter = computed<KbqFilter | null>(() => this.getDefaultFilter());

    readonly pipeTemplates = computed<KbqPipeTemplate[]>(() => [
        {
            name: 'Date',
            type: KbqPipeTypes.Date,
            values: this.periods.date(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Datetime',
            type: KbqPipeTypes.Datetime,
            values: this.periods.datetime(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],

            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Text',
            type: KbqPipeTypes.Text,

            cleanable: false,
            removable: true,
            disabled: false
        }
    ]);

    onFilterChange(filter: KbqFilter | null) {
        // KbqFilterBar flips `changed` to true on any pipe edit but never back to false.
        // Re-derive it by diffing the pipes against the default state, so reverting the pipes
        // also clears `changed` and hides <kbq-filter-reset>.
        const defaultFilter = this.defaultFilter();

        this.activeFilter.set(
            filter && defaultFilter && this.arePipesEqual(filter.pipes, defaultFilter.pipes)
                ? { ...filter, changed: false }
                : filter
        );
    }

    onReset() {
        this.activeFilter.set(this.getDefaultFilter(true));
    }

    /** Whether two pipe lists are equivalent (same name/type/value) — detects a return to the initial state. */
    arePipesEqual(a: KbqPipe[], b: KbqPipe[]): boolean {
        const serialize = (pipes: KbqPipe[]): string =>
            JSON.stringify(pipes.map(({ name, type, value }) => ({ name, type, value })));

        return serialize(a) === serialize(b);
    }

    /**
     * `openOnReset` is only for the filter the reset itself builds: the bar's reset stream stays latched
     * at `true`, so a pipe re-created for any other reason — a locale change, say — would replay it and
     * pop its panel open unprompted.
     */
    getDefaultFilter(openOnReset = false): KbqFilter {
        return {
            name: 'Select',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [
                {
                    name: 'Datetime',
                    type: KbqPipeTypes.Datetime,
                    value: this.periods.pick({ unit: 'hours', amount: -24 }),

                    cleanable: false,
                    removable: false,
                    disabled: false,
                    openOnReset
                }
            ]
        };
    }
}
```

### Unchangeable filters

The filter value cannot be changed, the filter can only be deleted.

**Example: filter-bar-readonly-pipe** (`filter-bar-readonly-pipe`)

```ts filter-bar-readonly-pipe-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { KbqFilter, KbqFilterBarModule, KbqPipeTypes } from '@koobiq/components/filter-bar';

@Component({
    selector: 'filter-bar-readonly-pipe-example',
    imports: [
        KbqFilterBarModule
    ],
    template: `
        <kbq-filter-bar [(filter)]="activeFilter">
            @for (pipe of activeFilter?.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarReadonlyPipeExample {
    activeFilter: KbqFilter | null = {
        name: '',
        readonly: false,
        disabled: false,
        changed: false,
        saved: false,
        pipes: [
            {
                name: 'Filter',
                value: 'Value',
                type: KbqPipeTypes.ReadOnly,

                cleanable: false,
                removable: true,
                disabled: false
            }
        ]
    };
}
```

Clicking on a pseudo-link in the list of parameters will add a new value to the filter. If the filter is not in the menu or in the panel: then an inactive element will be added, the value of which cannot be changed, it can only be deleted.

**Example: filter-bar-readonly-pipes** (`filter-bar-readonly-pipes`)

```ts filter-bar-readonly-pipes-example.ts
import { ChangeDetectionStrategy, Component, computed, linkedSignal } from '@angular/core';
import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter';
import { DateFormatter } from '@koobiq/components/core';
import { KbqDd, KbqDl, KbqDt } from '@koobiq/components/dl';
import { KbqFilter, KbqFilterBarModule, KbqPipe, KbqPipeTemplate, KbqPipeTypes } from '@koobiq/components/filter-bar';
import { KbqLink } from '@koobiq/components/link';
import { injectLocalizedPeriods, injectLocalizedText } from '../localized-data';

/** Text search is the first pipe in every filter: always present, never removable. */
const createSearchPipe = (name: string): KbqPipe => ({
    name,
    type: KbqPipeTypes.Input,
    value: null,

    cleanable: true,
    removable: false,
    disabled: false
});

@Component({
    selector: 'filter-bar-readonly-pipes-example',
    imports: [
        KbqFilterBarModule,
        LuxonDateModule,
        KbqDl,
        KbqDt,
        KbqDd,
        KbqLink
    ],
    template: `
        <kbq-filter-bar [pipeTemplates]="pipeTemplates()" [(filter)]="activeFilter">
            @for (pipe of activeFilter().pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }

            <kbq-pipe-add />

            @if (filterHasChanges()) {
                <kbq-filter-reset (onResetFilter)="onResetFilter()" />
            }
        </kbq-filter-bar>

        <br />

        <kbq-dl [verticalBreakpoint]="590">
            <kbq-dt>{{ text().user }}</kbq-dt>
            <kbq-dd><span kbqLink (click)="setUser(text().user, 'rturov')">rturov</span></kbq-dd>

            @for (item of text().readonlyPipes; track item) {
                <kbq-dt>{{ item.name }}</kbq-dt>
                <kbq-dd>
                    <span kbqLink (click)="addPipe(item.name, item.value)">{{ item.value }}</span>
                </kbq-dd>
            }
        </kbq-dl>
    `,
    providers: [DateFormatter],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarReadonlyPipesExample {
    /** Text the example owns: it has no counterpart in the library's locale data. */
    protected readonly text = injectLocalizedText({
        'ru-RU': {
            search: 'Поиск',
            user: 'Пользователь',
            readonlyPipes: [
                { name: 'Домен', value: 'Управление системой' },
                { name: 'Тип объекта', value: 'PaxMatrol IEMS' },
                { name: 'Действие', value: 'Изменение' },
                { name: 'Объект', value: 'Resolver' }
            ]
        },
        default: {
            search: 'Search',
            user: 'User',
            readonlyPipes: [
                { name: 'Domain', value: 'System management' },
                { name: 'Object type', value: 'PaxMatrol IEMS' },
                { name: 'Action', value: 'Modification' },
                { name: 'Object', value: 'Resolver' }
            ]
        }
    });

    /** Period labels follow the active locale, so everything built out of them is rebuilt with it. */
    protected readonly periods = injectLocalizedPeriods();

    // Rebuilt whenever the locale changes: `*kbqPipe` builds a pipe component once from the object it is
    // given and ignores later changes to that binding, so relabelled pipes only reach the screen as new
    // objects.
    readonly activeFilter = linkedSignal(() => this.getDefaultFilter());

    private readonly defaultFilter = computed(() => this.getDefaultFilter());

    /** `addPipe` only ever appends, so the default pipes stay index-aligned at the front. */
    protected readonly filterHasChanges = computed(() => {
        const defaultPipes = this.defaultFilter().pipes;
        const pipes = this.activeFilter().pipes;

        return (
            pipes.length > defaultPipes.length || pipes.some((pipe, index) => pipe.value !== defaultPipes[index].value)
        );
    });

    readonly pipeTemplates = computed<KbqPipeTemplate[]>(() => [
        {
            name: 'Date',
            type: KbqPipeTypes.Date,
            values: this.periods.date(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Datetime',
            type: KbqPipeTypes.Datetime,
            values: this.periods.datetime(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],

            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Text',
            type: KbqPipeTypes.Text,

            cleanable: false,
            removable: true,
            disabled: false
        }
    ]);

    onResetFilter() {
        console.log('onResetFilter: ');
        this.activeFilter.set(this.getDefaultFilter());
    }

    getDefaultFilter(): KbqFilter {
        return {
            name: '',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [
                createSearchPipe(this.text().search),
                {
                    name: this.text().user,
                    value: null,
                    type: KbqPipeTypes.ReadOnly,

                    cleanable: true,
                    removable: false,
                    disabled: false
                }
            ]
        };
    }

    setUser(name: string, value: string) {
        this.activeFilter.update((filter) =>
            filter.pipes.some((pipe) => pipe.name === name)
                ? {
                      ...filter,
                      changed: true,
                      pipes: filter.pipes.map((pipe) => (pipe.name === name ? { ...pipe, value } : pipe))
                  }
                : filter
        );
    }

    addPipe(name: string, value: string) {
        this.activeFilter.update((filter) =>
            filter.pipes.some((pipe) => pipe.name === name)
                ? filter
                : {
                      ...filter,
                      changed: true,
                      pipes: [
                          ...filter.pipes,
                          {
                              name,
                              value,
                              type: KbqPipeTypes.ReadOnly,

                              cleanable: false,
                              removable: true,
                              disabled: false
                          }
                      ]
                  }
        );
    }
}
```

### Filter types

**Example: filter-bar-pipe-types** (`filter-bar-pipe-types`)

```ts filter-bar-pipe-types-example.ts
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter';
import { DateFormatter } from '@koobiq/components/core';
import {
    kbqBuildTree,
    KbqFilter,
    KbqFilterBarModule,
    KbqPipeTemplate,
    KbqPipeTypes
} from '@koobiq/components/filter-bar';
import { injectLocalizedPeriods } from '../localized-data';

const DATA_OBJECT = {
    'No roles': 'value 0',
    'Management and Configuration': {
        Administrator: { value: 'value 1' },
        Operator: 'value 2',
        User: 'value 3'
    },
    'MP 10': {
        Administrator: 'value 4',
        Operator: 'value 5',
        User: 'value 6'
    },
    'Knowledge Base': {
        Administrator: 'value 7',
        Operator: 'value 8',
        User: 'value 9'
    }
};

@Component({
    selector: 'filter-bar-pipe-types-example',
    imports: [
        KbqFilterBarModule,
        LuxonDateModule
    ],
    template: `
        <kbq-filter-bar
            [pipeTemplates]="pipeTemplates()"
            [(filter)]="activeFilter"
            (onChangePipe)="onChangePipe($event)"
        >
            @for (pipe of activeFilter?.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }

            @if (isFilterChanged) {
                <kbq-filter-reset (onResetFilter)="onResetFilter()" />
            }
        </kbq-filter-bar>
    `,
    providers: [DateFormatter],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarPipeTypesExample {
    activeFilter: KbqFilter | null = this.getDefaultFilter();

    /** Period labels follow the active locale, so the templates are rebuilt whenever it changes. */
    protected readonly periods = injectLocalizedPeriods();

    readonly pipeTemplates = computed<KbqPipeTemplate[]>(() => [
        {
            name: 'Date',
            type: KbqPipeTypes.Date,
            values: this.periods.date(),
            cleanable: false,
            removable: false,
            disabled: false
        },
        {
            name: 'Datetime',
            type: KbqPipeTypes.Datetime,
            values: this.periods.datetime(),
            cleanable: true,
            removable: false,
            disabled: false
        },
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: [
                { name: 'Option 1', value: 'value1' },
                { name: 'Option 2', value: 'value2' },
                { name: 'Option 3', value: 'value3' },
                { name: 'Option 4', value: 'value4' },
                { name: 'Option 5', value: 'value5' },
                { name: 'Option 6', value: 'value6' },
                { name: 'Option 7', value: 'value7' }
            ],
            // Same rationale as the Select template below: options are identified by `value` rather than
            // a synthetic `id`, so a custom `compareWith` is needed instead of the default id-based one.
            compareWith: (o1, o2) => !!o1 && !!o2 && o1.value === o2.value,
            cleanable: false,
            removable: false,
            disabled: false
        },
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', value: 'value1' },
                { name: 'Option 2', value: 'value2' },
                { name: 'Option 3', value: 'value3' },
                { name: 'Option 4', value: 'value4' },
                { name: 'Option 5', value: 'value5' },
                { name: 'Option 6', value: 'value6' },
                { name: 'Option 7', value: 'value7' }
            ],
            // Options are identified by their business `value` rather than a synthetic `id`. Override
            // `compareWith` whenever a selected value can be a distinct object equal only by that key
            // (e.g. one restored from a saved filter) instead of the same reference. A custom comparator
            // is responsible for its own null handling — unlike the default, which never matches null.
            compareWith: (o1, o2) => !!o1 && !!o2 && o1.value === o2.value,
            cleanable: false,
            removable: false,
            disabled: false
        },
        {
            name: 'TreeSelect',
            type: KbqPipeTypes.TreeSelect,
            values: kbqBuildTree(DATA_OBJECT, 0),

            cleanable: false,
            removable: false,
            disabled: false
        },
        {
            name: 'MultiTreeSelect',
            type: KbqPipeTypes.MultiTreeSelect,
            values: kbqBuildTree(DATA_OBJECT, 0),

            cleanable: false,
            removable: false,
            disabled: false
        }
    ]);

    get isFilterChanged(): boolean {
        return JSON.stringify(this.activeFilter?.pipes ?? []) !== JSON.stringify(this.getDefaultFilter().pipes);
    }

    onResetFilter() {
        console.log('onResetFilter: ');
        this.activeFilter = this.getDefaultFilter();
    }

    onChangePipe($event) {
        console.log('onChangePipe: ', $event);
    }

    getDefaultFilter(): KbqFilter {
        return {
            name: '',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [
                {
                    name: 'Input',
                    type: KbqPipeTypes.Input,
                    value: null,

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'Select',
                    type: KbqPipeTypes.Select,
                    value: null,

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'MultiSelect',
                    type: KbqPipeTypes.MultiSelect,
                    value: [],

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'TreeSelect',
                    type: KbqPipeTypes.TreeSelect,
                    value: null,

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'TreeSelect Multiple',
                    id: 'MultiTreeSelect',
                    type: KbqPipeTypes.MultiTreeSelect,
                    value: [],

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'Text',
                    type: KbqPipeTypes.Text,
                    value: null,

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'Date',
                    type: KbqPipeTypes.Date,
                    value: null,

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'Datetime',
                    type: KbqPipeTypes.Datetime,
                    value: null,

                    cleanable: true,
                    removable: false,
                    disabled: false
                }
            ]
        };
    }
}
```

For the `select` and `multiselect` types, the pipe template accepts an optional `compareWith` — a comparator forwarded to the underlying select that controls how the selected value is matched against the option list. Override it when options are compared by a business key, or when the selected value is a distinct object (e.g. restored from a saved filter) rather than the same reference. When omitted, options are matched by their `id`. A custom comparator is responsible for its own null handling — unlike the default, which never matches a `null`/`undefined` value.

The `input` type is the only one without a popover: it renders a bare input that is typed into directly in the bar, using the pipe's `name` as its placeholder. The value is applied as you type — 200 ms after the last keystroke (`debounceTime`) and only from 3 characters on (`minLength`) — rather than through an Apply button. Text shorter than the threshold, including an emptied field, is applied as `null`, so the bar never keeps filtering by text that is no longer in the input. `Enter` and blur apply the value immediately and ignore the threshold: an explicit action is always applied as typed. Its only clear affordance is the built-in cleaner inside the input, so `removable` has no effect — an `input` pipe can only be removed programmatically. Values are trimmed, and a blank value is applied as `null`. Set its width with the `--kbq-filter-bar-pipe-input-width` CSS variable.

### Date and time limits

The `date` and `datetime` pipe templates accept optional limits for the custom period. `minDateTime` and `maxDateTime` bound the selectable range — the `datetime` pipe uses the full instant (date and time), the `date` pipe only the day. `minInterval` and `maxInterval` constrain the length of the selected period (`end − start`) as a duration-like object (e.g. `{ days: 3 }`, `{ hours: 1 }`); a period outside these bounds shows an error and disables Apply, with the limit formatted for the current locale. All four are ignored by other pipe types.

**Example: filter-bar-date-limits** (`filter-bar-date-limits`)

```ts filter-bar-date-limits-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter';
import { KbqFilterBarModule, KbqPipe, KbqPipeTemplate, KbqPipeTypes } from '@koobiq/components/filter-bar';
import { DateTime } from 'luxon';

@Component({
    selector: 'filter-bar-date-limits-example',
    imports: [
        KbqFilterBarModule,
        LuxonDateModule
    ],
    template: `
        <kbq-filter-bar [pipeTemplates]="pipeTemplates">
            @for (pipe of pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarDateLimitsExample {
    pipes: KbqPipe[] = [
        {
            name: 'Date',
            type: KbqPipeTypes.Date,
            value: null,

            cleanable: true,
            removable: false,
            disabled: false
        },
        {
            name: 'Datetime',
            type: KbqPipeTypes.Datetime,
            value: null,

            cleanable: true,
            removable: false,
            disabled: false
        }
    ];

    pipeTemplates: KbqPipeTemplate[] = [
        {
            name: 'Date',
            type: KbqPipeTypes.Date,
            // Selectable window: from a year ago up to today (only the day is used by the `date` pipe).
            minDateTime: DateTime.now().minus({ years: 1 }),
            maxDateTime: DateTime.now(),
            // The selected period must not be longer than 3 months.
            maxInterval: { months: 3 },
            cleanable: true,
            removable: false,
            disabled: false
        },
        {
            name: 'Datetime',
            type: KbqPipeTypes.Datetime,
            // Selectable window: the last 7 days up to now (date and time).
            minDateTime: DateTime.now().minus({ days: 7 }),
            maxDateTime: DateTime.now(),
            // The selected period must be between 1 hour and 3 days long.
            minInterval: { hours: 1 },
            maxInterval: { days: 3 },
            cleanable: true,
            removable: false,
            disabled: false
        }
    ];
}
```

### Search in pipes

If the filter has many values, it is useful to enable search in the drop-down menu.

**Example: filter-bar-search-in-pipes** (`filter-bar-search-in-pipes`)

```ts filter-bar-search-in-pipes-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
    kbqBuildTree,
    KbqFilter,
    KbqFilterBarModule,
    KbqPipeTemplate,
    KbqPipeTypes
} from '@koobiq/components/filter-bar';

const DATA_OBJECT = {
    'No roles': 'value 0',
    'Management and Configuration': {
        Administrator: { value: 'value 1' },
        Operator: 'value 2',
        User: 'value 3'
    },
    'MP 10': {
        Administrator: 'value 4',
        Operator: 'value 5',
        User: 'value 6'
    },
    'Knowledge Base': {
        Administrator: 'value 7',
        Operator: 'value 8',
        User: 'value 9'
    }
};

@Component({
    selector: 'filter-bar-search-in-pipes-example',
    imports: [
        KbqFilterBarModule
    ],
    template: `
        <kbq-filter-bar
            [pipeTemplates]="pipeTemplates"
            [selectedAllEqualsSelectedNothing]="false"
            [(filter)]="activeFilter"
            (filterChange)="onFilterChange($event)"
            (onChangePipe)="onChangePipe($event)"
        >
            @for (pipe of activeFilter.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarSearchInPipesExample {
    activeFilter: KbqFilter = this.getDefaultFilter();

    pipeTemplates: KbqPipeTemplate[] = [
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],

            cleanable: false,
            removable: false,
            disabled: false
        },
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],
            cleanable: false,
            removable: false,
            disabled: false
        },
        {
            name: 'TreeSelect',
            type: KbqPipeTypes.TreeSelect,
            values: kbqBuildTree(DATA_OBJECT, 0),

            cleanable: false,
            removable: false,
            disabled: false
        },
        {
            name: 'MultiTreeSelect',
            type: KbqPipeTypes.MultiTreeSelect,
            values: kbqBuildTree(DATA_OBJECT, 0),

            cleanable: false,
            removable: false,
            disabled: false
        }
    ];

    getDefaultFilter(): KbqFilter {
        return {
            name: '',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [
                {
                    name: 'Select',
                    type: KbqPipeTypes.Select,
                    value: null,

                    search: true,

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'MultiSelect',
                    type: KbqPipeTypes.MultiSelect,
                    value: null,

                    search: true,

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'TreeSelect',
                    type: KbqPipeTypes.TreeSelect,
                    value: null,

                    search: true,

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'TreeSelect Multiple',
                    id: 'MultiTreeSelect',
                    type: KbqPipeTypes.MultiTreeSelect,
                    value: null,

                    search: true,

                    cleanable: true,
                    removable: false,
                    disabled: false
                }
            ]
        };
    }

    onFilterChange($event) {
        console.log('onFilterChange: ', $event);
    }

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

### Master checkbox "Select all"

If there are many values, "Select All" will allow you to select all values ​​or deselect all values ​​in one action. When searching, the master checkbox selects only the results that match the query.

**Example: filter-bar-master-checkbox** (`filter-bar-master-checkbox`)

```ts filter-bar-master-checkbox-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
    kbqBuildTree,
    KbqFilter,
    KbqFilterBarModule,
    KbqPipeTemplate,
    KbqPipeTypes
} from '@koobiq/components/filter-bar';

const DATA_OBJECT = {
    'No roles': 'value 0',
    'Management and Configuration': {
        Administrator: { value: 'value 1' },
        Operator: 'value 2',
        User: 'value 3'
    },
    'MP 10': {
        Administrator: 'value 4',
        Operator: 'value 5',
        User: 'value 6'
    },
    'Knowledge Base': {
        Administrator: 'value 7',
        Operator: 'value 8',
        User: 'value 9'
    }
};

@Component({
    selector: 'filter-bar-master-checkbox-example',
    imports: [
        KbqFilterBarModule
    ],
    template: `
        <kbq-filter-bar
            [pipeTemplates]="pipeTemplates"
            [(filter)]="activeFilter"
            (filterChange)="onFilterChange($event)"
            (onChangePipe)="onChangePipe($event)"
        >
            @for (pipe of activeFilter?.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarMasterCheckboxExample {
    activeFilter: KbqFilter | null = {
        name: '',
        readonly: false,
        disabled: false,
        changed: false,
        saved: false,
        pipes: [
            {
                name: 'MultiSelect',
                value: [
                    { name: 'Option 1', id: '1' },
                    { name: 'Option 3', id: '3' },
                    { name: 'Option 4', id: '4' }
                ],
                type: KbqPipeTypes.MultiSelect,

                search: true,
                selectAll: true,

                cleanable: true,
                removable: false,
                disabled: false
            },
            {
                name: 'TreeSelect Multiple',
                id: 'MultiTreeSelect',
                type: KbqPipeTypes.MultiTreeSelect,
                value: ['value 2', 'value 3'],

                search: true,
                selectAll: true,

                cleanable: true,
                removable: false,
                disabled: false
            }
        ]
    };

    pipeTemplates: KbqPipeTemplate[] = [
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: Array.from({ length: 20 }, (_, i) => ({ name: `Option #${i}`, id: i.toString() })),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'MultiTreeSelect',
            type: KbqPipeTypes.MultiTreeSelect,
            values: kbqBuildTree(DATA_OBJECT, 0),
            cleanable: false,
            removable: true,
            disabled: false
        }
    ];

    onFilterChange($event) {
        console.log('onFilterChange: ', $event);
    }

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

### Locked values

The `multiselect` and `multi-tree-select` pipe templates accept an optional `lockedValues` — the list of values whose selection cannot be removed. They are always selected: rendered as disabled options with a checked checkbox, kept by clearing the pipe, and folded into an incoming filter value silently, without a change event. A pipe holding nothing but its locked values reads as empty — it renders unfilled and hides its clear button. Note that "reads as empty" is about the UI only: because a pipe's `value` is meant to be applied as-is, clearing removes just the entries the user was able to select, and both `onClearPipe` and `onChangePipe` still carry a `value` listing the locked ones.

"Select all" ignores them entirely: it neither selects nor deselects a locked option, and the master checkbox reflects only the options the user can actually toggle. Note that under `selectedAllEqualsSelectedNothing` a full selection is still committed as an empty `value` — there the locked values are implied rather than listed.

Entries of `lockedValues` have the same shape as the entries of the owning pipe's `value`: `{ name, id }` objects for `multiselect` (matched with `compareWith`, falling back to `id`), and raw node values for `multi-tree-select`. Locking a tree branch locks its whole subtree. The option is ignored by other pipe types.

Because locked options are rendered as `disabled`, keyboard navigation skips them.

**Example: filter-bar-locked-options** (`filter-bar-locked-options`)

```ts filter-bar-locked-options-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
    kbqBuildTree,
    KbqFilter,
    KbqFilterBarModule,
    KbqPipeTemplate,
    KbqPipeTypes
} from '@koobiq/components/filter-bar';

const DATA_OBJECT = {
    'No roles': 'value 0',
    'Management and Configuration': {
        Administrator: 'value 1',
        Operator: 'value 2',
        User: 'value 3'
    },
    'MP 10': {
        Administrator: 'value 4',
        Operator: 'value 5',
        User: 'value 6'
    }
};

const SELECT_VALUES = Array.from({ length: 6 }, (_, i) => ({ name: `Option #${i}`, id: i.toString() }));

@Component({
    selector: 'filter-bar-locked-options-example',
    imports: [
        KbqFilterBarModule
    ],
    template: `
        <kbq-filter-bar
            [pipeTemplates]="pipeTemplates"
            [selectedAllEqualsSelectedNothing]="false"
            [(filter)]="activeFilter"
        >
            @for (pipe of activeFilter?.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarLockedOptionsExample {
    activeFilter: KbqFilter | null = {
        name: '',
        readonly: false,
        disabled: false,
        changed: false,
        saved: false,
        pipes: [
            {
                name: 'MultiSelect',
                id: 'MultiSelect',
                type: KbqPipeTypes.MultiSelect,
                // The locked options are added on their own — listing them here is not required.
                value: [{ name: 'Option #3', id: '3' }],

                search: true,

                cleanable: true,
                removable: false,
                disabled: false
            },
            {
                name: 'MultiTreeSelect',
                id: 'MultiTreeSelect',
                type: KbqPipeTypes.MultiTreeSelect,
                value: ['value 2'],

                search: true,

                cleanable: true,
                removable: false,
                disabled: false
            }
        ]
    };

    pipeTemplates: KbqPipeTemplate[] = [
        {
            name: 'MultiSelect',
            id: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: SELECT_VALUES,
            // Same shape as the entries of the pipe's `value`: matched by the id-based comparator.
            lockedValues: [SELECT_VALUES[0]],
            cleanable: true,
            removable: false,
            disabled: false
        },
        {
            name: 'MultiTreeSelect',
            id: 'MultiTreeSelect',
            type: KbqPipeTypes.MultiTreeSelect,
            values: kbqBuildTree(DATA_OBJECT, 0),
            // Raw node values here; locking the `MP 10` branch would lock its whole subtree.
            lockedValues: ['value 0'],
            cleanable: true,
            removable: false,
            disabled: false
        }
    ];
}
```

### Dropdown height

By default the dropdown list of the `select`, `multiselect`, `tree-select` and `multi-tree-select` pipes is 256px tall — exactly eight 32px options, so a list of eight fits without a scrollbar. Pass `panelMaxHeight` in the pipe template, in pixels, to change that:

```ts
pipeTemplates: KbqPipeTemplate[] = [
    {
        name: 'Select',
        type: KbqPipeTypes.Select,
        values: [/* ... */],
        panelMaxHeight: 160,

        cleanable: false,
        removable: false,
        disabled: false
    }
];
```

The value caps the scrollable list only, and the list's own padding is added on top of it — so a multiple of the 32px option height fits without a scrollbar and without a clipped row. The pipe's search field is rendered above the list and adds to the panel's total height, while the "select all" row scrolls with the options and is counted by the cap. A value taller than the room left in the viewport is clipped by the overlay rather than scrolled. Omitting the option — or passing `null` — restores the default. The option is ignored by other pipe types.

An option with a `caption`, and any option under `multilineOptions`, is taller than 32px, so fewer of them fit.

**Example: filter-bar-panel-max-height** (`filter-bar-panel-max-height`)

```ts filter-bar-panel-max-height-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { KbqFilter, KbqFilterBarModule, KbqPipeTemplate, KbqPipeTypes } from '@koobiq/components/filter-bar';

const OPTIONS = Array.from({ length: 12 }, (_, index) => ({ name: `Option ${index + 1}`, id: `${index + 1}` }));

@Component({
    selector: 'filter-bar-panel-max-height-example',
    imports: [
        KbqFilterBarModule
    ],
    template: `
        <kbq-filter-bar [pipeTemplates]="pipeTemplates" [(filter)]="activeFilter">
            @for (pipe of activeFilter.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarPanelMaxHeightExample {
    activeFilter: KbqFilter = this.getDefaultFilter();

    pipeTemplates: KbqPipeTemplate[] = [
        {
            name: 'Default height',
            id: 'DefaultHeight',
            type: KbqPipeTypes.Select,
            values: OPTIONS,

            cleanable: false,
            removable: false,
            disabled: false
        },
        {
            name: '160px',
            id: 'CappedHeight',
            type: KbqPipeTypes.Select,
            values: OPTIONS,
            panelMaxHeight: 160,

            cleanable: false,
            removable: false,
            disabled: false
        }
    ];

    getDefaultFilter(): KbqFilter {
        return {
            name: '',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [
                {
                    name: 'Default height',
                    id: 'DefaultHeight',
                    type: KbqPipeTypes.Select,
                    value: null,

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: '160px',
                    id: 'CappedHeight',
                    type: KbqPipeTypes.Select,
                    value: null,

                    cleanable: true,
                    removable: false,
                    disabled: false
                }
            ]
        };
    }
}
```

### Caption in options

Add `caption` to the option value of a `select` or `multiselect` pipe to show a second line in the dropdown — a technical identifier, for example.

Both lines are truncated by default; `multilineOptions: true` wraps them instead.

**Example: filter-bar-option-caption** (`filter-bar-option-caption`)

```ts filter-bar-option-caption-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
    KbqFilter,
    KbqFilterBarModule,
    KbqPipeTemplate,
    KbqPipeTypes,
    KbqSelectValue
} from '@koobiq/components/filter-bar';

const EVENTS: KbqSelectValue[] = [
    { name: 'Event: Action', id: 'action', value: 'action', caption: 'action' },
    {
        name: 'Warning: additional information about the event that is far too long to be shown on a single line of the dropdown panel',
        id: 'warning',
        value: 'warning',
        caption:
            'A caption long enough to need a second line of its own once the panel stops growing, so it demonstrates wrapping when the available width of the dropdown panel is exceeded'
    },
    { name: 'Event: Threat type', id: 'threat', value: 'threat', caption: 'category.generic' },
    { name: 'Event: Source', id: 'source', value: 'source', caption: 'event.source' }
];

@Component({
    selector: 'filter-bar-option-caption-example',
    imports: [
        KbqFilterBarModule
    ],
    template: `
        <kbq-filter-bar [pipeTemplates]="pipeTemplates" [(filter)]="activeFilter">
            @for (pipe of activeFilter.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarOptionCaptionExample {
    activeFilter: KbqFilter = this.getDefaultFilter();

    pipeTemplates: KbqPipeTemplate[] = [
        {
            name: 'Single line',
            id: 'SingleLine',
            type: KbqPipeTypes.MultiSelect,
            values: EVENTS,
            search: true,

            cleanable: true,
            removable: false,
            disabled: false
        },
        {
            name: 'Multiline',
            id: 'Multiline',
            type: KbqPipeTypes.MultiSelect,
            values: EVENTS,
            multilineOptions: true,
            search: true,

            cleanable: true,
            removable: false,
            disabled: false
        },
        {
            name: 'Select',
            id: 'Select',
            type: KbqPipeTypes.Select,
            values: EVENTS,
            multilineOptions: true,

            cleanable: true,
            removable: false,
            disabled: false
        }
    ];

    getDefaultFilter(): KbqFilter {
        return {
            name: '',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [
                {
                    name: 'Single line',
                    id: 'SingleLine',
                    type: KbqPipeTypes.MultiSelect,
                    value: null,
                    search: true,

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'Multiline',
                    id: 'Multiline',
                    type: KbqPipeTypes.MultiSelect,
                    value: null,
                    search: true,

                    cleanable: true,
                    removable: false,
                    disabled: false
                },
                {
                    name: 'Select',
                    id: 'Select',
                    type: KbqPipeTypes.Select,
                    value: null,

                    cleanable: true,
                    removable: false,
                    disabled: false
                }
            ]
        };
    }
}
```

### Parameter not specified

The dropdown can offer a way to find objects whose parameter has no value. The option goes at the top of the list and is localized. Its wording can be chosen to agree with the name of the filter.

**Example: filter-bar-not-specified** (`filter-bar-not-specified`)

```ts filter-bar-not-specified-example.ts
import { ChangeDetectionStrategy, Component, computed, linkedSignal } from '@angular/core';
import {
    KbqFilter,
    KbqFilterBarModule,
    KbqPipeTemplate,
    KbqPipeTypes,
    KbqSelectValue
} from '@koobiq/components/filter-bar';
import { injectLocalizedText } from '../localized-data';

const ATTACK_TYPES: KbqSelectValue[] = [
    'Adversary-in-the-Middle',
    'Brute Force',
    'Buffer Overflow',
    'Command and Control',
    'Credential Stuffing',
    'Cross-Site Scripting',
    'Denial of Service',
    'DNS Spoofing',
    'Drive-by Download',
    'Eavesdropping',
    'Exploit Kit',
    'Keylogging',
    'Man-in-the-Middle',
    'Phishing',
    'Privilege Escalation',
    'Ransomware',
    'Rootkit',
    'SQL Injection',
    'Trojan Horse',
    'Zero-Day Exploit'
].map((name) => ({ name, id: name, value: name }));

@Component({
    selector: 'filter-bar-not-specified-example',
    imports: [
        KbqFilterBarModule
    ],
    template: `
        <kbq-filter-bar [pipeTemplates]="pipeTemplates()" [(filter)]="activeFilter">
            @for (pipe of activeFilter().pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarNotSpecifiedExample {
    /** Text the example owns; the option is worded to agree with the name of the filter. */
    protected readonly text = injectLocalizedText({
        'ru-RU': { name: 'Тип атаки', notSpecified: 'Не указан' },
        default: { name: 'Attack Type', notSpecified: 'Not specified' }
    });

    /** Objects whose attack type is not set: the first option of the list. */
    private readonly notSpecified = computed<KbqSelectValue>(() => ({
        name: this.text().notSpecified,
        id: 'NotSpecified',
        value: null
    }));

    readonly pipeTemplates = computed<KbqPipeTemplate[]>(() => [
        {
            name: this.text().name,
            id: 'AttackType',
            type: KbqPipeTypes.MultiSelect,
            values: [this.notSpecified(), ...ATTACK_TYPES],

            cleanable: false,
            removable: true,
            disabled: false
        }
    ]);

    // Rebuilt whenever the locale changes: `*kbqPipe` builds a pipe component once from the object it is
    // given, so relabelled pipes only reach the screen as new objects.
    readonly activeFilter = linkedSignal<KbqFilter>(() => ({
        name: '',
        readonly: false,
        disabled: false,
        changed: false,
        saved: false,
        pipes: [
            {
                name: this.text().name,
                id: 'AttackType',
                type: KbqPipeTypes.MultiSelect,
                value: [this.notSpecified()],
                search: true,
                selectAll: true,

                cleanable: false,
                removable: true,
                disabled: false
            }
        ]
    }));
}
```

### No available values

Do not show the filter when there are definitely no values to choose from. If the filter cannot be hidden, a hint has to appear on click.

**Example: filter-bar-no-options** (`filter-bar-no-options`)

```ts filter-bar-no-options-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { KbqFilter, KbqFilterBarModule, KbqPipeTemplate, KbqPipeTypes } from '@koobiq/components/filter-bar';

@Component({
    selector: 'filter-bar-no-options-example',
    imports: [
        KbqFilterBarModule
    ],
    template: `
        <kbq-filter-bar [pipeTemplates]="pipeTemplates" [(filter)]="activeFilter">
            @for (pipe of activeFilter.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarNoOptionsExample {
    activeFilter: KbqFilter = this.getDefaultFilter();

    pipeTemplates: KbqPipeTemplate[] = [
        {
            name: 'Tenant',
            id: 'Tenant',
            type: KbqPipeTypes.Select,
            values: [],

            cleanable: false,
            removable: true,
            disabled: false
        }
    ];

    getDefaultFilter(): KbqFilter {
        return {
            name: '',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [
                {
                    name: 'Tenant',
                    id: 'Tenant',
                    type: KbqPipeTypes.Select,
                    value: null,
                    search: true,

                    cleanable: false,
                    removable: true,
                    disabled: false
                }
            ]
        };
    }
}
```

The hint replaces the list of a `select`, `multiselect`, `tree-select` or `multi-tree-select` pipe whose template has no `values`; the search field and "Select all" are hidden. Its text comes from the localization — to word it after the filter, pass `noOptionsText` in the pipe template. The option is ignored by other pipe types.

### Inactive filter

The filter value cannot be changed or deleted.

**Example: filter-bar-readonly-pipe** (`filter-bar-inactive-filter`)

```ts filter-bar-inactive-filter-example.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { KbqFilter, KbqFilterBarModule, KbqPipeTypes } from '@koobiq/components/filter-bar';

@Component({
    selector: 'filter-bar-inactive-filter-example',
    imports: [
        KbqFilterBarModule
    ],
    template: `
        <kbq-filter-bar [(filter)]="activeFilter">
            @for (pipe of activeFilter?.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarInactiveFilterExample {
    activeFilter: KbqFilter | null = {
        name: '',
        readonly: false,
        disabled: false,
        changed: false,
        saved: false,
        pipes: [
            {
                name: 'Filter',
                value: null,
                type: KbqPipeTypes.ReadOnly,

                cleanable: false,
                removable: false,
                disabled: true
            },
            {
                name: 'Filter',
                value: 'Value',
                type: KbqPipeTypes.ReadOnly,

                cleanable: false,
                removable: true,
                disabled: true
            }
        ]
    };
}
```

### Search

Text search allows you to search for information based on any data, even if there are no separate filters for it.

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: Filter-bar search** (`filter-bar-search`)

```ts filter-bar-search-example.ts
import { ChangeDetectionStrategy, Component, computed, linkedSignal } from '@angular/core';
import { createSearchPredicate } from '@koobiq/components/core';
import { KbqFilter, KbqFilterBarModule, KbqPipe, KbqPipeTypes } from '@koobiq/components/filter-bar';
import { injectLocalizedText } from '../localized-data';

interface Network {
    name: string;
    description: string;
}

/** Text search is the first pipe in every filter: always present, never removable. */
const createSearchPipe = (name: string): KbqPipe => ({
    name,
    type: KbqPipeTypes.Input,
    value: null,

    cleanable: true,
    removable: false,
    disabled: false
});

@Component({
    selector: 'filter-bar-search-example',
    imports: [KbqFilterBarModule],
    template: `
        <kbq-filter-bar [filter]="activeFilter()" (filterChange)="onFilterChange($event)">
            @for (pipe of activeFilter().pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }
        </kbq-filter-bar>

        <ul class="network-list">
            @for (network of filteredNetworks(); track network.name) {
                <li>
                    <strong>{{ network.name }}</strong>
                    <span>{{ network.description }}</span>
                </li>
            } @empty {
                <li>Nothing found</li>
            }
        </ul>
    `,
    styles: `
        .network-list {
            display: flex;
            flex-direction: column;
            gap: var(--kbq-size-s);
            margin: var(--kbq-size-m) 0 0;
            padding: 0;
            list-style: none;
        }

        .network-list li {
            display: flex;
            gap: var(--kbq-size-s);
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarSearchExample {
    readonly networks: Network[] = [
        { name: '10.125.123.0/24 - all', description: 'Development network' },
        { name: '10.125.10.0/24 - admin', description: 'Admin network' },
        { name: '10.125.11.0/24 - guest', description: 'Guest network with limited access' },
        { name: '172.16.0.0/16 - office', description: 'Main office network' },
        { name: '192.168.1.0/24 - lab', description: 'Testing laboratory' },
        { name: 'Café Wi-Fi guest network', description: 'Guest cafe wireless network' }
    ];

    protected readonly text = injectLocalizedText({
        'ru-RU': { search: 'Поиск' },
        default: { search: 'Search' }
    });

    // Rebuilt whenever the locale changes: `*kbqPipe` builds a pipe component once from the object it is
    // given and ignores later changes to that binding, so the relabelled pipe only reaches the screen as
    // a new object.
    readonly activeFilter = linkedSignal(() => this.getDefaultFilter());

    readonly filteredNetworks = computed(() => {
        const query = (this.activeFilter()?.pipes[0]?.value as string | null) ?? '';
        const predicate = createSearchPredicate(query);

        return this.networks.filter((network) => predicate([network.name, network.description]));
    });

    onFilterChange(filter: KbqFilter | null) {
        if (!filter) return;

        this.activeFilter.set(filter);
    }

    getDefaultFilter(): KbqFilter {
        return {
            name: '',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [createSearchPipe(this.text().search)]
        };
    }
}
```

### Saving filters

The user can quickly get search results by selecting a saved filter, without re-configuring the parameters. It is also possible to create a new set of filters and use them in the future.

**Example: filter-bar-saved-filters** (`filter-bar-saved-filters`)

```ts filter-bar-saved-filters-example.ts
import {
    ChangeDetectionStrategy,
    Component,
    computed,
    inject,
    linkedSignal,
    TemplateRef,
    viewChild
} from '@angular/core';
import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter';
import { DateFormatter } from '@koobiq/components/core';
import {
    KbqFilter,
    KbqFilterBar,
    KbqFilterBarModule,
    KbqPipe,
    KbqPipeTemplate,
    KbqPipeTypes,
    KbqSaveFilterEvent,
    KbqSaveFilterStatuses
} from '@koobiq/components/filter-bar';
import { KbqLink } from '@koobiq/components/link';
import { KbqToastData, KbqToastService, KbqToastStyle } from '@koobiq/components/toast';
import { injectLocalizedPeriods, injectLocalizedText } from '../localized-data';

interface ExampleFilter extends KbqFilter {
    id?: number;
}

/** Text search is the first pipe in every filter: always present, never removable. */
const createSearchPipe = (name: string): KbqPipe => ({
    name,
    type: KbqPipeTypes.Input,
    value: null,

    cleanable: true,
    removable: false,
    disabled: false
});

@Component({
    selector: 'filter-bar-saved-filters-example',
    imports: [
        KbqFilterBarModule,
        LuxonDateModule,
        KbqLink
    ],
    template: `
        <kbq-filter-bar
            [pipeTemplates]="pipeTemplates()"
            [filter]="activeFilter()"
            (filterChange)="onFilterChange($event)"
        >
            <kbq-filters
                [filters]="filters()"
                (onSave)="onSaveFilter($event)"
                (onResetFilterChanges)="onResetFilterChanges($event)"
                (onRemoveFilter)="onDeleteFilter($event)"
            />

            @for (pipe of activeFilter()?.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }

            <kbq-pipe-add />

            @if (activeFilter()?.name !== defaultFilter()?.name || activeFilter()?.changed) {
                <kbq-filter-reset (onResetFilter)="onResetFilter()" />
            }
        </kbq-filter-bar>

        <ng-template #toastErrorTitleTemplate let-toast>
            {{ toast.data.text }}
            <span class="kbq-text-normal-strong">{{ toast.data.filter.name }}</span>
        </ng-template>

        <ng-template #errorToastActions let-toast>
            <a kbqLink [pseudo]="true" (click)="toast.close()">{{ text().retry }}</a>
        </ng-template>
    `,
    providers: [DateFormatter],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarSavedFiltersExample {
    readonly toastService = inject(KbqToastService);

    readonly errorToastActionsTemplate = viewChild.required<TemplateRef<any>>('errorToastActions');
    readonly toastErrorTitleTemplate = viewChild.required<TemplateRef<any>>('toastErrorTitleTemplate');

    /** Period labels follow the active locale, so everything built out of them is rebuilt with it. */
    protected readonly periods = injectLocalizedPeriods();

    /** Text the example owns: it has no counterpart in the library's locale data. */
    protected readonly text = injectLocalizedText({
        'ru-RU': {
            search: 'Поиск',
            retry: 'Повторить',
            deleteError: 'Не удалось удалить фильтр: ',
            saveError: (name: string) => `Не удалось сохранить фильтр: ${name}`
        },
        default: {
            search: 'Search',
            retry: 'Retry',
            deleteError: 'Could not delete the filter: ',
            saveError: (name: string) => `Could not save the filter: ${name}`
        }
    });

    // Everything below is derived from the period labels, so it is rebuilt whenever the locale changes:
    // `*kbqPipe` builds a pipe component once from the object it is given and ignores later changes to
    // that binding, so a relabelled period only reaches the screen as a new pipe object.
    readonly filters = linkedSignal(() => this.createFilters());
    readonly savedFilters = computed(() => this.createSavedFilters());

    readonly defaultFilter = computed<KbqFilter | null>(() => this.getDefaultFilter());
    readonly activeFilter = linkedSignal<KbqFilter | null>(() => this.createFilters()[0]);

    readonly pipeTemplates = computed<KbqPipeTemplate[]>(() => [
        {
            name: 'Date',
            type: KbqPipeTypes.Date,
            values: this.periods.date(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Datetime',
            type: KbqPipeTypes.Datetime,
            values: this.periods.datetime(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],

            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Text',
            type: KbqPipeTypes.Text,

            cleanable: false,
            removable: true,
            disabled: false
        }
    ]);

    onFilterChange(filter: KbqFilter | null) {
        // KbqFilterBar flips `changed` to true on any pipe edit but never back to false.
        // Re-derive it by diffing the pipes against the filter's initial (saved/default) state,
        // so reverting the pipes also clears `changed` and hides <kbq-filter-reset>.
        const initialFilter = filter ? this.getInitialFilter(filter) : null;

        this.activeFilter.set(
            filter && initialFilter && this.arePipesEqual(filter.pipes, initialFilter.pipes)
                ? { ...filter, changed: false }
                : filter
        );
    }

    onResetFilter() {
        console.log('onResetFilter');

        this.activeFilter.set(this.getDefaultFilter(true));
    }

    onResetFilterChanges(filter: KbqFilter | null) {
        console.log('onResetFilterChanges');

        const defaultFilter = this.getSavedFilter(filter);

        this.filters.update((filters) => filters.map((item) => (item.name === filter?.name ? defaultFilter : item)));

        this.activeFilter.set(defaultFilter);
    }

    onDeleteFilter(filter: ExampleFilter) {
        console.log('onDeleteFilter: ', filter);

        if (filter.id === 3) {
            this.toastService.show({
                style: KbqToastStyle.Error,
                title: this.toastErrorTitleTemplate(),
                closeButton: true,
                actions: this.errorToastActionsTemplate(),
                text: this.text().deleteError,
                filter
            } as KbqToastData);
        } else {
            this.filters.update((filters) => filters.filter(({ name }) => name !== filter?.name));

            this.activeFilter.set(this.getDefaultFilter());
        }
    }

    onSaveFilter({ filter, filterBar, status }: KbqSaveFilterEvent & { filter: ExampleFilter }) {
        console.log('onSaveFilter: ', filter);

        setTimeout(() => {
            if (filter.id === 2) {
                filterBar.filters()?.filterSavedUnsuccessfully({ text: this.text().saveError(filter.name) });
            } else if (status === KbqSaveFilterStatuses.NewFilter) {
                this.saveNewFilter(filter, filterBar);
            } else if (status === KbqSaveFilterStatuses.NewName) {
                this.saveCurrentFilterWithNewName(filter, filterBar);
            } else if (status === KbqSaveFilterStatuses.OnlyChanges) {
                this.saveCurrentFilterWithChangesInPipes(filter, filterBar);
            }
        }, 3000);
    }

    saveNewFilter(filter: KbqFilter, filterBar: KbqFilterBar) {
        // This logic simulates the behavior of the backend
        if (!this.filters().some(({ name }) => name === filter.name)) {
            this.filters.update((filters) => [...filters, filter]);

            this.activeFilter.set(filter);

            filterBar.filters()?.filterSavedSuccessfully();
        } else {
            filterBar.filters()?.filterSavedUnsuccessfully({ nameAlreadyExists: true });
        }
    }

    saveCurrentFilterWithNewName(filter: KbqFilter, filterBar: KbqFilterBar) {
        // This logic simulates the behavior of the backend
        if (filterBar.filter()?.name === filter.name) {
            filterBar.filters()?.filterSavedUnsuccessfully({ nameAlreadyExists: true });

            return;
        }

        const currentName = filterBar.filter()?.name;

        // A filter the store does not know about cannot be renamed.
        if (!this.filters().some(({ name }) => name === currentName)) {
            filterBar.filters()?.filterSavedUnsuccessfully();

            return;
        }

        // Renaming writes the name only: the stored pipes stay as they were saved, so unsaved changes
        // in the bar are not persisted along with the new name.
        this.filters.update((filters) =>
            filters.map((item) => (item.name === currentName ? { ...item, name: filter.name } : item))
        );

        this.activeFilter.set(filter);

        filterBar.filters()?.filterSavedSuccessfully();
    }

    saveCurrentFilterWithChangesInPipes(filter: KbqFilter, filterBar: KbqFilterBar) {
        // This logic simulates the behavior of the backend
        this.filters.update((filters) => filters.map((item) => (item.name === filter.name ? filter : item)));

        this.activeFilter.set(filter);

        filterBar.filters()?.filterSavedSuccessfully();
    }

    getSavedFilter(filter: KbqFilter | null): KbqFilter {
        return structuredClone(this.savedFilters().find(({ name }) => name === filter?.name)!);
    }

    /** Pristine (saved or default) version of a filter — the baseline for change detection. */
    getInitialFilter(filter: KbqFilter): KbqFilter | null {
        return filter.name === this.defaultFilter()?.name
            ? this.defaultFilter()
            : (this.savedFilters().find(({ name }) => name === filter.name) ?? null);
    }

    /** Whether two pipe lists are equivalent (same name/type/value) — detects a return to the initial state. */
    arePipesEqual(a: KbqPipe[], b: KbqPipe[]): boolean {
        const serialize = (pipes: KbqPipe[]): string =>
            JSON.stringify(pipes.map(({ name, type, value }) => ({ name, type, value })));

        return serialize(a) === serialize(b);
    }

    createFilters(): ExampleFilter[] {
        return [
            {
                name: 'Saved Filter 1',
                id: 1,
                readonly: false,
                disabled: false,
                changed: true,
                saved: true,
                pipes: [
                    createSearchPipe(this.text().search),
                    {
                        name: 'Datetime',
                        value: this.periods.pick({ unit: 'days', amount: -7 }),
                        type: KbqPipeTypes.Datetime,

                        cleanable: false,
                        removable: false,
                        disabled: false
                    },
                    {
                        name: 'Select',
                        value: { name: 'Option 6', id: '6' },
                        type: KbqPipeTypes.Select,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    },
                    {
                        name: 'Text',
                        value: 'Text: Edited filter with unsaved changes',
                        type: KbqPipeTypes.Text,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    }
                ]
            },
            {
                name: 'Saved Filter 2 (save Error)',
                id: 2,
                readonly: false,
                disabled: false,
                changed: false,
                saved: true,
                pipes: [
                    createSearchPipe(this.text().search),
                    {
                        name: 'Datetime',
                        value: this.periods.pick({ unit: 'years', amount: -1 }),
                        type: KbqPipeTypes.Datetime,

                        cleanable: false,
                        removable: false,
                        disabled: false
                    },
                    {
                        name: 'MultiSelect',
                        value: [
                            { name: 'Option 1', id: '1' },
                            { name: 'Option 3', id: '3' },
                            { name: 'Option 4', id: '4' }
                        ],
                        type: KbqPipeTypes.MultiSelect,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    },
                    {
                        name: 'Date',
                        value: this.periods.pick({ unit: 'days', amount: -7 }),
                        type: KbqPipeTypes.Date,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    }
                ]
            },
            {
                name: 'Saved Filter 3 (delete Error)',
                id: 3,
                readonly: false,
                disabled: false,
                changed: false,
                saved: true,
                pipes: [
                    createSearchPipe(this.text().search),
                    {
                        name: 'Datetime',
                        value: this.periods.pick({ unit: 'days', amount: -3 }),
                        type: KbqPipeTypes.Datetime,

                        cleanable: false,
                        removable: false,
                        disabled: false
                    },
                    {
                        name: 'Select',
                        value: { name: 'Option 5', id: '5' },
                        type: KbqPipeTypes.Select,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    },
                    {
                        name: 'MultiSelect',
                        value: [
                            { name: 'Option 1', id: '1' },
                            { name: 'Option 2', id: '2' }
                        ],
                        type: KbqPipeTypes.MultiSelect,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    }
                ]
            }
        ];
    }

    /**
     * The pristine versions the store holds. "Saved Filter 1" deliberately differs from the filter of
     * the same name above, so the bar starts with unsaved changes to demonstrate.
     */
    createSavedFilters(): KbqFilter[] {
        const [, saveError, deleteError] = this.createFilters();

        return [
            {
                name: 'Saved Filter 1',
                readonly: false,
                disabled: false,
                changed: false,
                saved: true,
                pipes: [
                    createSearchPipe(this.text().search),
                    {
                        name: 'Datetime',
                        value: this.periods.pick({ unit: 'days', amount: -7 }),
                        type: KbqPipeTypes.Datetime,

                        cleanable: false,
                        removable: false,
                        disabled: false
                    },
                    {
                        name: 'Select',
                        value: { name: 'Option 3', id: '3' },
                        type: KbqPipeTypes.Select,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    },
                    {
                        name: 'Text',
                        value: 'Angular Rules',
                        type: KbqPipeTypes.Text,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    }
                ]
            },
            structuredClone(saveError),
            structuredClone(deleteError)
        ];
    }

    /**
     * `openOnReset` is only for the filter the reset itself builds: the bar's reset stream stays latched
     * at `true`, so a pipe re-created for any other reason — a locale change, say — would replay it and
     * pop its panel open unprompted.
     */
    getDefaultFilter(openOnReset = false): KbqFilter {
        return {
            name: '',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [
                createSearchPipe(this.text().search),
                {
                    name: 'Date',
                    value: this.periods.pick({ unit: 'days', amount: -1 }),
                    type: KbqPipeTypes.Date,

                    cleanable: false,
                    removable: false,
                    disabled: false,
                    openOnReset
                }
            ]
        };
    }
}
```

### State Saving

The filter bar remembers which filter is selected and the edits made to it, and restores the state after a page reload. On by default — use `[useStateSaving]="false"` to turn it off on a specific component.

This is a different thing from the section above. There the user saves a named filter, and the application stores it; here the component keeps the state on the device.

**Example: filter-bar-state-saving** (`filter-bar-state-saving`)

```ts filter-bar-state-saving-example.ts
import { ChangeDetectionStrategy, Component, viewChild } from '@angular/core';
import { KbqButtonModule } from '@koobiq/components/button';
import {
    KbqFilter,
    KbqFilterBar,
    KbqFilterBarModule,
    KbqPipeTemplate,
    KbqPipeTypes
} from '@koobiq/components/filter-bar';

const SELECT_VALUES = [
    { name: 'Option 1', id: '1' },
    { name: 'Option 2', id: '2' },
    { name: 'Option 3', id: '3' }
];

@Component({
    selector: 'filter-bar-state-saving-example',
    imports: [KbqFilterBarModule, KbqButtonModule],
    template: `
        <kbq-filter-bar
            stateSavingKey="filter-bar-state-saving-example"
            [pipeTemplates]="pipeTemplates"
            [(filter)]="activeFilter"
        >
            <kbq-filters [filters]="filters" />

            @for (pipe of activeFilter?.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }

            <kbq-pipe-add />
        </kbq-filter-bar>

        <div class="example-filter-bar-state-saving__footer">
            <button kbq-button type="button" (click)="filterBar().clearSavedState()">Reset saved state</button>
        </div>
    `,
    styles: `
        .example-filter-bar-state-saving__footer {
            margin-top: var(--kbq-size-l);
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarStateSavingExample {
    protected readonly filterBar = viewChild.required(KbqFilterBar);

    /**
     * Pick a filter, change a pipe or add one, then reload the page — the selection and the edits come
     * back. What is stored is the filter's name and the values of its pipes, so an application that
     * renames or deletes a saved filter simply gets nothing restored for it.
     */
    protected activeFilter: KbqFilter | null = null;

    protected filters: KbqFilter[] = [
        {
            name: 'Errors',
            readonly: false,
            disabled: false,
            changed: false,
            saved: true,
            pipes: [
                {
                    name: 'Select',
                    type: KbqPipeTypes.Select,
                    value: SELECT_VALUES[0],
                    cleanable: true,
                    removable: true,
                    disabled: false
                }
            ]
        },
        {
            name: 'Everything else',
            readonly: false,
            disabled: false,
            changed: false,
            saved: true,
            pipes: [
                {
                    name: 'Text',
                    type: KbqPipeTypes.Text,
                    value: 'Angular',
                    cleanable: true,
                    removable: true,
                    disabled: false
                }
            ]
        }
    ];

    protected pipeTemplates: KbqPipeTemplate[] = [
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            values: SELECT_VALUES,
            cleanable: true,
            removable: true,
            disabled: false
        },
        {
            name: 'Text',
            type: KbqPipeTypes.Text,
            cleanable: true,
            removable: true,
            disabled: false
        }
    ];
}
```

Restoring writes through the `filter` model, so it overrides the value a `[filter]` binding supplied at initialization, and `filterChange` fires before the user has touched anything. Only a later change wins.

Project the pipes from the value the bar reports — bind `[(filter)]`, or assign what `(filterChange)` hands you. Restoring replaces the filter object and every pipe in it, exactly as picking a filter from `<kbq-filters>` already does, so a pipe projected from an array of your own is no longer the one in `filter`: editing it does not reach the persisted state, and its remove button stops working.

Values come back as new objects, which is what `compareWith` is for — see [Filter types](#filter-types). A filter is identified by its `name`, so one that is no longer in `filters` restores nothing. A pipe is rebuilt from the filter or from `pipeTemplates`, so one your application builds itself, outside both, is not restored — put it in `pipeTemplates` if it has to come back.

`clearSavedState()` removes what is stored, and `hasSavedState` says whether anything is. Neither has anything to do with `saveFilterState()` / `restoreFilterState()`, which snapshot the filter in memory within one session.

Keys, storage and expiry work the same for every component that persists — see [Saving component state](https://koobiq.io/en/components/core.md#saving-component-state).

### Localization

The filter bar takes its own strings — the filters menu, the reset button, pipe tooltips and the date pipe's custom-period flow — from `KbqLocaleService`. The data you pass in `pipeTemplates` and `filter` is never translated: those labels are yours to produce. The example below derives the period labels of its `datetime` pipe from the active locale with `DateFormatter.duration()`, so they follow the locale of the bar they belong to. The option list follows `pipeTemplates`, so handing the bar a new list relabels it in place. The label stored in a pipe's own value does not: `*kbqPipe` builds a pipe component once from the object it is given and ignores later changes to that binding, so the examples below rebuild their filters for it.

There are three ways to control the locale. `KBQ_DEFAULT_LOCALE_ID` is the fallback (`ru-RU`) used when nothing else is provided — it is a plain constant, not an injection token, so it cannot be provided. `KBQ_LOCALE_ID` fixes the locale once, when `KbqLocaleService` is constructed; it has to be provided alongside the service itself, because the service reads the token from the injector that created it. `KbqLocaleService.setLocale()` changes the locale at runtime.

Each bar below provides its own `KbqLocaleService`, so they are isolated from each other and from the language selector in the footer of this site.

**Example: filter-bar-localization** (`filter-bar-localization`)

```ts filter-bar-localization-example.ts
import { ChangeDetectionStrategy, Component, computed, linkedSignal, Provider } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LuxonDateAdapter } from '@koobiq/angular-luxon-adapter/adapter';
import {
    DateAdapter,
    DateFormatter,
    kbqInjectLocaleService,
    kbqLocaleIDProvider,
    kbqLocaleServiceProvider
} from '@koobiq/components/core';
import { KbqFilter, KbqFilterBarModule, KbqPipeTemplate, KbqPipeTypes } from '@koobiq/components/filter-bar';
import { KbqSelectModule } from '@koobiq/components/select';
import { injectLocalizedPeriods } from '../localized-data';

/**
 * The date pipe resolves `DateAdapter` and `DateFormatter` through the element injector, so both must be
 * provided next to the locale service. `imports: [LuxonDateModule]` would provide `DateAdapter` in the
 * environment injector instead, where it resolves the application-wide locale service and formats dates —
 * calendar month names included — in a different locale than the rest of the bar. `DateFormatter` is not
 * part of that module at all, so it has to be listed here in either case.
 */
const scopedDateProviders: Provider[] = [{ provide: DateAdapter, useClass: LuxonDateAdapter }, DateFormatter];

/**
 * The bar itself, identical in every variant. `KbqFilterBar` is the only component that reads
 * `KBQ_LOCALE_SERVICE` — its sub-components and pipes take their strings from the bar — so whichever
 * ancestor provides the service localizes the whole thing.
 *
 * The data is a different matter: the bar never translates what it is handed, so the period labels are
 * built here out of the active locale, and every variant below gets its own instance of them.
 */
@Component({
    selector: 'localization-demo-bar',
    imports: [KbqFilterBarModule],
    template: `
        <kbq-filter-bar [pipeTemplates]="pipeTemplates()" [(filter)]="filter">
            <kbq-filters [filters]="savedFilters" />

            @for (pipe of filter().pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }

            <kbq-pipe-add />

            <kbq-filter-reset (onResetFilter)="onResetFilter()" />
        </kbq-filter-bar>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class LocalizationDemoBar {
    private readonly periods = injectLocalizedPeriods();

    protected readonly savedFilters: KbqFilter[] = [];

    // Rebuilt whenever the locale changes: `*kbqPipe` builds a pipe component once from the object it is
    // given and ignores later changes to that binding, so the relabelled period only reaches the screen
    // as a new pipe object. Pipes write their value in place, so every bar needs its own instance anyway.
    protected readonly filter = linkedSignal(() => this.createFilter());

    protected readonly pipeTemplates = computed<KbqPipeTemplate[]>(() => [
        {
            name: 'Period',
            type: KbqPipeTypes.Datetime,
            // The three periods this example has always offered; the point here is the controls
            // around them, not the list.
            values: [
                this.periods.pick({ unit: 'hours', amount: -24 }),
                this.periods.pick({ unit: 'days', amount: -7 }),
                this.periods.pick({ unit: 'days', amount: -30 })
            ],
            cleanable: false,
            removable: false,
            disabled: false
        },
        {
            name: 'Status',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' }
            ],
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Tags',
            type: KbqPipeTypes.MultiSelect,
            selectAll: true,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' }
            ],
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Text',
            type: KbqPipeTypes.Text,
            cleanable: false,
            removable: true,
            disabled: false
        }
    ]);

    protected onResetFilter(): void {
        this.filter.set(this.createFilter());
    }

    private createFilter(): KbqFilter {
        return {
            name: '',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [
                {
                    name: 'Period',
                    type: KbqPipeTypes.Datetime,
                    value: this.periods.pick({ unit: 'hours', amount: -24 }),
                    cleanable: false,
                    removable: false,
                    disabled: false
                }
            ]
        };
    }
}

/** Without `KBQ_LOCALE_ID` the service falls back to `KBQ_DEFAULT_LOCALE_ID`, which is `ru-RU`. */
@Component({
    selector: 'localization-default-locale',
    imports: [LocalizationDemoBar],
    template: `
        <localization-demo-bar />
    `,
    providers: [kbqLocaleServiceProvider(), ...scopedDateProviders],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class LocalizationDefaultLocale {}

/**
 * `kbqLocaleIDProvider` sets the locale once, when the service is constructed. Both providers have to sit
 * in the same `providers` array: `KbqLocaleService` reads `KBQ_LOCALE_ID` from the injector that created
 * it, so providing the id without the service leaves the application-wide service — and its locale — in
 * charge.
 */
@Component({
    selector: 'localization-static-locale',
    imports: [LocalizationDemoBar],
    template: `
        <localization-demo-bar />
    `,
    providers: [kbqLocaleIDProvider('en-US'), kbqLocaleServiceProvider(), ...scopedDateProviders],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class LocalizationStaticLocale {}

/** `setLocale()` re-localizes every filter-bar under this component at runtime. */
@Component({
    selector: 'localization-runtime-locale',
    imports: [LocalizationDemoBar, KbqSelectModule, FormsModule],
    template: `
        <div class="layout-column layout-gap-s">
            <kbq-form-field style="max-width: 200px">
                <kbq-select [ngModel]="localeService.localeId()" (ngModelChange)="localeService.setLocale($event)">
                    @for (item of localeService.locales.items; track item.id) {
                        <kbq-option [value]="item.id">{{ item.name }}</kbq-option>
                    }
                </kbq-select>
            </kbq-form-field>

            <localization-demo-bar />
        </div>
    `,
    providers: [kbqLocaleServiceProvider(), ...scopedDateProviders],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class LocalizationRuntimeLocale {
    protected readonly localeService = kbqInjectLocaleService();
}

@Component({
    selector: 'filter-bar-localization-example',
    imports: [LocalizationDefaultLocale, LocalizationStaticLocale, LocalizationRuntimeLocale],
    template: `
        <div class="layout-column layout-gap-3xl">
            <div class="layout-column layout-gap-s">
                <div class="kbq-text-compact-strong">Default locale — KBQ_DEFAULT_LOCALE_ID (ru-RU)</div>
                <localization-default-locale />
            </div>

            <div class="layout-column layout-gap-s">
                <div class="kbq-text-compact-strong">Fixed locale — KBQ_LOCALE_ID ('en-US')</div>
                <localization-static-locale />
            </div>

            <div class="layout-column layout-gap-s">
                <div class="kbq-text-compact-strong">Runtime locale — KbqLocaleService.setLocale()</div>
                <localization-runtime-locale />
            </div>
        </div>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarLocalizationExample {}
```

## API

### Components

#### KbqFilterBar (component)

```ts
@Component({ selector: 'kbq-filter-bar, [kbq-filter-bar]' })
class KbqFilterBar implements KbqFilterBarHost {
    readonly selectedAllEqualsSelectedNothing = input<boolean>(true);
    readonly filter = model<KbqFilter | null>(null);
    readonly pipeTemplates = input<KbqPipeTemplate[]>([]);
    readonly onChangePipe = output<KbqPipe>();
    readonly onRemovePipe = output<KbqPipe>();
    readonly onClearPipe = output<KbqPipe>();
    readonly onClosePipe = output<KbqPipe>();
    readonly localeConfiguration: Signal<KbqFilterBarLocaleConfiguration>;
    readonly isSaved: Signal<boolean>;
    readonly isChanged: Signal<boolean>;
    readonly isSavedAndChanged: Signal<boolean>;
    readonly isReadOnly: Signal<boolean>;
    readonly isDisabled: Signal<boolean>;
    readonly onResetFilter: BehaviorSubject<boolean>;
    readonly internalFilterChanges: BehaviorSubject<KbqFilter | null>;
    readonly internalTemplatesChanges: BehaviorSubject<KbqPipeTemplate[] | null>;
    get hasSavedState(): boolean;
    clearSavedState(): void;
    removePipe(pipe: KbqPipe): void;
    saveFilterState(filter?: KbqFilter): void;
    restoreFilterState(filter?: KbqFilter): void;
    resetFilterChangedState(): void;
}
```

- `[selectedAllEqualsSelectedNothing]`: `boolean` — This is special logic that unselect all items when all selected because "all selected = nothing selected".
  Default is true
- `[(filter)]`: `KbqFilter | null` — Filter that is currently selected. A two-way-bindable `model()`: derived state (`isSaved`/`isChanged`/…)
  is exposed via `computed()` off it and change detection is driven by signal reactivity. `model()`
  auto-provides the `filterChange` output for `[(filter)]` two-way binding.
- `[pipeTemplates]`: `KbqPipeTemplate[]` — An array of templates that are used when adding a pipe. Also contains lists of options to select (values).
- `[useStateSaving]`: `boolean` — Whether the component remembers its state across reloads and restores it on the next render.
  Defaults to `true`, or to whatever `KBQ_STATE_SAVING_ENABLED` is provided as.
- `[stateSavingKey]`: `string` — The key the state is persisted under. While it is empty the key is derived from where the
  component sits in the document, which moves when the surrounding markup is restructured — an `id`
  on the component or any ancestor pins it just as well as this input does.
- `[localeOverrides]`: `KbqPartialLocaleData | undefined` — Localized strings for this element and its subtree, keyed by locale section. Every key is optional at
  every depth.
- `(onChangePipe)`: `KbqPipe` — Event that emits whenever the value of the pipe changes.
- `(onRemovePipe)`: `KbqPipe` — Event that emits whenever the pipe deleted.
- `(onClearPipe)`: `KbqPipe` — Event that emits whenever the pipe cleared.
- `(onClosePipe)`: `KbqPipe` — Event that emits whenever the select or multiselect pipe closed.
- `localeConfiguration`: `Signal<KbqFilterBarLocaleConfiguration>` — Localized strings and configuration for the filter-bar and its pipes.

  Read through a signal so that a runtime `setLocale()` reaches the pipes and the projected
  sub-components, which render these strings from their own `OnPush` views.
- `isSaved`: `Signal<boolean>` — Whether the current filter is saved
- `isChanged`: `Signal<boolean>` — Whether the current filter is changed
- `isSavedAndChanged`: `Signal<boolean>` — Whether the current filter is saved and changed
- `isReadOnly`: `Signal<boolean>` — Whether the current filter is readonly
- `isDisabled`: `Signal<boolean>` — Whether the current filter is disabled
- `onResetFilter`: `BehaviorSubject<boolean>` — Event that emits whenever the filter is reset.
- `internalFilterChanges`: `BehaviorSubject<KbqFilter | null>` — internal filter changes
- `internalTemplatesChanges`: `BehaviorSubject<KbqPipeTemplate[] | null>` — internal changes in templates
- `hasSavedState`: `boolean` — Whether state is currently persisted for this filter bar — restored on init, or written since.
  Always `false` while `useStateSaving` is unset, and `false` again after `clearSavedState()`.
- `clearSavedState()`: `void` — Removes the state persisted for this filter bar.

  Persistence itself stays on — the next change is written again. Unset `useStateSaving` to stop it.
  Unrelated to `restoreFilterState()`, which reverts to an in-memory snapshot within one session.
- `removePipe()`: `void` — Remove pipe from current filter and emit event
- `saveFilterState()`: `void` — Save current state of filter.

  Deep-clones the filter via `structuredClone`, so any pipe `value` payload handed to save/restore must be
  structured-cloneable (plain data — no functions, DOM nodes, class instances or `TemplateRef`s), otherwise
  `structuredClone` throws `DataCloneError`. All built-in pipes produce cloneable values.
- `restoreFilterState()`: `void` — Restore previously saved filter state. As with `saveFilterState`, pipe `value` payloads must be
  structured-cloneable.
- `resetFilterChangedState()`: `void` — Set the filter state "changed" to false

#### KbqFilterRefresher (component)

```ts
@Component({ selector: 'kbq-filter-refresher, [kbq-filter-refresher]' })
class KbqFilterRefresher {}
```

#### KbqFilterReset (component)

```ts
@Component({ selector: 'kbq-filter-reset' })
class KbqFilterReset {
    readonly onResetFilter = output<KbqFilter | null>();
}
```

- `(onResetFilter)`: `KbqFilter | null` — Event that is generated whenever the user reset a filter.

#### KbqFilters (component)

```ts
@Component({ selector: 'kbq-filters', exportAs: 'kbqFilters' })
class KbqFilters {
    readonly filters = input.required<KbqFilter[]>();
    readonly onSelectFilter = output<KbqFilter>();
    readonly onSave = output<KbqSaveFilterEvent>();
    readonly onChangeFilter = output<KbqSaveFilterEvent>();
    readonly onRemoveFilter = output<KbqFilter>();
    readonly onResetFilterChanges = output<KbqFilter | null>();
    readonly searchControl: FormControl<string | null>;
    readonly filteredOptions: Signal<KbqFilter[]>;
    get opened(): boolean;
    get filterActionsOpened(): boolean;
    get filter(): KbqFilter | null;
    get isEmpty(): boolean;
    selectFilter(filter: KbqFilter): void;
    saveChanges(): void;
    restoreFocus(): void;
    resetFilterChanges(): void;
    removeFilter(): void;
    filterSavedSuccessfully(): void;
    filterSavedUnsuccessfully(error?: KbqSaveFilterError): void;
}
```

- `(onSelectFilter)`: `KbqFilter` — Event that is generated whenever the user selects a filter.
- `(onSave)`: `KbqSaveFilterEvent` — Event that is generated whenever the user save a filter.

  The emitted `filter` shares its pipe objects with the live filter by design (so the rendered pipes keep
  their identity across a save instead of being destroyed and recreated). Consumers that persist
  `event.filter` as an immutable record should snapshot it first (e.g. via `KbqFilterBar.saveFilterState`
  or a structural copy) — otherwise later in-place pipe edits will mutate the stored reference.
- `(onChangeFilter)`: `KbqSaveFilterEvent` — Event that is generated whenever the user change a filter.
- `(onRemoveFilter)`: `KbqFilter` — Event that is generated whenever the user remove a filter.
- `(onResetFilterChanges)`: `KbqFilter | null` — Event that is generated whenever the user reset a filter changes.
- `searchControl`: `FormControl<string | null>` — control for search filter
- `filteredOptions`: `Signal<KbqFilter[]>` — Saved filters whose name matches the search query.
- `opened`: `boolean` — Component state. true if opened dropdown or popup
- `filterActionsOpened`: `boolean` — Component state. true if opened dropdown or popup of filterActions
- `filter`: `KbqFilter | null` — Selected filter. Kept as a plain getter facade over the bar's `filter` model.
- `isEmpty`: `boolean` — Component state. True if 'filters' input contains no elements.
- `filterSavedSuccessfully()`: `void` — Hide the popup and restore focus.
  Use this method in the onSave or onChangeFilter events after the data has been successfully saved.
- `filterSavedUnsuccessfully()`: `void` — Shows an error. Use this method in the onSave or onChangeFilter events if saving data failed.

#### KbqPipeAdd (component)

```ts
@Component({ selector: 'kbq-pipe-add' })
class KbqPipeAdd {
    readonly filterTemplate = input<KbqFilter>(
        {
            name: '',
            pipes: [],
            readonly: false,
            disabled: false,
            changed: false,
            saved: false
        }
    );
    readonly onAddPipe = output<KbqPipeTemplate>();
    readonly addedPipes: Signal<(string | number | (string & {}))[]>;
    addPipeFromTemplate(option: KbqOption): void;
    compareWith(o1: KbqPipe, o2: string): boolean;
}
```

- `[filterTemplate]`: `KbqFilter` — template of filter
- `(onAddPipe)`: `KbqPipeTemplate` — Event that is generated after add pipe.
- `addedPipes`: `Signal<(string | number | (string & {}))[]>` — Ids of the pipes already added to the current filter. Used to open an already-added pipe.
  Derived from the `filter` signal so it stays in sync without the retired `changes` bus.
- `compareWith()`: `boolean` — Function to compare the option values with the selected values. The first argument
  is a value from an option. The second is a value from the selection. A boolean
  should be returned.

#### KbqPipeButton (component)

```ts
@Component({ selector: 'kbq-pipe-button' })
class KbqPipeButton {
    readonly readonly = input<boolean>(false);
}
```

- `[readonly]`: `boolean` — enables/disables read-only state

#### KbqPipeDateComponent (component)

```ts
@Component({ selector: 'kbq-pipe-date' })
class KbqPipeDateComponent<D> extends KbqPipeDateBaseComponent<D> {
    get formattedValue(): string;
    get disabled(): boolean;
    get start(): D;
    get defaultStart(): D;
    get end(): D;
    get defaultEnd(): D;
    get isEmpty(): boolean;
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    get showRemoveButton(): boolean;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    onApplyPeriod(): void;
    onSelect(item: KbqDateTimeValue): void;
    showPeriod(): void;
    showList(): void;
    open(): void;
    onSelectStartDate(value: D): void;
    onSelectEndDate(value: D): void;
    onFocusStartInput(): void;
    onFocusEndInput(): void;
    hideCalendars(): void;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
    onClear(): void;
}
```

- `formattedValue`: `string` — formatted value for period
- `disabled`: `boolean` — Whether the current pipe is disabled.
- `start`: `D` — parsed start
- `defaultStart`: `D` — default object for start
- `end`: `D` — parsed end
- `defaultEnd`: `D` — default object for end
- `isEmpty`: `boolean` — Whether the current pipe is empty.
- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — updates values for selection and value template
- `open()`: `void` — opens popover
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes
- `onClear()`: `void` — clears the pipe and triggers changes

#### KbqPipeDatetimeComponent (component)

```ts
@Component({ selector: 'kbq-pipe-datetime' })
class KbqPipeDatetimeComponent<D> extends KbqPipeDateBaseComponent<D> {
    get formattedValue(): string;
    get disabled(): boolean;
    get start(): D;
    get defaultStart(): D;
    get end(): D;
    get defaultEnd(): D;
    get isEmpty(): boolean;
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    get showRemoveButton(): boolean;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    onApplyPeriod(): void;
    onSelect(item: KbqDateTimeValue): void;
    showPeriod(): void;
    showList(): void;
    open(): void;
    onSelectStartDate(value: D): void;
    onSelectEndDate(value: D): void;
    onFocusStartInput(): void;
    onFocusEndInput(): void;
    hideCalendars(): void;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
    onClear(): void;
}
```

- `formattedValue`: `string` — formatted value for period
- `disabled`: `boolean` — Whether the current pipe is disabled.
- `start`: `D` — parsed start
- `defaultStart`: `D` — default object for start
- `end`: `D` — parsed end
- `defaultEnd`: `D` — default object for end
- `isEmpty`: `boolean` — Whether the current pipe is empty.
- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — updates values for selection and value template
- `open()`: `void` — opens popover
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes
- `onClear()`: `void` — clears the pipe and triggers changes

#### KbqPipeInputComponent (component)

```ts
@Component({ selector: 'kbq-pipe-input' })
class KbqPipeInputComponent extends KbqBasePipe<string | null> {
    readonly control: FormControl<string | null>;
    debounceTime: number;
    minLength: number;
    get isEmpty(): boolean;
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    get showRemoveButton(): boolean;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    onClear(): void;
    open(): void;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
}
```

- `control`: `FormControl<string | null>` — Input control.

  Must stay nullable: `KbqCleaner` resets the control, and `reset()` resolves to `defaultValue`, which is
  `null` only while `nonNullable` is unset. `{ nonNullable: true }` would make `reset()` yield `''` and silently
  break the cleaner bridge below.
- `debounceTime`: `number` — Debounce in milliseconds between the last keystroke and the typed value being applied to the filter.
  Matches `defaultEmitValueTimeout` of `kbq-search-expandable`, so both search fields of a bar feel the
  same. `Enter` and blur apply immediately and cancel a pending apply.
- `minLength`: `number` — Minimum number of characters before typing applies the value to the filter. Anything shorter —
  including an emptied field — is applied as `null`, so the filter never keeps text the user has
  already erased. `Enter` and blur ignore the threshold. Set to `0` to apply every keystroke.
- `isEmpty`: `boolean` — Whether the current pipe is empty. Used for apply style modifier
- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — updates values for selection and value template
- `onClear()`: `void` — clears the pipe and triggers changes
- `open()`: `void` — focuses the input
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes

#### KbqPipeMultiSelectComponent (component)

```ts
@Component({ selector: 'kbq-pipe-multi-select' })
class KbqPipeMultiSelectComponent extends KbqBasePipe<KbqSelectValue[]> {
    readonly searchControl: FormControl<string | null>;
    readonly filteredOptions: Signal<KbqSelectValue[]>;
    get selected(): KbqSelectValue[] | null;
    get isEmpty(): boolean;
    get checkboxState(): KbqPseudoCheckboxState;
    get allVisibleOptionsSelected(): boolean;
    get allOptionsSelected(): boolean;
    get selectedAllEqualsSelectedNothing(): boolean;
    compareByValue: (o1: Pick<KbqSelectValue, "id"> | null, o2: Pick<KbqSelectValue, "id"> | null) => boolean;
    selectAllHandler: (event: KeyboardEvent) => void;
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    get showRemoveButton(): boolean;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    open(): void;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
}
```

- `searchControl`: `FormControl<string | null>` — control for search options
- `filteredOptions`: `Signal<KbqSelectValue[]>` — Options of the pipe template that match the search query. Follows the templates too, so options
  supplied after initialization render on first open.
- `selected`: `KbqSelectValue[] | null` — selected value
- `isEmpty`: `boolean` — Whether the current pipe is empty.
- `checkboxState`: `KbqPseudoCheckboxState` — state for checkbox 'select all'.
- `allVisibleOptionsSelected`: `boolean` — true if all visible options selected
- `allOptionsSelected`: `boolean` — true if all options selected
- `compareByValue`: `(o1: Pick<KbqSelectValue, "id"> | null, o2: Pick<KbqSelectValue, "id"> | null) => boolean` — Comparator of selected options. Two null/absent values never match (aligned with the select pipe).
- `selectAllHandler`: `(event: KeyboardEvent) => void` — handler for select all options in select
- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — updates values for selection and value template
- `open()`: `void` — opens select
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes

#### KbqPipeMultiTreeSelectComponent (component)

```ts
@Component({ selector: 'kbq-pipe-multi-tree-select' })
class KbqPipeMultiTreeSelectComponent extends KbqTreeSelectPipeBase<KbqSelectValue[]> {
    get selected(): KbqSelectValue[] | null;
    get isEmpty(): boolean;
    get selectAllCheckboxState(): KbqPseudoCheckboxState;
    get numberOfSelectedLeaves(): number;
    get allOptionsSelected(): boolean;
    get selectedAllEqualsSelectedNothing(): boolean;
    get allVisibleOptionsSelected(): boolean;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    selectAllHandler: (event: KeyboardEvent) => void;
    readonly searchControl: FormControl<string | null>;
    treeControl: FlatTreeControl<KbqTreeSelectFlatNode>;
    treeFlattener: KbqTreeFlattener<KbqTreeSelectNode, KbqTreeSelectFlatNode>;
    dataSource: KbqTreeFlatDataSource<KbqTreeSelectNode, KbqTreeSelectFlatNode>;
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    get showRemoveButton(): boolean;
    isNodeSelectAll(
        _: number,
        nodeData: KbqTreeSelectFlatNode
    ): boolean;
    onSelect({ value: option }: { value: any; }): void;
    searchKeydownHandler(): void;
    toggleSelectAllNode(emitEvent?: boolean): void;
    onClear(): void;
    hasChild(_: number, nodeData: KbqTreeSelectFlatNode): boolean;
    open(): void;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
}
```

- `selected`: `KbqSelectValue[] | null` — selected value
- `isEmpty`: `boolean` — Whether the current pipe is empty.
- `allOptionsSelected`: `boolean` — true if all options selected
- `allVisibleOptionsSelected`: `boolean` — true if all visible options selected
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — Populates the tree data source from the pipe template. The shared `values`/`valueTemplate`
  assignment is already performed by the base subscription, so this override only does the
  tree-specific work and avoids a redundant double assignment.
- `selectAllHandler`: `(event: KeyboardEvent) => void` — handler for select all options in select
- `searchControl`: `FormControl<string | null>` — control for search options
- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `hasChild()`: `boolean` — tree-node predicate: whether the node has children
- `open()`: `void` — opens the tree-select panel
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes

#### KbqPipeReadonlyComponent (component)

```ts
@Component({ selector: 'kbq-pipe-readonly' })
class KbqPipeReadonlyComponent extends KbqBasePipe<string | null> {
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    get isEmpty(): boolean;
    get showRemoveButton(): boolean;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    open(): void;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
    onClear(): void;
}
```

- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `isEmpty`: `boolean` — Whether the current pipe is empty. Used for apply style modifier
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — updates values for selection and value template
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes
- `onClear()`: `void` — clears the pipe and triggers changes

#### KbqPipeSelectComponent (component)

```ts
@Component({ selector: 'kbq-pipe-select' })
class KbqPipeSelectComponent extends KbqBasePipe<KbqSelectValue> {
    readonly searchControl: FormControl<string | null>;
    readonly filteredOptions: Signal<KbqSelectValue[]>;
    get selected(): KbqSelectValue | null;
    get isEmpty(): boolean;
    compareByValue: (o1: Pick<KbqSelectValue, "id"> | null, o2: Pick<KbqSelectValue, "id"> | null) => boolean;
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    get showRemoveButton(): boolean;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    onSelect(item: KbqSelectValue): void;
    open(): void;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
    onClear(): void;
}
```

- `searchControl`: `FormControl<string | null>` — control for search options
- `filteredOptions`: `Signal<KbqSelectValue[]>` — Options of the pipe template that match the search query. Follows the templates too, so options
  supplied after initialization render on first open.
- `selected`: `KbqSelectValue | null` — selected value
- `isEmpty`: `boolean` — Whether the current pipe is empty.
- `compareByValue`: `(o1: Pick<KbqSelectValue, "id"> | null, o2: Pick<KbqSelectValue, "id"> | null) => boolean` — Comparator of selected options
- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — updates values for selection and value template
- `open()`: `void` — opens select
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes
- `onClear()`: `void` — clears the pipe and triggers changes

#### KbqPipeTextComponent (component)

```ts
@Component({ selector: 'kbq-pipe-text' })
class KbqPipeTextComponent extends KbqBasePipe<string | null> {
    readonly placements: typeof PopUpPlacements;
    get disabled(): boolean;
    control: FormControl<typeof this.data.value>;
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    get isEmpty(): boolean;
    get showRemoveButton(): boolean;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    onApply(): void;
    onClear(): void;
    open(): void;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
}
```

- `disabled`: `boolean` — Whether the current pipe is disabled.
- `control`: `FormControl<typeof this.data.value>` — textarea control
- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `isEmpty`: `boolean` — Whether the current pipe is empty. Used for apply style modifier
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — updates values for selection and value template
- `onClear()`: `void` — clears the pipe and triggers changes
- `open()`: `void` — opens popover
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes

#### KbqPipeTreeSelectComponent (component)

```ts
@Component({ selector: 'kbq-pipe-tree-select' })
class KbqPipeTreeSelectComponent extends KbqTreeSelectPipeBase<KbqSelectValue> {
    get selected(): KbqSelectValue | null;
    get isEmpty(): boolean;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    readonly searchControl: FormControl<string | null>;
    treeControl: FlatTreeControl<KbqTreeSelectFlatNode>;
    treeFlattener: KbqTreeFlattener<KbqTreeSelectNode, KbqTreeSelectFlatNode>;
    dataSource: KbqTreeFlatDataSource<KbqTreeSelectNode, KbqTreeSelectFlatNode>;
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    get showRemoveButton(): boolean;
    onSelect(item: KbqTreeOption): void;
    hasChild(_: number, nodeData: KbqTreeSelectFlatNode): boolean;
    open(): void;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
    onClear(): void;
}
```

- `selected`: `KbqSelectValue | null` — selected value
- `isEmpty`: `boolean` — Whether the current pipe is empty.
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — Populates the tree data source from the pipe template. The shared `values`/`valueTemplate`
  assignment is already performed by the base subscription, so this override only does the
  tree-specific work and avoids a redundant double assignment.
- `searchControl`: `FormControl<string | null>` — control for search options
- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `hasChild()`: `boolean` — tree-node predicate: whether the node has children
- `open()`: `void` — opens the tree-select panel
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes
- `onClear()`: `void` — clears the pipe and triggers changes

### Directives

#### KbqBasePipe (directive)

```ts
@Directive()
abstract class KbqBasePipe<V> {
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    protected values: any;
    protected valueTemplate?: TemplateRef<any> | string;
    protected optionCompareWith?: ( o1: KbqSelectValue | null | undefined, o2: KbqSelectValue | null | undefined ) => boolean;
    protected lockedValues?: unknown[];
    protected panelMaxHeight?: KbqPanelMaxHeight;
    protected multilineOptions?: boolean;
    protected noOptionsText?: string;
    get isEmpty(): boolean;
    get showRemoveButton(): boolean;
    protected get noOptions(): boolean;
    protected get noOptionsMessage(): string;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
    onClear(): void;
}
```

- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `values`: `any` — values to select from the pipe template
- `valueTemplate?`: `TemplateRef<any> | string` — TemplateRef for selecting an option
- `optionCompareWith?`: `( o1: KbqSelectValue | null | undefined, o2: KbqSelectValue | null | undefined ) => boolean` — Optional equality comparator forwarded from the pipe template to the underlying `kbq-select`.
  Only consumed by the select / multi-select pipe components; when unset they fall back to their
  own id-based `compareByValue`.
- `lockedValues?`: `unknown[]` — Values forwarded from the pipe template whose selection the user cannot remove. Only consumed by
  the multi-select pipe components; other pipe types ignore it.
- `panelMaxHeight?`: `KbqPanelMaxHeight` — Maximum height of the dropdown panel's scrollable list, forwarded from the pipe template to the
  underlying `kbq-select` / `kbq-tree-select`. Only consumed by the select-family pipe components;
  `undefined` leaves the list at the select-family default of 256px.
- `multilineOptions?`: `boolean` — Whether the dropdown's option names and captions wrap instead of being truncated, forwarded from
  the pipe template. Only consumed by the select / multi-select pipe components.
- `noOptionsText?`: `string` — Message shown in place of the options when the pipe template has none, forwarded from the pipe
  template. Only consumed by the select-family pipe components; `undefined` falls back to the localized
  `pipe.noOptions` string.
- `isEmpty`: `boolean` — Whether the current pipe is empty. Used for apply style modifier
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `noOptions`: `boolean` — Whether the pipe template provides no values to choose from. The select-family pipe components then
  show `noOptionsMessage` instead of the list; the date pipes open straight on the custom period.
- `noOptionsMessage`: `string` — Message the select-family pipe components show when there are no options.
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — updates values for selection and value template
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes
- `onClear()`: `void` — clears the pipe and triggers changes

#### KbqFilterBarButton (directive)

```ts
@Directive({ selector: '[kbqFilterBarButton]' })
class KbqFilterBarButton {}
```

#### KbqPipeDateBaseComponent (directive)

Shared implementation for the `date` and `datetime` pipes. The two pipes differ only in how a range
is formatted and in their default start/end values, exposed here as the `formatRange`,
`getDefaultStart` and `getDefaultEnd` hooks. Everything else — the period list / custom
period flow, calendar handling and form group — is common.

```ts
@Directive()
abstract class KbqPipeDateBaseComponent<D> extends KbqBasePipe<KbqDateTimeValue> {
    protected readonly adapter: DateAdapter<any>;
    protected readonly formatter: DateFormatter<any>;
    protected readonly isListMode: WritableSignal<boolean>;
    get formattedValue(): string;
    get disabled(): boolean;
    get start(): D;
    get defaultStart(): D;
    get end(): D;
    get defaultEnd(): D;
    get isEmpty(): boolean;
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    protected values: any;
    protected valueTemplate?: TemplateRef<any> | string;
    protected optionCompareWith?: ( o1: KbqSelectValue | null | undefined, o2: KbqSelectValue | null | undefined ) => boolean;
    protected lockedValues?: unknown[];
    protected panelMaxHeight?: KbqPanelMaxHeight;
    protected multilineOptions?: boolean;
    protected noOptionsText?: string;
    get showRemoveButton(): boolean;
    protected get noOptions(): boolean;
    protected get noOptionsMessage(): string;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    onApplyPeriod(): void;
    onSelect(item: KbqDateTimeValue): void;
    showPeriod(): void;
    showList(): void;
    open(): void;
    onSelectStartDate(value: D): void;
    onSelectEndDate(value: D): void;
    onFocusStartInput(): void;
    onFocusEndInput(): void;
    hideCalendars(): void;
    protected abstract usesTime(): boolean;
    protected abstract formatRange(start: D, end: D): string;
    protected abstract getDefaultStart(): D;
    protected abstract getDefaultEnd(): D;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
    onClear(): void;
}
```

- `isListMode`: `WritableSignal<boolean>` — Whether the current state is list of periods. When false will displayed control for set custom period
- `formattedValue`: `string` — formatted value for period
- `disabled`: `boolean` — Whether the current pipe is disabled.
- `start`: `D` — parsed start
- `defaultStart`: `D` — default object for start
- `end`: `D` — parsed end
- `defaultEnd`: `D` — default object for end
- `isEmpty`: `boolean` — Whether the current pipe is empty.
- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `values`: `any` — values to select from the pipe template
- `valueTemplate?`: `TemplateRef<any> | string` — TemplateRef for selecting an option
- `optionCompareWith?`: `( o1: KbqSelectValue | null | undefined, o2: KbqSelectValue | null | undefined ) => boolean` — Optional equality comparator forwarded from the pipe template to the underlying `kbq-select`.
  Only consumed by the select / multi-select pipe components; when unset they fall back to their
  own id-based `compareByValue`.
- `lockedValues?`: `unknown[]` — Values forwarded from the pipe template whose selection the user cannot remove. Only consumed by
  the multi-select pipe components; other pipe types ignore it.
- `panelMaxHeight?`: `KbqPanelMaxHeight` — Maximum height of the dropdown panel's scrollable list, forwarded from the pipe template to the
  underlying `kbq-select` / `kbq-tree-select`. Only consumed by the select-family pipe components;
  `undefined` leaves the list at the select-family default of 256px.
- `multilineOptions?`: `boolean` — Whether the dropdown's option names and captions wrap instead of being truncated, forwarded from
  the pipe template. Only consumed by the select / multi-select pipe components.
- `noOptionsText?`: `string` — Message shown in place of the options when the pipe template has none, forwarded from the pipe
  template. Only consumed by the select-family pipe components; `undefined` falls back to the localized
  `pipe.noOptions` string.
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `noOptions`: `boolean` — Whether the pipe template provides no values to choose from. The select-family pipe components then
  show `noOptionsMessage` instead of the list; the date pipes open straight on the custom period.
- `noOptionsMessage`: `string` — Message the select-family pipe components show when there are no options.
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — updates values for selection and value template
- `open()`: `void` — opens popover
- `usesTime()`: `boolean` — Whether this pipe edits the time part (`datetime`) or only the date (`date`). Gates whether the
  `minDateTime` / `maxDateTime` bounds keep their time or are pinned to day edges.
- `formatRange()`: `string` — Formats the selected range for display.
- `getDefaultStart()`: `D` — Default start value used when the pipe has no stored `start`.
- `getDefaultEnd()`: `D` — Default end value used when the pipe has no stored `start`.
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes
- `onClear()`: `void` — clears the pipe and triggers changes

#### KbqPipeDirective (directive)

```ts
@Directive({ selector: '[kbqPipe]' })
class KbqPipeDirective<T extends KbqPipe> {
    readonly pipe = input<T>(undefined, { alias: 'kbqPipe' });
}
```

#### KbqPipeState (directive)

```ts
@Directive({ selector: '[kbqPipeState]' })
class KbqPipeState<T> {
    readonly state = input<T | null>(null, { alias: 'kbqPipeState' });
}
```

- `[kbqPipeState]`: `T | null` — Pipe state the styled button belongs to.

  Carries the binding this directive's selector matches. Every binding passes the pipe's own `data`,
  which is mutated in place, so this value is a constant for the life of the pipe and the style is
  derived from `pipe.isEmpty` instead.

#### KbqTreeSelectPipeBase (directive)

Shared scaffolding for the tree-based select pipes (`kbq-pipe-tree-select`,
`kbq-pipe-multi-tree-select`): the flat-tree control / flattener / data source, the node accessors,
search-driven filtering and open handling. Subclasses provide the value-specific pieces (`selected`,
`isEmpty`, `onSelect`, `updateTemplates`).

The `internalTemplatesChanges` subscription is intentionally left in each subclass constructor: it must
be set up after the subclass's `updateTemplates` field initializer runs, so the initial `BehaviorSubject`
replay dispatches to the subclass override (which writes `dataSource.data`) rather than the base one.

```ts
@Directive()
abstract class KbqTreeSelectPipeBase<V> extends KbqBasePipe<V> {
    readonly searchControl: FormControl<string | null>;
    treeControl: FlatTreeControl<KbqTreeSelectFlatNode>;
    treeFlattener: KbqTreeFlattener<KbqTreeSelectNode, KbqTreeSelectFlatNode>;
    dataSource: KbqTreeFlatDataSource<KbqTreeSelectNode, KbqTreeSelectFlatNode>;
    protected transformer: (node: KbqTreeSelectNode, level: number, parent: KbqTreeSelectFlatNode | null) => KbqTreeSelectFlatNode;
    protected getLevel: (node: KbqTreeSelectFlatNode) => number;
    protected isExpandable: (node: KbqTreeSelectFlatNode) => boolean;
    protected getChildren: (node: KbqTreeSelectNode) => KbqTreeSelectNode[] | null;
    protected getValue: (node: KbqTreeSelectFlatNode) => unknown;
    protected getViewValue: (node: KbqTreeSelectFlatNode) => string;
    readonly stateChanges: Subject<void>;
    readonly data: KbqPipeData<V>;
    protected values: any;
    protected valueTemplate?: TemplateRef<any> | string;
    protected optionCompareWith?: ( o1: KbqSelectValue | null | undefined, o2: KbqSelectValue | null | undefined ) => boolean;
    protected lockedValues?: unknown[];
    protected panelMaxHeight?: KbqPanelMaxHeight;
    protected multilineOptions?: boolean;
    protected noOptionsText?: string;
    get isEmpty(): boolean;
    get showRemoveButton(): boolean;
    protected get noOptions(): boolean;
    protected get noOptionsMessage(): string;
    updateTemplates: (templates: KbqPipeTemplate[] | null) => void;
    hasChild(_: number, nodeData: KbqTreeSelectFlatNode): boolean;
    open(): void;
    isTemplateRef(value: unknown): boolean;
    onRemove(): void;
    onClear(): void;
}
```

- `searchControl`: `FormControl<string | null>` — control for search options
- `stateChanges`: `Subject<void>` — changes of state
- `data`: `KbqPipeData<V>` — pipe data. Provided from subclass
- `values`: `any` — values to select from the pipe template
- `valueTemplate?`: `TemplateRef<any> | string` — TemplateRef for selecting an option
- `optionCompareWith?`: `( o1: KbqSelectValue | null | undefined, o2: KbqSelectValue | null | undefined ) => boolean` — Optional equality comparator forwarded from the pipe template to the underlying `kbq-select`.
  Only consumed by the select / multi-select pipe components; when unset they fall back to their
  own id-based `compareByValue`.
- `lockedValues?`: `unknown[]` — Values forwarded from the pipe template whose selection the user cannot remove. Only consumed by
  the multi-select pipe components; other pipe types ignore it.
- `panelMaxHeight?`: `KbqPanelMaxHeight` — Maximum height of the dropdown panel's scrollable list, forwarded from the pipe template to the
  underlying `kbq-select` / `kbq-tree-select`. Only consumed by the select-family pipe components;
  `undefined` leaves the list at the select-family default of 256px.
- `multilineOptions?`: `boolean` — Whether the dropdown's option names and captions wrap instead of being truncated, forwarded from
  the pipe template. Only consumed by the select / multi-select pipe components.
- `noOptionsText?`: `string` — Message shown in place of the options when the pipe template has none, forwarded from the pipe
  template. Only consumed by the select-family pipe components; `undefined` falls back to the localized
  `pipe.noOptions` string.
- `isEmpty`: `boolean` — Whether the current pipe is empty. Used for apply style modifier
- `showRemoveButton`: `boolean` — Whether the current pipe is removable or cleanable. Used for apply style modifier
- `noOptions`: `boolean` — Whether the pipe template provides no values to choose from. The select-family pipe components then
  show `noOptionsMessage` instead of the list; the date pipes open straight on the custom period.
- `noOptionsMessage`: `string` — Message the select-family pipe components show when there are no options.
- `updateTemplates`: `(templates: KbqPipeTemplate[] | null) => void` — updates values for selection and value template
- `hasChild()`: `boolean` — tree-node predicate: whether the node has children
- `open()`: `void` — opens the tree-select panel
- `isTemplateRef()`: `boolean` — templateRef checker utility
- `onRemove()`: `void` — removes pipe from filter-bar and triggers changes
- `onClear()`: `void` — clears the pipe and triggers changes

### Classes

#### KbqTreeSelectFlatNode (class)

Flattened node

```ts
class KbqTreeSelectFlatNode {
    name: string;
    value: unknown;
    level: number;
    expandable: boolean;
    parent?: KbqTreeSelectFlatNode;
}
```

### Functions

#### getId (function)

function to get unique identifier of an element

```ts
function getId(item: KbqPipeTemplate): KbqPipeType | string | number;
```

#### kbqBuildTree (function)

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 extends `KbqTreeSelectNode`.

```ts
function kbqBuildTree<T extends KbqTreeSelectNode>(
    value: Record<string, unknown>,
    level: number
): T[];
```

### Types

#### KbqDateTimeValue (interface)

```ts
interface KbqDateTimeValue {
    name?: string;
    start?: string;
    end?: string;
}
```

#### KbqFilter (interface)

```ts
interface KbqFilter {
    name: string;
    pipes: KbqPipe[];
    readonly: boolean;
    disabled: boolean;
    changed: boolean;
    saved: boolean;
}
```

#### KbqFilterBarHost (interface)

Contract a pipe (or filter-bar sub-component) depends on instead of the concrete `KbqFilterBar`.
`KbqFilterBar` provides itself as `KBQ_FILTER_BAR_HOST`, so a pipe can be unit-tested against a
lightweight mock of this seam rather than a full bar. Members mirror the current `KbqFilterBar` surface;
they are expected to slim down once the state model moves to signals.

```ts
interface KbqFilterBarHost {
    readonly localeConfiguration: Signal<KbqFilterBarLocaleConfiguration>;
    readonly filter: ModelSignal<KbqFilter | null>;
    readonly pipeTemplates: Signal<KbqPipeTemplate[]>;
    readonly selectedAllEqualsSelectedNothing: Signal<boolean>;
    readonly isSaved: Signal<boolean>;
    readonly isChanged: Signal<boolean>;
    readonly isSavedAndChanged: Signal<boolean>;
    readonly isReadOnly: Signal<boolean>;
    readonly onChangePipe: OutputEmitterRef<KbqPipe>;
    readonly onClearPipe: OutputEmitterRef<KbqPipe>;
    readonly onClosePipe: OutputEmitterRef<KbqPipe>;
    readonly internalFilterChanges: BehaviorSubject<KbqFilter | null>;
    readonly internalTemplatesChanges: BehaviorSubject<KbqPipeTemplate[] | null>;
    readonly openPipe: BehaviorSubject<string | number | null>;
    readonly onResetFilter: BehaviorSubject<boolean>;
    removePipe(pipe: KbqPipe): void;
    resetFilterChangedState(): void;
}
```

- `localeConfiguration`: `Signal<KbqFilterBarLocaleConfiguration>` — Localized strings and configuration for the filter-bar and its pipes. Read via `localeConfiguration()`:
  the bar derives it from the active locale, so an override is registered with
  `kbqFilterBarLocaleConfigurationProvider` or bound as `[localeOverrides]`.
- `filter`: `ModelSignal<KbqFilter | null>` — Currently selected filter. A two-way-bindable `model()`: read via `filter()`, write via `filter.set()`.
- `pipeTemplates`: `Signal<KbqPipeTemplate[]>` — Templates used when adding a pipe (also hold the option lists). Read via `pipeTemplates()`.
- `selectedAllEqualsSelectedNothing`: `Signal<boolean>` — Whether selecting all options counts as selecting nothing.
- `isSaved`: `Signal<boolean>` — Whether the current filter is saved.
- `isChanged`: `Signal<boolean>` — Whether the current filter is changed.
- `isSavedAndChanged`: `Signal<boolean>` — Whether the current filter is both saved and changed.
- `isReadOnly`: `Signal<boolean>` — Whether the current filter is readonly.
- `onChangePipe`: `OutputEmitterRef<KbqPipe>` — Emits whenever a pipe value changes.
  (The two-way-bound `filter` value is emitted by the `filter` model's `filterChange` output.)
- `onClearPipe`: `OutputEmitterRef<KbqPipe>` — Emits whenever a pipe is cleared.
- `onClosePipe`: `OutputEmitterRef<KbqPipe>` — Emits whenever a select/multiselect pipe closes.
- `internalFilterChanges`: `BehaviorSubject<KbqFilter | null>` — Internal filter changes.
- `internalTemplatesChanges`: `BehaviorSubject<KbqPipeTemplate[] | null>` — Internal pipe-template changes.
- `openPipe`: `BehaviorSubject<string | number | null>` — Requests that an already-added pipe open its pop-up. A one-shot command handled synchronously by the
  pipes that exist when it is dispatched, not retained state — clear it back to `null` after `next`. A
  pipe that has not been rendered yet is opened with `openOnAdd` on the pipe itself, not through this.
- `onResetFilter`: `BehaviorSubject<boolean>` — Emits when the filter is reset.
- `removePipe()`: `void` — Removes a pipe from the current filter and emits the change.
- `resetFilterChangedState()`: `void` — Clears the current filter's "changed" flag.

#### KbqFilterBarPipeState (interface)

One pipe as it is persisted: what identifies it, and what the user put in it.

Deliberately not the pipe itself. A pipe built from a template keeps whatever that template carried
(`pipe-add.ts` strips only `values` and `valueTemplate`), so it can hold a `compareWith` function or a
`DateTime` bound — things `JSON.stringify` drops silently rather than rejecting. Everything but the
value is rebuilt from the application's own filters and templates while restoring.

```ts
interface KbqFilterBarPipeState {
    id: string | number;
    value: unknown;
}
```

- `id`: `string | number` — `getId(pipe)` — the pipe's `id`, or its `name` when it has none.
- `value`: `unknown` — The pipe's value, in whatever shape the pipe stores it. Has to survive `JSON.stringify`.

#### KbqFilterBarState (interface)

The persisted state of a filter bar: which filter was selected, and the edits made to it.

```ts
interface KbqFilterBarState {
    name: string;
    changed: boolean;
    pipes: KbqFilterBarPipeState[];
}
```

- `name`: `string` — `KbqFilter.name`, the only identity a filter has. Empty for a filter the user never saved.
- `changed`: `boolean` — Whether the filter carried unsaved changes.
- `pipes`: `KbqFilterBarPipeState[]` — The filter's pipes, in order.

#### KbqFiltersHost (interface)

Contract a projected child depends on instead of the concrete `KbqFilters`. `KbqFilters` provides
itself as `KBQ_FILTERS`, so a child declared in its template does not import the component that
declares it.

```ts
interface KbqFiltersHost {
    saveFocusedElement(button?: KbqButton): void;
}
```

- `saveFocusedElement()`: `void` — Remembers the control focus returns to once the save popover closes.

#### KbqPipe (interface)

```ts
interface KbqPipe {
    name: string;
    id?: string | number;
    type: KbqPipeType;
    value: unknown | null;
    search?: boolean;
    selectAll?: boolean;
    openOnAdd?: boolean;
    openOnReset?: boolean;
    selectedAllEqualsSelectedNothing?: boolean;
    cleanable: boolean;
    removable: boolean;
    disabled: boolean;
}
```

- `selectedAllEqualsSelectedNothing?`: `boolean` — This is special logic that unselect all items when all selected because "all selected = nothing selected".
  Default is true

#### KbqPipeData (interface)

```ts
interface KbqPipeData<V> extends KbqPipe {
    value: V | null;
    name: string;
    id?: string | number;
    type: KbqPipeType;
    search?: boolean;
    selectAll?: boolean;
    openOnAdd?: boolean;
    openOnReset?: boolean;
    selectedAllEqualsSelectedNothing?: boolean;
    cleanable: boolean;
    removable: boolean;
    disabled: boolean;
}
```

- `selectedAllEqualsSelectedNothing?`: `boolean` — This is special logic that unselect all items when all selected because "all selected = nothing selected".
  Default is true

#### KbqPipeTemplate (interface)

```ts
interface KbqPipeTemplate extends Omit<KbqPipe, 'value'> {
    values?: unknown[];
    valueTemplate?: TemplateRef<any> | string;
    compareWith?: (o1: KbqSelectValue | null | undefined, o2: KbqSelectValue | null | undefined) => boolean;
    lockedValues?: unknown[];
    panelMaxHeight?: KbqPanelMaxHeight;
    multilineOptions?: boolean;
    noOptionsText?: string;
    minDateTime?: unknown;
    maxDateTime?: unknown;
    minInterval?: unknown;
    maxInterval?: unknown;
    disabled: boolean;
    name: string;
    selectAll?: boolean;
    search?: boolean;
    id?: string | number;
    type: KbqPipeType;
    openOnAdd?: boolean;
    openOnReset?: boolean;
    selectedAllEqualsSelectedNothing?: boolean;
    cleanable: boolean;
    removable: boolean;
}
```

- `compareWith?`: `(o1: KbqSelectValue | null | undefined, o2: KbqSelectValue | null | undefined) => boolean` — Custom equality comparator forwarded to the underlying `kbq-select` of the `select` /
  `multiselect` pipes. When omitted, the pipe's default id-based comparator (`compareByValue`)
  is used. Ignored by other pipe types.
- `lockedValues?`: `unknown[]` — Options whose selection cannot be removed by the user: they are always selected, render as
  `disabled` with a checked checkbox, and survive both "select all" toggling and clearing the pipe.

  Entries have the same shape as the entries of the owning pipe's `value`: `KbqSelectValue` objects
  for the `multiselect` pipe (matched with `compareWith`, falling back to the pipe's id-based
  comparator), and raw node values for the `multi-tree-select` pipe. Locking a tree branch locks its
  whole subtree. Ignored by other pipe types.
- `panelMaxHeight?`: `KbqPanelMaxHeight` — Maximum height of the dropdown panel's scrollable list, in pixels, forwarded to the underlying
  `kbq-select` / `kbq-tree-select` of the `select`, `multiselect`, `tree-select` and `multi-tree-select`
  pipes. Omitting it — or passing `null` — keeps the select-family default of 256px, i.e. exactly eight
  32px options.

  The cap covers the scrollable list only, and the list's own padding is added on top of it, so a
  multiple of the option height fits without a scrollbar. The pipe's search field is rendered above the
  list and adds to the panel's total height, while the "select all" row scrolls with the options and is
  counted by the cap. A value taller than the room left in the viewport is clipped by the overlay rather
  than scrolled. Ignored by other pipe types.
- `multilineOptions?`: `boolean` — Whether option names and captions in the dropdown wrap instead of being truncated to one line.
  Applies to the `select` and `multiselect` pipes; ignored by the other pipe types. Unrelated to
  `KbqSelect.multiline`, which lays the trigger's values out in rows.
- `noOptionsText?`: `string` — Message shown in the dropdown of the `select`, `multiselect`, `tree-select` and `multi-tree-select`
  pipes when `values` is empty. Defaults to the localized `pipe.noOptions` string. Ignored by other pipe
  types.
- `minDateTime?`: `unknown` — Earliest selectable instant for the `date` / `datetime` pipe custom period. Accepts any value the
  configured `DateAdapter` can deserialize (with the default Luxon adapter: an ISO-8601 string, a
  `Date`, or a `DateTime`). The `date` pipe uses only its day (pinned to the start of day). Ignored by
  other pipe types.
- `maxDateTime?`: `unknown` — Latest selectable instant for the `date` / `datetime` pipe custom period. See
  `KbqPipeTemplate.minDateTime`. The `date` pipe uses only its day (pinned to the end of day).
  Ignored by other pipe types.
- `minInterval?`: `unknown` — Minimum length of the `date` / `datetime` pipe custom period (`end − start`), as a Luxon
  duration-like object, e.g. `{ days: 1 }` or `{ hours: 2 }`. When the selected period is shorter, the
  editor shows an error and disables Apply. Ignored by other pipe types.

  Unlike `KbqPipeTemplate.minDateTime`, this value is NOT deserialized through the configured
  `DateAdapter` — it is applied directly via the date value's own `.plus()`, so this option currently
  assumes the default Luxon adapter and is not portable to other `DateAdapter` implementations (e.g.
  the Moment adapter).
- `maxInterval?`: `unknown` — Maximum length of the `date` / `datetime` pipe custom period (`end − start`). See
  `KbqPipeTemplate.minInterval`. When the selected period is longer, the editor shows an error and
  disables Apply. Ignored by other pipe types.
- `selectedAllEqualsSelectedNothing?`: `boolean` — This is special logic that unselect all items when all selected because "all selected = nothing selected".
  Default is true

#### KbqPipeType (type)

```ts
type KbqPipeType = `${KbqPipeTypes}` | (string & {});
```

#### KbqSaveFilterError (interface)

```ts
interface KbqSaveFilterError {
    text?: string;
    nameAlreadyExists?: boolean;
}
```

#### KbqSaveFilterEvent (interface)

```ts
interface KbqSaveFilterEvent {
    filter: KbqFilter;
    filterBar: KbqFilterBar;
    status?: KbqSaveFilterStatuses;
}
```

#### KbqSelectValue (interface)

```ts
interface KbqSelectValue {
    name: string;
    value: unknown;
    id?: string | number;
    caption?: string;
}
```

- `id?`: `string | number` — Optional stable identifier used by the select/multi-select pipe comparators.
- `caption?`: `string` — Second line under `name` in the dropdown of the `select` / `multiselect` pipes. Ignored where a
  `valueTemplate` owns the option.

#### KbqTreeSelectNode (interface)

```ts
interface KbqTreeSelectNode {
    children: KbqTreeSelectNode[] | null;
    name: string;
    value: unknown;
}
```

### Enums

#### KbqPipeTypes (enum)

list of pipe types available out of the box

```ts
enum KbqPipeTypes {
    ReadOnly = 'readonly',
    Text = 'text',
    Input = 'input',
    Select = 'select',
    TreeSelect = 'tree-select',
    MultiSelect = 'multiselect',
    MultiTreeSelect = 'multi-tree-select',
    Date = 'date',
    Datetime = 'datetime'
}
```

- `Input`: `'input'` — Renders a bare input: no popover, never opens. Commits while typing (debounced), on Enter and on blur.

#### KbqSaveFilterStatuses (enum)

saving statuses for KbqSaveFilterEvent

```ts
enum KbqSaveFilterStatuses {
    OnlyChanges = 'onlyChanges',
    NewName = 'newName',
    NewFilter = 'newFilter'
}
```

### Constants

#### defaultFilterBarPipes (const)

list of pipes available out of the box.

```ts
const defaultFilterBarPipes: [KbqPipeType, Type<KbqBasePipe<unknown>>][];
```

#### KBQ_FILTER_BAR_CONFIGURATION (const)

Deprecated: Use `KBQ_FILTER_BAR_LOCALE_CONFIGURATION`. Will be removed in 22.0.0.

```ts
const KBQ_FILTER_BAR_CONFIGURATION: InjectionToken<KbqFilterBarLocaleConfiguration>;
```

#### KBQ_FILTER_BAR_DEFAULT_CONFIGURATION (const)

Deprecated: Use `KBQ_FILTER_BAR_DEFAULT_LOCALE_CONFIGURATION`. Will be removed in 22.0.0.

```ts
const KBQ_FILTER_BAR_DEFAULT_CONFIGURATION: KbqFilterBarLocaleConfiguration;
```

#### KBQ_FILTER_BAR_DEFAULT_LOCALE_CONFIGURATION (const)

Default localized strings for the filter-bar, used when no `KBQ_LOCALE_SERVICE` (nor an explicit
`KBQ_FILTER_BAR_LOCALE_CONFIGURATION`) is provided. These are the Russian (`ru-RU`) strings, matching the
library-wide default-locale convention (every `KBQ_*_DEFAULT_LOCALE_CONFIGURATION` resolves to
`ruRULocaleData`).
Provide `KBQ_LOCALE_SERVICE` at the application root to localize the filter-bar for other locales.

```ts
const KBQ_FILTER_BAR_DEFAULT_LOCALE_CONFIGURATION: KbqFilterBarLocaleConfiguration;
```

#### KBQ_FILTER_BAR_HOST (const)

Injection token exposing the `KbqFilterBarHost` seam. `KbqFilterBar` provides itself via
`useExisting`, so pipes `inject(KBQ_FILTER_BAR_HOST)` instead of depending on the concrete bar.

```ts
const KBQ_FILTER_BAR_HOST: InjectionToken<KbqFilterBarHost>;
```

#### KBQ_FILTER_BAR_LOCALE_CONFIGURATION (const)

Injection Token for providing configuration of filter-bar

```ts
const KBQ_FILTER_BAR_LOCALE_CONFIGURATION: InjectionToken<KbqFilterBarLocaleConfiguration>;
```

#### KBQ_FILTER_BAR_PIPES (const)

Injection Token for providing pipes in filter-bar. Defaults to `defaultFilterBarPipes` so the
standalone `KbqFilterBar` works without `KbqFilterBarModule`; override it with
`kbqFilterBarPipesProvider` or a provider of your own.

```ts
const KBQ_FILTER_BAR_PIPES: InjectionToken<Map<KbqPipeType, Type<KbqBasePipe<unknown>>>>;
```

#### KBQ_FILTERS (const)

Injection token exposing the `KbqFiltersHost` seam.

```ts
const KBQ_FILTERS: InjectionToken<KbqFiltersHost>;
```

#### KBQ_PIPE_DATA (const)

Injection Token for providing configuration of filter-bar

```ts
const KBQ_PIPE_DATA: InjectionToken<unknown>;
```

#### kbqFilterBarLocaleConfigurationProvider (const)

Utility provider for `KBQ_FILTER_BAR_LOCALE_CONFIGURATION`. Only the strings you pass are overridden; the rest
keep following the active locale.

```ts
const kbqFilterBarLocaleConfigurationProvider: (
    configuration: KbqDeepPartial<KbqFilterBarLocaleConfiguration>
) => Provider;
```

#### kbqFilterBarPipesProvider (const)

Utility provider for `KBQ_FILTER_BAR_PIPES`.

```ts
const kbqFilterBarPipesProvider: () => Provider;
```

## Examples

### Example of adding a custom pipe

**Example: filter-bar-custom-pipe** (`filter-bar-custom-pipe`)

```ts filter-bar-custom-pipe-example.ts
import {
    AfterViewInit,
    ChangeDetectionStrategy,
    Component,
    computed,
    OnInit,
    viewChild,
    ViewEncapsulation
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter';
import { KbqButtonModule } from '@koobiq/components/button';
import { DateFormatter, PopUpPlacements } from '@koobiq/components/core';
import { KbqDividerModule } from '@koobiq/components/divider';
import {
    defaultFilterBarPipes,
    KBQ_FILTER_BAR_PIPES,
    KbqBasePipe,
    KbqFilter,
    KbqFilterBarModule,
    KbqPipeButton,
    KbqPipeState,
    KbqPipeTemplate,
    KbqPipeTypes
} from '@koobiq/components/filter-bar';
import { KbqInputModule } from '@koobiq/components/input';
import { KbqPopoverModule, KbqPopoverTrigger } from '@koobiq/components/popover';
import { KbqTextareaModule } from '@koobiq/components/textarea';
import { KbqTitleModule } from '@koobiq/components/title';
import { injectLocalizedPeriods } from '../localized-data';

@Component({
    selector: 'color-pipe',
    imports: [
        KbqButtonModule,
        KbqPopoverModule,
        KbqInputModule,
        KbqDividerModule,
        FormsModule,
        KbqTextareaModule,
        ReactiveFormsModule,
        KbqTitleModule,
        KbqPipeState,
        KbqPipeButton
    ],
    template: `
        <button
            kbq-button
            kbqPopover
            [disabled]="data.disabled"
            [kbqPipeState]="data"
            [kbq-title]="pipeTooltip"
            [kbqPopoverArrow]="false"
            [kbqPopoverClass]="'kbq-pipe__popover'"
            [kbqPopoverContent]="content"
            [kbqPopoverOffset]="4"
            [kbqPopoverPlacement]="placements.BottomLeft"
            [class]="{ 'kbq-active': popover().isOpen }"
        >
            <span #kbqTitleText class="kbq-pipe__name">{{ data.name }}</span>
            <span #kbqTitleText class="kbq-pipe__value" [class.kbq-pipe__value_empty]="!data.value">
                {{ data.value }}
            </span>
        </button>

        @if (showRemoveButton) {
            <kbq-pipe-button />
        }

        <ng-template #content>
            <input
                class="layout-margin-bottom-s"
                type="color"
                style="width:100%;"
                [formControl]="control"
                (keydown.enter)="popover().hide()"
            />
        </ng-template>

        <ng-template #pipeTooltip>
            <div class="kbq-pipe-tooltip__name kbq-text-compact">{{ data.name }}</div>
            <div class="kbq-pipe-tooltip__value kbq-text-compact">{{ data.value }}</div>
        </ng-template>
    `,
    styleUrls: ['../../../../components/filter-bar/pipes/base-pipe.scss'],
    providers: [
        {
            provide: KbqBasePipe,
            useExisting: this
        }
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    encapsulation: ViewEncapsulation.None
})
export class ColorPipeComponent extends KbqBasePipe<string | null> implements AfterViewInit, OnInit {
    readonly placements = PopUpPlacements;

    readonly popover = viewChild.required(KbqPopoverTrigger);

    get isEmpty(): boolean {
        return this.data.value === null || this.data.value === undefined;
    }

    get showRemoveButton(): boolean {
        return this.data.removable || (this.data.cleanable && !this.isEmpty);
    }

    get disabled(): boolean {
        return !this.control.value;
    }

    control = new FormControl<typeof this.data.value>('');

    ngOnInit(): void {
        this.control.setValue(this.data.value);
    }

    ngAfterViewInit() {
        super.ngAfterViewInit();

        this.popover()
            .visibleChange.pipe(takeUntilDestroyed(this.destroyRef))
            .subscribe((visible) => {
                if (!visible) {
                    this.data.value = this.control.value;
                    this.filterBar?.onChangePipe.emit(this.data);
                }

                this.stateChanges.next();
            });
    }

    override open() {
        this.popover().show();
    }
}

@Component({
    selector: 'filter-bar-custom-pipe-example',
    imports: [
        KbqFilterBarModule,
        LuxonDateModule
    ],
    template: `
        <kbq-filter-bar [pipeTemplates]="pipeTemplates()" [(filter)]="filter">
            @for (pipe of filter.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }

            <kbq-pipe-add />

            @if (filter.pipes.length) {
                <kbq-filter-reset (onResetFilter)="filter.pipes = []" />
            }
        </kbq-filter-bar>
    `,
    providers: [
        DateFormatter,
        {
            provide: KBQ_FILTER_BAR_PIPES,
            useValue: new Map([...defaultFilterBarPipes, ['colorPipe', ColorPipeComponent]])
        }
    ],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarCustomPipeExample {
    filters: KbqFilter[] = [];
    filter: KbqFilter = {
        name: '',
        readonly: false,
        disabled: false,
        changed: true,
        saved: false,
        pipes: [
            {
                name: 'colorPipe',
                type: 'colorPipe',
                value: '#112233',

                cleanable: false,
                removable: true,
                disabled: false
            }
        ]
    };

    /** Period labels follow the active locale, so the templates are rebuilt whenever it changes. */
    protected readonly periods = injectLocalizedPeriods();

    readonly pipeTemplates = computed<KbqPipeTemplate[]>(() => [
        {
            name: 'colorPipe',
            type: 'colorPipe',

            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Date',
            type: KbqPipeTypes.Date,
            values: this.periods.date(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Datetime',
            type: KbqPipeTypes.Datetime,
            values: this.periods.datetime(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],

            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Text',
            type: KbqPipeTypes.Text,

            cleanable: false,
            removable: true,
            disabled: false
        }
    ]);
}
```

```scss ../../../../components/filter-bar/pipes/base-pipe.scss
@use '../../core/styles/common';
@use '../filter-bar-theme' as *;

.kbq-pipe {
    display: flex;
    flex-direction: row;

    // Caps the whole pipe, clear and remove buttons included. The label's grid tracks have no
    // min-content, so the trigger gives way down to its own paddings without `min-width: 0`.
    max-width: var(--kbq-filter-bar-pipe-max-width, 320px);
    max-height: 32px;

    & .kbq-select .kbq-select__trigger {
        height: unset;
    }

    // The button's default slot is an inline text box; a pipe puts its own row of two independently
    // truncated parts (name + value) in there, as two shrinkable tracks: as flex items they would give
    // up the same percentage, which wipes out a short name long before a long value becomes unreadable.
    // Qualified with `.kbq-button` on purpose: the base rule is `.kbq-button .kbq-button-text`, so a
    // two-class selector would only win on source order, which differs between app bundles.
    .kbq-button .kbq-button-text {
        display: grid;
        grid-auto-flow: column;
        grid-auto-columns: minmax(0, max-content);
        align-items: center;
    }

    .kbq-pipe__name {
        @include common.kbq-truncate-line();
    }

    .kbq-pipe__value {
        @include common.kbq-truncate-line();

        &:not(.kbq-pipe__value_empty) {
            margin-inline-start: 4px;
        }
    }

    .kbq-select:not(:hover, .kbq-active),
    .kbq-tree-select:not(:hover, .kbq-active),
    .kbq-button:not(:hover, .kbq-active, [disabled]) {
        & + .kbq-pipe-button:not(:hover, .kbq-active)::after {
            content: '';
            position: absolute;
            inset-inline-start: 0;
            top: var(--kbq-size-s);

            width: 1px;
            height: var(--kbq-size-l);
        }
    }

    .kbq-button.cdk-keyboard-focused {
        z-index: 1;
    }
}

.kbq-pipe.kbq-pipe_removable,
.kbq-pipe.kbq-pipe_cleanable:not(.kbq-pipe_empty) {
    .kbq-button {
        border-end-end-radius: unset;
        border-start-end-radius: unset;
        border-inline-end: none;

        &.cdk-keyboard-focused::after {
            content: '';
            position: absolute;
            inset-inline-end: 0;
            top: 0;
            bottom: 0;
            width: 1px;
        }
    }

    .kbq-button-icon {
        border-end-start-radius: unset;
        border-start-start-radius: unset;
        border-inline-start: none;

        &.cdk-keyboard-focused::after {
            content: '';
            position: absolute;
            inset-inline-start: 0;
            top: 0;
            bottom: 0;
            width: 1px;
        }
    }
}

// The dropdown is portaled into the overlay, so it is reachable only through the pipe's `panelClass`.
.kbq-pipe-select__panel_multiline,
.kbq-pipe-multiselect__panel_multiline {
    // `overflow: hidden` from `kbq-list-text()` stays: it gives the flex item a minimum size of 0, so the
    // text shrinks to the option width instead of stretching the row.
    .kbq-option-text {
        white-space: normal;
        overflow-wrap: break-word;
    }
}

@include kbq-filter-bar-pipe-theme();
```

### Example of multiple pipes with the same type

**Example: filter bar** (`filter-bar-uniq-pipes`)

```ts filter-bar-uniq-pipes-example.ts
import { ChangeDetectionStrategy, Component, computed, linkedSignal } from '@angular/core';
import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter';
import { DateFormatter } from '@koobiq/components/core';
import {
    KbqFilter,
    KbqFilterBar,
    KbqFilterBarModule,
    KbqPipe,
    KbqPipeTemplate,
    KbqPipeTypes,
    KbqSaveFilterEvent,
    KbqSaveFilterStatuses
} from '@koobiq/components/filter-bar';
import { injectLocalizedPeriods, injectLocalizedText } from '../localized-data';

/** Text search is the first pipe in every filter: always present, never removable. */
const createSearchPipe = (name: string): KbqPipe => ({
    name,
    type: KbqPipeTypes.Input,
    value: null,

    cleanable: true,
    removable: false,
    disabled: false
});

@Component({
    selector: 'filter-bar-uniq-pipes-example',
    imports: [KbqFilterBarModule, LuxonDateModule],
    template: `
        <kbq-filter-bar
            [pipeTemplates]="pipeTemplates()"
            [filter]="activeFilter()"
            (filterChange)="onFilterChange($event)"
        >
            <kbq-filters
                [filters]="filters()"
                (onSave)="onSaveFilter($event)"
                (onResetFilterChanges)="onResetFilterChanges($event)"
                (onRemoveFilter)="onDeleteFilter($event)"
            />

            @for (pipe of activeFilter()?.pipes; track pipe) {
                <ng-container *kbqPipe="pipe" />
            }

            <kbq-pipe-add />

            @if (activeFilter()?.name !== defaultFilter()?.name || activeFilter()?.changed) {
                <kbq-filter-reset (onResetFilter)="onResetFilter()" />
            }
        </kbq-filter-bar>
    `,
    providers: [DateFormatter],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarUniqPipesExample {
    /** Period labels follow the active locale, so everything built out of them is rebuilt with it. */
    protected readonly periods = injectLocalizedPeriods();

    /** Text the example owns: it has no counterpart in the library's locale data. */
    protected readonly text = injectLocalizedText({
        'ru-RU': { search: 'Поиск' },
        default: { search: 'Search' }
    });

    // Everything below is derived from the period labels, so it is rebuilt whenever the locale changes:
    // `*kbqPipe` builds a pipe component once from the object it is given and ignores later changes to
    // that binding, so a relabelled period only reaches the screen as a new pipe object.
    readonly filters = linkedSignal(() => this.createFilters());
    readonly savedFilters = computed(() => structuredClone(this.createFilters()));

    readonly defaultFilter = computed<KbqFilter | null>(() => this.getDefaultFilter());
    readonly activeFilter = linkedSignal<KbqFilter | null>(() => this.getDefaultFilter());

    readonly pipeTemplates = computed<KbqPipeTemplate[]>(() => [
        {
            name: 'Date',
            type: KbqPipeTypes.Date,
            values: this.periods.date(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'myDate',
            type: KbqPipeTypes.Date,
            values: this.periods.date().slice(0, 3),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Datetime',
            type: KbqPipeTypes.Datetime,
            values: this.periods.datetime(),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Datetime_1',
            id: 'myDatetime',
            type: KbqPipeTypes.Datetime,
            values: this.periods.datetime().slice(0, 3),
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'MultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'MultiSelect_1',
            id: 'myMultiSelect',
            type: KbqPipeTypes.MultiSelect,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' }
            ],
            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Select',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],

            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Select_1',
            id: 'mySelect',
            type: KbqPipeTypes.Select,
            values: [
                { name: 'Option 1', id: '1' },
                { name: 'Option 2', id: '2' },
                { name: 'Option 3', id: '3' },
                { name: 'Option 4', id: '4' },
                { name: 'Option 5', id: '5' },
                { name: 'Option 6', id: '6' },
                { name: 'Option 7', id: '7' }
            ],

            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Text',
            type: KbqPipeTypes.Text,

            cleanable: false,
            removable: true,
            disabled: false
        },
        {
            name: 'Text_1',
            id: 'myText',
            type: KbqPipeTypes.Text,

            cleanable: false,
            removable: true,
            disabled: false
        }
    ]);

    onFilterChange(filter: KbqFilter | null) {
        // KbqFilterBar flips `changed` to true on any pipe edit but never back to false.
        // Re-derive it by diffing the pipes against the filter's initial (saved/default) state,
        // so reverting the pipes also clears `changed` and hides <kbq-filter-reset>.
        const initialFilter = filter ? this.getInitialFilter(filter) : null;

        this.activeFilter.set(
            filter && initialFilter && this.arePipesEqual(filter.pipes, initialFilter.pipes)
                ? { ...filter, changed: false }
                : filter
        );
    }

    onResetFilter() {
        console.log('onResetFilter: ');
        this.activeFilter.set(this.getDefaultFilter());
    }

    onResetFilterChanges(filter: KbqFilter | null) {
        console.log('onResetFilterChanges: ');
        const defaultFilter = this.getSavedFilter(filter);

        this.filters.update((filters) => filters.map((item) => (item.name === filter?.name ? defaultFilter : item)));

        this.activeFilter.set(defaultFilter);
    }

    onDeleteFilter(filter: KbqFilter) {
        this.filters.update((filters) => filters.filter(({ name }) => name !== filter?.name));

        this.activeFilter.set(this.getDefaultFilter());
    }

    onSaveFilter({ filter, filterBar, status }: KbqSaveFilterEvent) {
        if (status === KbqSaveFilterStatuses.NewFilter) {
            this.saveNewFilter(filter, filterBar);
        } else if (status === KbqSaveFilterStatuses.NewName) {
            this.saveCurrentFilterWithNewName(filter, filterBar);
        } else if (status === KbqSaveFilterStatuses.OnlyChanges) {
            this.saveCurrentFilterWithChangesInPipes(filter, filterBar);
        }
    }

    saveNewFilter(filter: KbqFilter, filterBar: KbqFilterBar) {
        // This logic simulates the behavior of the backend
        if (!this.filters().some(({ name }) => name === filter.name)) {
            this.filters.update((filters) => [...filters, filter]);

            this.activeFilter.set(filter);

            filterBar.filters()?.filterSavedSuccessfully();
        } else {
            filterBar.filters()?.filterSavedUnsuccessfully({ nameAlreadyExists: true });
        }
    }

    saveCurrentFilterWithNewName(filter: KbqFilter, filterBar: KbqFilterBar) {
        // This logic simulates the behavior of the backend
        if (filterBar.filter()?.name === filter.name) {
            filterBar.filters()?.filterSavedUnsuccessfully({ nameAlreadyExists: true });

            return;
        }

        const currentName = filterBar.filter()?.name;

        // A filter the store does not know about cannot be renamed.
        if (!this.filters().some(({ name }) => name === currentName)) {
            filterBar.filters()?.filterSavedUnsuccessfully();

            return;
        }

        // Renaming writes the name only: the stored pipes stay as they were saved, so unsaved changes
        // in the bar are not persisted along with the new name.
        this.filters.update((filters) =>
            filters.map((item) => (item.name === currentName ? { ...item, name: filter.name } : item))
        );

        this.activeFilter.set(filter);

        filterBar.filters()?.filterSavedSuccessfully();
    }

    saveCurrentFilterWithChangesInPipes(filter: KbqFilter, filterBar: KbqFilterBar) {
        // This logic simulates the behavior of the backend
        this.filters.update((filters) => filters.map((item) => (item.name === filter.name ? filter : item)));

        this.activeFilter.set(filter);

        filterBar.filters()?.filterSavedSuccessfully();
    }

    getSavedFilter(filter: KbqFilter | null): KbqFilter {
        return structuredClone(this.savedFilters().find(({ name }) => name === filter?.name)!);
    }

    /** Pristine (saved or default) version of a filter — the baseline for change detection. */
    getInitialFilter(filter: KbqFilter): KbqFilter | null {
        return filter.name === this.defaultFilter()?.name
            ? this.defaultFilter()
            : (this.savedFilters().find(({ name }) => name === filter.name) ?? null);
    }

    /** Whether two pipe lists are equivalent (same name/type/value) — detects a return to the initial state. */
    arePipesEqual(a: KbqPipe[], b: KbqPipe[]): boolean {
        const serialize = (pipes: KbqPipe[]): string =>
            JSON.stringify(pipes.map(({ name, type, value }) => ({ name, type, value })));

        return serialize(a) === serialize(b);
    }

    createFilters(): KbqFilter[] {
        return [
            {
                name: 'Saved Filter 1',
                readonly: false,
                disabled: false,
                changed: false,
                saved: true,
                pipes: [
                    createSearchPipe(this.text().search),
                    {
                        name: 'Datetime',
                        value: this.periods.pick({ unit: 'days', amount: -7 }),
                        type: KbqPipeTypes.Datetime,

                        cleanable: false,
                        removable: false,
                        disabled: false
                    },
                    {
                        name: 'Select',
                        value: { name: 'Option 6', id: '6' },
                        type: KbqPipeTypes.Select,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    },
                    {
                        name: 'Text',
                        value: 'Angular Rules',
                        type: KbqPipeTypes.Text,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    }
                ]
            },
            {
                name: 'Saved Filter 2',
                readonly: false,
                disabled: false,
                changed: false,
                saved: true,
                pipes: [
                    createSearchPipe(this.text().search),
                    {
                        name: 'Datetime',
                        value: this.periods.pick({ unit: 'years', amount: -1 }),
                        type: KbqPipeTypes.Datetime,

                        cleanable: false,
                        removable: false,
                        disabled: false
                    },
                    {
                        name: 'MultiSelect',
                        value: [
                            { name: 'Option 1', id: '1' },
                            { name: 'Option 3', id: '3' },
                            { name: 'Option 4', id: '4' }
                        ],
                        type: KbqPipeTypes.MultiSelect,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    },
                    {
                        name: 'Date',
                        value: this.periods.pick({ unit: 'days', amount: -7 }),
                        type: KbqPipeTypes.Date,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    }
                ]
            },
            {
                name: 'Saved Filter 3',
                readonly: false,
                disabled: false,
                changed: false,
                saved: true,
                pipes: [
                    createSearchPipe(this.text().search),
                    {
                        name: 'Datetime',
                        value: this.periods.pick({ unit: 'days', amount: -3 }),
                        type: KbqPipeTypes.Datetime,

                        cleanable: false,
                        removable: false,
                        disabled: false
                    },
                    {
                        name: 'Select',
                        value: { name: 'Option 5', id: '5' },
                        type: KbqPipeTypes.Select,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    },
                    {
                        name: 'MultiSelect',
                        value: [
                            { name: 'Option 1', id: '1' },
                            { name: 'Option 2', id: '2' }
                        ],
                        type: KbqPipeTypes.MultiSelect,

                        cleanable: false,
                        removable: true,
                        disabled: false
                    }
                ]
            }
        ];
    }

    getDefaultFilter(): KbqFilter {
        return {
            name: '',
            readonly: false,
            disabled: false,
            changed: false,
            saved: false,
            pipes: [
                createSearchPipe(this.text().search),
                {
                    name: 'Datetime',
                    value: this.periods.pick({ unit: 'hours', amount: -24 }),
                    type: KbqPipeTypes.Datetime,

                    cleanable: false,
                    removable: false,
                    disabled: false
                }
            ]
        };
    }
}
```
