Skip to content
27 changes: 27 additions & 0 deletions docs/guides/migration.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,33 @@ in templates and in TypeScript code (for example `.fileQueueChanged.subscribe(..
matches an unrelated string, attribute value or identifier of your own that happens to carry the same name,
so review the diff before committing.

#### Tree

The tree moved its inputs and its query members to signals. Six members that used to be writable are getters now — over a `computed()`, over an `InputSignal`, or over an `asObservable()` view of a `Subject`.

**An unmigrated write does not merely stop compiling.** Assigning to a getter-only property throws `TypeError: Cannot set property … which has only a getter` in strict mode, and an ES module is always strict, so it throws at runtime in any build that skips type checking.

| Member | Was | Is |
| ------------------------------------------ | ------------------------------ | -------------------------------------- |
| `KbqTreeNodeToggle.disabled` | `@Input()` accessor pair | getter over a `computed()` |
| `KbqTreeBase.nodeDefs` | `QueryList<KbqTreeNodeDef<T>>` | `Signal<readonly KbqTreeNodeDef<T>[]>` |
| `KbqTreeNodePadding.indent` | accessor pair | `InputSignal<number \| string>` |
| `KbqTreeNodePadding.indentUnits` | writable field | getter derived from `indent` |
| `KbqTreeNodeToggleBaseDirective.recursive` | accessor pair | `InputSignalWithTransform<boolean, …>` |
| `KbqTreeOption.onFocus` / `onBlur` | `Subject<KbqTreeOptionEvent>` | `Observable<KbqTreeOptionEvent>` |

| Pattern | Manual migration |
| ------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `<toggle>.disabled = …` | Bind the `disabled` attribute; it still reaches the toggle through `disabledInput` |
| `<toggle>.recursive` | Read `recursive()`; bind `kbqTreeNodeToggleRecursive` |
| `nodeDefs.changes` / `.length` / `.toArray()` / … | Read `nodeDefs()`; replace the subscription with an `effect` |
| `<padding>.indent` / `.indentUnits` | Read `indent()`; bind `kbqTreeNodePaddingIndent` |
| `<option>.onFocus.next(…)` / `onBlur.next(…)` | Subscribe instead — the option emits on both streams itself |

Two of the six are silent rather than loud. `KbqTreeBase` is exported and is the documented extension point for a custom tree: a subclass reading `this.nodeDefs.length` now gets `0` — the arity of the signal function — instead of the number of node definitions, and `this.nodeDefs.changes.subscribe(…)` throws. And `KbqTreeNodeToggle` kept `disabled` as the input alias (the input itself is declared as `disabledInput`), so every template binding keeps working and only imperative writes break.

Reported by `tree-signals`. A project that renders a tree at all also gets a summary of all six members, because five of them are only visible at a call site that writes them — a consumer that merely reads one gets a value whose type changed under it and no diagnostic at all.

### After the migration

The migration is regex-based and does not rewrite aliased imports, local variables, or re-exports — **review the diff before committing**, rebuild the project and run your tests. The full list of breaking changes is on the [Angular 20 breaking changes](https://github.com/koobiq/angular-components/blob/main/docs/guides/angular-20-breaking-changes.en.md) page.
27 changes: 27 additions & 0 deletions docs/guides/migration.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,33 @@ ng g @koobiq/components:file-upload-deprecated-outputs --project <your project>
также затронет чужую строку, значение атрибута или ваш собственный идентификатор с таким же именем, поэтому
проверьте диф перед коммитом.

#### Tree

Дерево перевело свои входы и запросы к содержимому на сигналы. Шесть членов, в которые раньше можно было писать, стали геттерами — поверх `computed()`, поверх `InputSignal` либо поверх `asObservable()`-представления `Subject`.

**Запись, которую не поправили, не просто перестаёт компилироваться.** Присваивание в свойство, у которого есть только геттер, бросает `TypeError: Cannot set property … which has only a getter` в strict-режиме, а ES-модуль всегда strict, — то есть падение произойдёт в рантайме в любой сборке без проверки типов.

| Член | Было | Стало |
| ------------------------------------------ | ------------------------------- | -------------------------------------- |
| `KbqTreeNodeToggle.disabled` | пара геттер/сеттер с `@Input()` | геттер поверх `computed()` |
| `KbqTreeBase.nodeDefs` | `QueryList<KbqTreeNodeDef<T>>` | `Signal<readonly KbqTreeNodeDef<T>[]>` |
| `KbqTreeNodePadding.indent` | пара геттер/сеттер | `InputSignal<number \| string>` |
| `KbqTreeNodePadding.indentUnits` | поле для записи | геттер, выводимый из `indent` |
| `KbqTreeNodeToggleBaseDirective.recursive` | пара геттер/сеттер | `InputSignalWithTransform<boolean, …>` |
| `KbqTreeOption.onFocus` / `onBlur` | `Subject<KbqTreeOptionEvent>` | `Observable<KbqTreeOptionEvent>` |

| Что было | Что делать вручную |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `<toggle>.disabled = …` | Привязать атрибут `disabled` — он по-прежнему доходит до переключателя через `disabledInput` |
| `<toggle>.recursive` | Читать `recursive()`; привязывать `kbqTreeNodeToggleRecursive` |
| `nodeDefs.changes` / `.length` / `.toArray()` / … | Читать `nodeDefs()`; подписку заменить на `effect` |
| `<padding>.indent` / `.indentUnits` | Читать `indent()`; привязывать `kbqTreeNodePaddingIndent` |
| `<option>.onFocus.next(…)` / `onBlur.next(…)` | Подписываться, а не отправлять — опция сама публикует значения в оба потока |

Два случая из шести проявляются молча. `KbqTreeBase` экспортируется и является задокументированной точкой расширения для собственного дерева: наследник, читающий `this.nodeDefs.length`, получит `0` — арность функции-сигнала — вместо количества определений узлов, а `this.nodeDefs.changes.subscribe(…)` бросит исключение. А `KbqTreeNodeToggle` сохранил `disabled` как алиас входа (сам вход объявлен как `disabledInput`), поэтому все привязки в шаблонах продолжают работать и ломаются только программные записи.

Сообщает `tree-signals`. Проект, который вообще отрисовывает дерево, получает ещё и сводку по всем шести членам: пять из них видны только там, где в них пишут, а потребитель, который просто читает один из них, получает значение с изменившимся типом и вообще никакой диагностики.

### После миграции

Миграция работает на регулярных выражениях и не переписывает алиасные импорты, локальные переменные и ре-экспорты — **проверьте диф перед коммитом**, пересоберите проект и прогоните тесты. Полный список ломающих изменений — на странице [Ломающие изменения — Angular 20](https://github.com/koobiq/angular-components/blob/main/docs/guides/angular-20-breaking-changes.ru.md).
3 changes: 2 additions & 1 deletion packages/components/core/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const enUSLocaleData = {
clear: 'Clear',
showPassword: 'Show password',
hidePassword: 'Hide password',
resizeColumns: 'Resize columns'
resizeColumns: 'Resize columns',
optionActions: 'Actions'
},
select: { hiddenItemsText: '+{{ number }}', selectAll: 'Select all' },
datepicker: {
Expand Down
3 changes: 2 additions & 1 deletion packages/components/core/locales/es-LA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const esLALocaleData = {
clear: 'Borrar',
showPassword: 'Mostrar la contraseña',
hidePassword: 'Ocultar la contraseña',
resizeColumns: 'Redimensionar columnas'
resizeColumns: 'Redimensionar columnas',
optionActions: 'Acciones'
},
select: {
hiddenItemsText: '+{{ number }}',
Expand Down
3 changes: 2 additions & 1 deletion packages/components/core/locales/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const ptBRLocaleData = {
clear: 'Apagar',
showPassword: 'Mostrar a senha',
hidePassword: 'Ocultar a senha',
resizeColumns: 'Redimensionar colunas'
resizeColumns: 'Redimensionar colunas',
optionActions: 'Ações'
},
select: {
hiddenItemsText: '+{{ number }}',
Expand Down
3 changes: 2 additions & 1 deletion packages/components/core/locales/ru-RU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const ruRULocaleData = {
clear: 'Очистить',
showPassword: 'Показать пароль',
hidePassword: 'Скрыть пароль',
resizeColumns: 'Изменить ширину колонок'
resizeColumns: 'Изменить ширину колонок',
optionActions: 'Действия'
},
select: { hiddenItemsText: '+{{ number }}', selectAll: 'Выбрать все' },
datepicker: {
Expand Down
3 changes: 2 additions & 1 deletion packages/components/core/locales/tk-TM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const tkTMLocaleData = {
clear: 'Arassala',
showPassword: 'Paroly görkez',
hidePassword: 'Paroly gizle',
resizeColumns: 'Sütünleriň giňligini üýtget'
resizeColumns: 'Sütünleriň giňligini üýtget',
optionActions: 'Hereketler'
},
select: {
hiddenItemsText: '+{{ number }}',
Expand Down
2 changes: 2 additions & 0 deletions packages/components/core/locales/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export type KbqA11yLocaleConfiguration = {
hidePassword: string;
/** Separator that resizes the columns of a description list. */
resizeColumns: string;
/** Trailing action button of a list or tree option. */
optionActions: string;
};

/** Locale configuration for `KbqCodeBlockModule`. */
Expand Down
27 changes: 27 additions & 0 deletions packages/components/core/option/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import {
inject,
InjectionToken,
Input,
input,
OnDestroy,
ViewEncapsulation
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ENTER, SPACE, TAB } from '../keycodes';
import { kbqInjectA11yLocaleConfiguration } from '../locales';
import { kbqInjectNativeElement } from '../utils';

export interface KbqOptionActionParent {
Expand Down Expand Up @@ -45,9 +47,12 @@ export const KBQ_OPTION_ACTION_PARENT = new InjectionToken<KbqOptionActionParent
encapsulation: ViewEncapsulation.None,
host: {
class: 'kbq-option-action',
role: 'button',
'[class.kbq-expanded]': 'false',
'[class.kbq-disabled]': 'disabled',
'[attr.disabled]': 'disabled || null',
'[attr.aria-disabled]': 'disabled || null',
'[attr.aria-label]': 'resolvedAriaLabel',
'[attr.tabIndex]': '-1',
'(click)': 'onClick($event)',
'(keydown)': 'onKeyDown($event)'
Expand All @@ -58,6 +63,28 @@ export class KbqOptionActionComponent implements AfterViewInit, OnDestroy {
private readonly nativeElement = kbqInjectNativeElement();
private readonly focusMonitor = inject(FocusMonitor);
private readonly option = inject(KBQ_OPTION_ACTION_PARENT);
private readonly a11yConfiguration = kbqInjectA11yLocaleConfiguration();

/**
* Accessible name of the button. The rendered content is an icon, so without a name the button
* is announced as unlabelled; defaults to the localized "option actions" text.
*/
readonly ariaLabel = input<string>(undefined!, { alias: 'aria-label' });

/**
* Accessible name written to the host.
*
* The host is the very element the consumer writes, and a host binding runs after their own
* `[attr.aria-label]` binding on that element — which never reaches the aliased input. Falling
* straight through to the localized default would replace the specific name they set ("Delete
* file") with the generic one, so a name already on the element outranks the default.
* @docs-private
*/
protected get resolvedAriaLabel(): string {
return (
this.ariaLabel() || this.nativeElement.getAttribute('aria-label') || this.a11yConfiguration().optionActions
);
}

// TODO: Skipped for migration because:
// Accessor inputs cannot be migrated as they are too complex.
Expand Down
11 changes: 11 additions & 0 deletions packages/components/core/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,14 @@ import { ElementRef, inject } from '@angular/core';
export const kbqInjectNativeElement = <T extends Element = HTMLElement>(): T => {
return inject<ElementRef<T>>(ElementRef<T>).nativeElement;
};

/**
* Rendered height of an element, or `0` when it has no box.
*
* `getClientRects()` returns an empty list on the server and for elements that are not laid out, so
* the first rect is read defensively rather than through `getBoundingClientRect()`, whose zeroes are
* indistinguishable from a genuinely collapsed element.
*/
export const kbqGetElementHeight = (element: Element): number => {
return element.getClientRects()[0]?.height ?? 0;
};
4 changes: 4 additions & 0 deletions packages/components/tree-select/tree-select.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,10 @@ export class KbqTreeSelect

tree.onKeyDown = () => {};

// `panelKeydownHandler` forwards every unhandled key to the tree's key manager, and the search
// field sits inside the panel — so its query would otherwise drive the tree's type-ahead.
tree.typeAhead = !this.search();

tree.keyManager.change.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
const treeValue = this.tree()!;

Expand Down
145 changes: 145 additions & 0 deletions packages/components/tree/control/flat-tree-control.filters.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { FlatTreeControl } from './flat-tree-control';
import {
FilterByValues,
FilterByViewValue,
FilterParentsForNodes,
kbqTreeSelectAllValue
} from './flat-tree-control.filters';

/**
* root
* documents
* draft
* images
* other
*/
interface Node {
name: string;
level: number;
expandable: boolean;
value?: string;
}

describe('FlatTreeControl filters', () => {
let control: FlatTreeControl<Node>;
let nodes: Node[];

const names = (result: Node[]): string[] => result.map((node) => node.name);

beforeEach(() => {
nodes = [
{ name: 'root', level: 0, expandable: true },
{ name: 'documents', level: 1, expandable: true },
{ name: 'draft', level: 2, expandable: false },
{ name: 'images', level: 1, expandable: false },
{ name: 'other', level: 0, expandable: false }
];

control = new FlatTreeControl<Node>(
(node) => node.level,
(node) => node.expandable,
(node) => node.name,
(node) => node.name
);
control.dataNodes = nodes;
});

describe('FilterByViewValue', () => {
it('should keep the nodes whose view value contains the query', () => {
expect(names(new FilterByViewValue(control).handle('o'))).toEqual(['root', 'documents', 'other']);
});

it('should be case insensitive', () => {
expect(names(new FilterByViewValue(control).handle('DRAFT'))).toEqual(['draft']);
});

it('should always keep the select-all row', () => {
nodes.unshift({ name: 'select all', level: 0, expandable: false, value: kbqTreeSelectAllValue });

expect(names(new FilterByViewValue(control).handle('draft'))).toEqual(['select all', 'draft']);
});

it('should drop the select-all row when it is the only thing left', () => {
nodes.unshift({ name: 'select all', level: 0, expandable: false, value: kbqTreeSelectAllValue });

expect(new FilterByViewValue(control).handle('nothing matches this')).toEqual([]);
});

it('should expose the last result on `result`', () => {
const filter = new FilterByViewValue(control);

filter.handle('draft');

expect(names(filter.result)).toEqual(['draft']);
});
});

describe('FilterParentsForNodes', () => {
it('should re-add the ancestors of every match, in tree order', () => {
const previous = new FilterByViewValue(control);

previous.handle('draft');

expect(names(new FilterParentsForNodes(control).handle(null, previous))).toEqual([
'root',
'documents',
'draft'
]);
});

it('should not repeat an ancestor shared by two matches', () => {
const previous = new FilterByViewValue(control);

previous.handle('a');

expect(names(new FilterParentsForNodes(control).handle(null, previous))).toEqual([
'root',
'documents',
'draft',
'images'
]);
});

it('should return nothing when the previous stage is missing', () => {
expect(new FilterParentsForNodes(control).handle(null, null as never)).toEqual([]);
});
});

describe('FilterByValues', () => {
it('should add the nodes carrying the configured values', () => {
const filter = new FilterByValues(control);

filter.setValues(['images']);

expect(names(filter.handle(null))).toEqual(['images']);
});

it('should keep what the previous stage matched', () => {
const previous = new FilterByViewValue(control);
const filter = new FilterByValues(control);

previous.handle('draft');
filter.setValues(['images']);

expect(names(filter.handle(null, previous))).toEqual(['draft', 'images']);
});

it('should not repeat a node the previous stage already matched', () => {
const previous = new FilterByViewValue(control);
const filter = new FilterByValues(control);

previous.handle('draft');
filter.setValues(['draft']);

expect(names(filter.handle(null, previous))).toEqual(['draft']);
});

it('should report the values it was given', () => {
const filter = new FilterByValues(control);

filter.setValues(['draft', 'images']);

expect(filter.getValues()).toEqual(['draft', 'images']);
});
});
});
Loading