diff --git a/docs/guides/migration.en.md b/docs/guides/migration.en.md index c284812bbc..ade6f98908 100644 --- a/docs/guides/migration.en.md +++ b/docs/guides/migration.en.md @@ -17,6 +17,7 @@ New versions include improvements but also contain **breaking changes**; they mu 11. **20.3.0**: the button-toggle review — ARIA semantics, keyboard navigation and signal inputs. 12. **20.3.0**: the form-field review — signals, accessibility and the removal of `mixinColor`. 13. **20.3.0**: the theme service review — signals, `auto` mode and built-in persistence. +14. **20.3.0**: explicit prefix and suffix slots for tag content. ### 1. Upgrade to 18.5.3 @@ -766,6 +767,74 @@ themeService.currentTheme(); // read directly, or wrap with toObservable() if yo **Custom themes and DI-based setup.** `setThemes()` still accepts any array of `{ name, className, colorScheme? }` objects — `colorScheme` (`'light' | 'dark'`) is optional: when set, it's each theme's own polarity, independent of its `name`, and is what `colorScheme()` (and `toggle()`) key off; when omitted, `colorScheme()` falls back to the OS preference for that theme. New: `kbqThemeProvider({ themes, mode, storageKey, autoLight, autoDark })` configures the service through DI instead of calling `setThemes()`/`setTheme()` imperatively. The active theme is always applied as a CSS class on `` — the design tokens' `.kbq-light`/`.kbq-dark` styles depend on it, so there's no attribute-based alternative. `auto` resolves to the theme named `autoLight`/`autoDark` (`'light'`/`'dark'` by default) — set these if your custom theme set doesn't use those names, otherwise `auto` won't match any registered theme. +### 14. Explicit tag content slots (20.3.0) + +Until 20.3.0 every directly projected element with `kbq-icon` was placed before the tag text, regardless of its position in the template. This implicit rule made icon placement depend on the component's projection selector and made the markup easy to break. Tag content now has explicit `kbqTagPrefix` and `kbqTagSuffix` slots: + +```html + + + Tag + + +``` + +`kbqTagRemove` and `kbqTagEditSubmit` are suffix controls already: `KbqTagSuffix` is attached to them through `hostDirectives`. Do not add `kbqTagSuffix` to the same element explicitly, because that applies the directive twice. + +#### Running the migration + +The `tag-slots` schematic runs automatically: + +```bash +ng update @koobiq/components@20 +``` + +Or manually — for example, if you have already upgraded to 20.3.0: + +```bash +ng g @koobiq/components:tag-slots --project +``` + +To preview the changes without writing them, use `--fix=false`: + +```bash +ng g @koobiq/components:tag-slots --project --fix=false +``` + +#### What is fixed automatically + +The schematic adds `kbqTagPrefix` to every legacy, directly projected `kbq-icon` that is not already assigned to a slot and is not a remove or edit-submit control: + +```html + + + Tag + + + + + + + Tag + + + +``` + +Source order is not used to infer a suffix: under the old projection rule all such icons were prefixes. Existing `kbqTagPrefix`, `kbqTagSuffix`, `kbqTagRemove` and `kbqTagEditSubmit` attributes are left unchanged, so the migration is idempotent. + +#### What you need to fix manually + +**Intentional trailing content.** Add `kbqTagSuffix` yourself when an icon or another element should follow the label. The schematic cannot infer a new visual intent from markup whose old rendering always placed `kbq-icon` before the label. + +**Content outside the legacy icon selector.** Elements with only `kbq-icon-button` or `kbq-icon-item`, nested consumer wrappers and nodes with `ngProjectAs` are left unchanged because they were not directly matched by the old `kbq-icon` slot. Review them only if you want to move them to one of the new slots. + +**Standalone imports.** `KbqTagsModule` exports both slot directives. If a standalone component imports `KbqTag` directly instead of the module, also import `KbqTagPrefix` and/or `KbqTagSuffix` when using them; otherwise their host classes and slot spacing are not applied. + + + +**Deprecated imperative placement and styles.** Replace calls to `addClassModificatorForIcons()` with explicit slot directives, and migrate custom selectors from `.kbq-icon_left` to `.kbq-tag-prefix`. The method and the old selector are deprecated and will be removed in the next major version. + ### 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. diff --git a/docs/guides/migration.ru.md b/docs/guides/migration.ru.md index 565f12e210..fe6294451f 100644 --- a/docs/guides/migration.ru.md +++ b/docs/guides/migration.ru.md @@ -17,6 +17,7 @@ 11. **20.3.0**: ревью группы кнопок — ARIA-семантика, навигация с клавиатуры и сигнальные входы. 12. **20.3.0**: ревью поля формы — сигналы, доступность и удаление `mixinColor`. 13. **20.3.0**: ревью сервиса темизации — сигналы, режим `auto` и сохранение выбора из коробки. +14. **20.3.0**: явные prefix- и suffix-слоты для содержимого тегов. ### 1. Обновление до 18.5.3 @@ -766,6 +767,74 @@ themeService.currentTheme(); // читайте напрямую, либо обе **Кастомные темы и настройка через DI.** `setThemes()` по-прежнему принимает любой массив объектов `{ name, className, colorScheme? }` — `colorScheme` (`'light' | 'dark'`) необязателен: если задан, это собственная «полярность» темы, независимая от её `name`, и именно на неё опирается `colorScheme()` (а также `toggle()`); если не задан, `colorScheme()` для этой темы откатывается на предпочтение ОС. Новое: `kbqThemeProvider({ themes, mode, storageKey, autoLight, autoDark })` настраивает сервис через DI вместо императивных вызовов `setThemes()`/`setTheme()`. Активная тема всегда применяется как CSS-класс на `` — от этого зависят стили `.kbq-light`/`.kbq-dark` дизайн-токенов, поэтому альтернативы через атрибут нет. `auto` разрешается в тему с именем `autoLight`/`autoDark` (по умолчанию `'light'`/`'dark'`) — задайте их, если ваш набор кастомных тем использует другие имена, иначе `auto` не совпадёт ни с одной зарегистрированной темой. +### 14. Явные слоты содержимого тегов (20.3.0) + +До 20.3.0 каждый непосредственно спроецированный элемент с `kbq-icon` размещался перед текстом тега независимо от своего положения в шаблоне. Это неявное правило привязывало расположение иконок к селектору проекции компонента, поэтому разметку было легко сломать. Теперь у содержимого тега есть явные слоты `kbqTagPrefix` и `kbqTagSuffix`: + +```html + + + Тег + + +``` + +`kbqTagRemove` и `kbqTagEditSubmit` уже являются suffix-контролами: `KbqTagSuffix` подключается к ним через `hostDirectives`. Не добавляйте `kbqTagSuffix` на тот же элемент явно, иначе директива будет применена дважды. + +#### Запуск миграции + +Схематик `tag-slots` запускается автоматически: + +```bash +ng update @koobiq/components@20 +``` + +Или вручную — например, если вы уже обновились до 20.3.0: + +```bash +ng g @koobiq/components:tag-slots --project +``` + +Посмотреть изменения без записи можно с помощью `--fix=false`: + +```bash +ng g @koobiq/components:tag-slots --project --fix=false +``` + +#### Что исправляется автоматически + +Схематик добавляет `kbqTagPrefix` каждой непосредственно спроецированной legacy-иконке с `kbq-icon`, которая ещё не помещена в слот и не является контролом удаления или подтверждения редактирования: + +```html + + + Тег + + + + + + + Тег + + + +``` + +Положение в исходном коде не используется для определения suffix-слота: по старому правилу проекции все такие иконки были префиксами. Существующие атрибуты `kbqTagPrefix`, `kbqTagSuffix`, `kbqTagRemove` и `kbqTagEditSubmit` не изменяются, поэтому миграция идемпотентна. + +#### Что нужно исправить вручную + +**Намеренно замыкающий контент.** Добавьте `kbqTagSuffix` самостоятельно, если иконка или другой элемент должны располагаться после текста. Схематик не может определить новое визуальное намерение по разметке, в которой старое правило всегда размещало `kbq-icon` перед текстом. + +**Контент вне старого селектора иконок.** Элементы только с `kbq-icon-button` или `kbq-icon-item`, вложенные пользовательские обёртки и узлы с `ngProjectAs` остаются без изменений, поскольку старый слот `kbq-icon` не находил их напрямую. Проверьте их только в том случае, если хотите перенести их в один из новых слотов. + +**Standalone-импорты.** `KbqTagsModule` экспортирует обе slot-директивы. Если standalone-компонент импортирует `KbqTag` напрямую вместо модуля, при использовании слотов также импортируйте `KbqTagPrefix` и/или `KbqTagSuffix` — иначе их host-классы и отступы слотов не применятся. + + + +**Устаревшее императивное расположение и стили.** Замените вызовы `addClassModificatorForIcons()` явными slot-директивами, а пользовательские селекторы `.kbq-icon_left` — на `.kbq-tag-prefix`. Метод и старый селектор устарели и будут удалены в следующей мажорной версии. + ### После миграции Миграция работает на регулярных выражениях и не переписывает алиасные импорты, локальные переменные и ре-экспорты — **проверьте диф перед коммитом**, пересоберите проект и прогоните тесты. Полный список ломающих изменений — на странице [Ломающие изменения — Angular 20](https://github.com/koobiq/angular-components/blob/main/docs/guides/angular-20-breaking-changes.ru.md). diff --git a/packages/components-dev/all/template.html b/packages/components-dev/all/template.html index 0e53f27d31..a300c7bbaf 100644 --- a/packages/components-dev/all/template.html +++ b/packages/components-dev/all/template.html @@ -886,19 +886,19 @@

Tag

Normal - + Hovered - + Focused - + Disabled - +
@@ -906,19 +906,19 @@

Tag

Normal - + Hovered - + Focused - + Disabled - +
@@ -926,19 +926,19 @@

Tag

Normal - + Hovered - + Focused - + Disabled - +
@@ -948,19 +948,19 @@

Tag


Normal - + Hovered - + Focused - + Disabled - +
@@ -968,19 +968,19 @@

Tag

Normal - + Hovered - + Focused - + Disabled - +
@@ -988,19 +988,19 @@

Tag

Normal - + Hovered - + Focused - + Disabled - +
@@ -1010,19 +1010,19 @@

Tag


Normal - + Hovered - + Focused - + Disabled - +
@@ -1030,19 +1030,19 @@

Tag

Normal - + Hovered - + Focused - + Disabled - +
@@ -1050,19 +1050,19 @@

Tag

Normal - + Hovered - + Focused - + Disabled - + diff --git a/packages/components-dev/icon-adopted-stylesheets/template.html b/packages/components-dev/icon-adopted-stylesheets/template.html index 585cce634e..2dbdc29f21 100644 --- a/packages/components-dev/icon-adopted-stylesheets/template.html +++ b/packages/components-dev/icon-adopted-stylesheets/template.html @@ -145,7 +145,7 @@

5. Ссылки с иконками

6. Теги с иконками

- + с иконкой diff --git a/packages/components-dev/tag/template.html b/packages/components-dev/tag/template.html index ddf3ea06b4..1a5874fac2 100644 --- a/packages/components-dev/tag/template.html +++ b/packages/components-dev/tag/template.html @@ -32,7 +32,7 @@

Tags with icons

@for (tag of simpleTags; track tag) { - + {{ tag }} @@ -50,7 +50,7 @@

Without remove

} @for (tag of simpleTags; track tag) { - + {{ tag }} } diff --git a/packages/components/icon/e2e.ts b/packages/components/icon/e2e.ts index b3936bb152..7c620a8af7 100644 --- a/packages/components/icon/e2e.ts +++ b/packages/components/icon/e2e.ts @@ -197,12 +197,12 @@ export class E2eIconStateAndStyle {
- + Tag - + Tag diff --git a/packages/components/select/__screenshots__/02-dark.png b/packages/components/select/__screenshots__/02-dark.png index 4469fa7d3a..322b6227e8 100644 Binary files a/packages/components/select/__screenshots__/02-dark.png and b/packages/components/select/__screenshots__/02-dark.png differ diff --git a/packages/components/select/__screenshots__/02-light.png b/packages/components/select/__screenshots__/02-light.png index 7946cb0194..f04007fd47 100644 Binary files a/packages/components/select/__screenshots__/02-light.png and b/packages/components/select/__screenshots__/02-light.png differ diff --git a/packages/components/select/__screenshots__/03-dark.png b/packages/components/select/__screenshots__/03-dark.png index eb17a9f93c..6f1172af2a 100644 Binary files a/packages/components/select/__screenshots__/03-dark.png and b/packages/components/select/__screenshots__/03-dark.png differ diff --git a/packages/components/select/__screenshots__/03-light.png b/packages/components/select/__screenshots__/03-light.png index fecc37ec12..e51a7538ef 100644 Binary files a/packages/components/select/__screenshots__/03-light.png and b/packages/components/select/__screenshots__/03-light.png differ diff --git a/packages/components/select/__screenshots__/05-light.png b/packages/components/select/__screenshots__/05-light.png index ba00ae71c1..3dd8eed19d 100644 Binary files a/packages/components/select/__screenshots__/05-light.png and b/packages/components/select/__screenshots__/05-light.png differ diff --git a/packages/components/select/__screenshots__/06-dark.png b/packages/components/select/__screenshots__/06-dark.png index 0178a35406..be61b8d2ae 100644 Binary files a/packages/components/select/__screenshots__/06-dark.png and b/packages/components/select/__screenshots__/06-dark.png differ diff --git a/packages/components/select/__screenshots__/06-light.png b/packages/components/select/__screenshots__/06-light.png index b65a0e252b..124a7cd427 100644 Binary files a/packages/components/select/__screenshots__/06-light.png and b/packages/components/select/__screenshots__/06-light.png differ diff --git a/packages/components/select/select.component.spec.ts b/packages/components/select/select.component.spec.ts index 8044d82a99..e93f1ab801 100644 --- a/packages/components/select/select.component.spec.ts +++ b/packages/components/select/select.component.spec.ts @@ -1575,7 +1575,7 @@ class CdkVirtualScrollViewportSelect { @if (!option.disabled && !select.disabled) { } diff --git a/packages/components/tags/e2e.ts b/packages/components/tags/e2e.ts index 577457ffd5..1c502fc2c8 100644 --- a/packages/components/tags/e2e.ts +++ b/packages/components/tags/e2e.ts @@ -24,7 +24,7 @@ import { KbqTagsModule } from '@koobiq/components/tags'; [class.cdk-keyboard-focused]="state === 'focused'" [disabled]="state === 'disabled'" > - + Tag @@ -67,7 +67,7 @@ export class E2eTagStateAndStyle { imports: [KbqTagsModule, KbqIconModule, FormsModule], template: ` - + {{ tag() }} @if (tag().length > 0) { @@ -78,7 +78,7 @@ export class E2eTagStateAndStyle { - + {{ tag() }} @if (tag().length > 0) { diff --git a/packages/components/tags/tag.component.spec.ts b/packages/components/tags/tag.component.spec.ts index b3650b7f4d..bce5cc1292 100644 --- a/packages/components/tags/tag.component.spec.ts +++ b/packages/components/tags/tag.component.spec.ts @@ -7,6 +7,7 @@ import { FormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { createKeyboardEvent, dispatchFakeEvent } from '@koobiq/components/core'; +import { KbqIconModule } from '@koobiq/components/icon'; import { Subject } from 'rxjs'; import { KbqTagList } from './tag-list.component'; import { @@ -15,7 +16,8 @@ import { KbqTagEditSubmit, KbqTagEvent, KbqTagRemove, - KbqTagSelectionChange + KbqTagSelectionChange, + KbqTagSuffix } from './tag.component'; import { KbqTagsModule } from './tag.module'; @@ -1030,6 +1032,42 @@ describe(KbqTag.name, () => { }); }); +describe('Tag content slots', () => { + it('should project explicit prefix and suffix independently of their source order', () => { + const fixture = createComponent(TagSlotsTest); + const wrapper = fixture.nativeElement.querySelector('.kbq-tag__wrapper') as HTMLElement; + + expect(Array.from(wrapper.children, (element) => (element as HTMLElement).id || null)).toEqual([ + 'prefix', + null, + 'remove', + 'suffix' + ]); + expect(wrapper.querySelector('#prefix')?.classList).toContain('kbq-tag-prefix'); + expect(wrapper.querySelector('#suffix')?.classList).toContain('kbq-tag-suffix'); + expect(wrapper.querySelector('#remove')?.classList).not.toContain('kbq-icon_left'); + expect(wrapper.querySelector('.kbq-tag__text #label')).not.toBeNull(); + }); + + it('should apply the suffix host directive to remove and edit-submit controls', () => { + const fixture = createComponent(TagSlotsTest); + const remove = fixture.debugElement.query(By.directive(KbqTagRemove)); + + expect(remove.injector.get(KbqTagSuffix)).toBeInstanceOf(KbqTagSuffix); + expect(remove.nativeElement.classList).toContain('kbq-tag-suffix'); + + getTagElement(fixture.debugElement).dispatchEvent(new MouseEvent('dblclick')); + fixture.detectChanges(); + + const editSubmit = fixture.debugElement.query(By.directive(KbqTagEditSubmit)); + + expect(editSubmit.injector.get(KbqTagSuffix)).toBeInstanceOf(KbqTagSuffix); + expect(editSubmit.nativeElement.classList).toContain('kbq-tag-suffix'); + expect(fixture.nativeElement.querySelector('.kbq-tag__wrapper #remove')).toBeNull(); + expect(fixture.nativeElement.querySelector('.kbq-tag__wrapper #edit-submit')).not.toBeNull(); + }); +}); + @Component({ imports: [KbqTagsModule], template: ` @@ -1078,3 +1116,18 @@ class SingleTag { ` }) class BasicTag {} + +@Component({ + imports: [KbqTagsModule, KbqIconModule], + template: ` + + + Tag + + + + + + ` +}) +class TagSlotsTest {} diff --git a/packages/components/tags/tag.component.ts b/packages/components/tags/tag.component.ts index 9c6ace5ce1..6f21747cf8 100644 --- a/packages/components/tags/tag.component.ts +++ b/packages/components/tags/tag.component.ts @@ -2,7 +2,6 @@ import { CdkDrag } from '@angular/cdk/drag-drop'; import { BACKSPACE, DELETE, ENTER, ESCAPE, F2, SPACE } from '@angular/cdk/keycodes'; import { - AfterContentInit, AfterViewInit, booleanAttribute, ChangeDetectionStrategy, @@ -105,6 +104,24 @@ export class KbqTagAvatar {} }) export class KbqTagTrailingIcon {} +/** Marks content to be projected into the prefix (leading) slot of a tag. */ +@Directive({ + selector: '[kbqTagPrefix]', + host: { + class: 'kbq-tag-prefix' + } +}) +export class KbqTagPrefix {} + +/** Marks content to be projected into the suffix (trailing) slot of a tag. */ +@Directive({ + selector: '[kbqTagSuffix]', + host: { + class: 'kbq-tag-suffix' + } +}) +export class KbqTagSuffix {} + /** * Directive to add submit behavior for the editable tag. */ @@ -116,6 +133,7 @@ export class KbqTagTrailingIcon {} '(click)': 'tag.submitEditing("click")' }, + hostDirectives: [KbqTagSuffix], exportAs: 'kbqTagEditSubmit' }) export class KbqTagEditSubmit { @@ -176,7 +194,7 @@ export class KbqTagEditInput { selector: 'kbq-tag, [kbq-tag], kbq-basic-tag, [kbq-basic-tag]', template: `
- + @if (editing()) { @@ -191,6 +209,7 @@ export class KbqTagEditInput { } } +
`, styleUrls: ['./tag.scss'], @@ -218,10 +237,7 @@ export class KbqTagEditInput { hostDirectives: [CdkDrag], exportAs: 'kbqTag' }) -export class KbqTag - extends KbqColorDirective - implements IFocusableOption, OnDestroy, KbqTitleTextRef, AfterContentInit, AfterViewInit -{ +export class KbqTag extends KbqColorDirective implements IFocusableOption, OnDestroy, KbqTitleTextRef, AfterViewInit { changeDetectorRef = inject(ChangeDetectorRef); private readonly focusMonitor = inject(FocusMonitor); @@ -425,10 +441,6 @@ export class KbqTag this.setupDragInitialProperties(); } - ngAfterContentInit() { - this.addClassModificatorForIcons(); - } - ngAfterViewInit(): void { this.setupFocusMonitor(); } @@ -439,8 +451,13 @@ export class KbqTag this.destroyed.emit({ tag: this }); } - /** @docs-private */ - addClassModificatorForIcons() { + /** + * Adds the legacy positional classes inferred from projected icons. + * + * @deprecated Use `KbqTagPrefix` and `KbqTagSuffix` to declare the position explicitly. + * @docs-private + */ + addClassModificatorForIcons(): void { const icons = this.contentChildren().map((item) => item.elementRef.nativeElement); if (icons.length === 1) { @@ -738,7 +755,8 @@ export class KbqTag '[attr.tabindex]': '-1', '(click)': 'handleClick($event)', '(focus)': 'focus($event)' - } + }, + hostDirectives: [KbqTagSuffix] }) export class KbqTagRemove { protected parentTag = inject(KbqTag); diff --git a/packages/components/tags/tag.en.md b/packages/components/tags/tag.en.md index ae1c54691f..655f4f956e 100644 --- a/packages/components/tags/tag.en.md +++ b/packages/components/tags/tag.en.md @@ -12,6 +12,8 @@ Tags are used within the [`Tag list`](/en/components/tag-list) component in inpu ### Icon +Use `kbqTagPrefix` and `kbqTagSuffix` to explicitly place content before or after the tag text. `kbqTagRemove` and `kbqTagEditSubmit` are suffix controls already; do not combine them with `kbqTagSuffix`, because it is supplied through their host directives. + ### “Remove” Button diff --git a/packages/components/tags/tag.module.ts b/packages/components/tags/tag.module.ts index 0a53ed6cad..9d3abe4044 100644 --- a/packages/components/tags/tag.module.ts +++ b/packages/components/tags/tag.module.ts @@ -9,13 +9,17 @@ import { KbqTagAvatar, KbqTagEditInput, KbqTagEditSubmit, + KbqTagPrefix, KbqTagRemove, + KbqTagSuffix, KbqTagTrailingIcon } from './tag.component'; const COMPONENTS = [ KbqTagEditSubmit, - KbqTagEditInput + KbqTagEditInput, + KbqTagPrefix, + KbqTagSuffix ]; @NgModule({ diff --git a/packages/components/tags/tag.ru.md b/packages/components/tags/tag.ru.md index 73151d3455..9c7030af02 100644 --- a/packages/components/tags/tag.ru.md +++ b/packages/components/tags/tag.ru.md @@ -12,6 +12,8 @@ ### Иконка +Используйте `kbqTagPrefix` и `kbqTagSuffix`, чтобы явно расположить контент до или после текста тега. `kbqTagRemove` и `kbqTagEditSubmit` уже являются suffix-контролами: не комбинируйте их с `kbqTagSuffix`, поскольку эта директива подключается через их `hostDirectives`. + ### Длинный текст diff --git a/packages/components/tags/tag.scss b/packages/components/tags/tag.scss index afec1b1d08..7b3101d481 100644 --- a/packages/components/tags/tag.scss +++ b/packages/components/tags/tag.scss @@ -48,6 +48,20 @@ margin: 0 var(--kbq-tag-size-content-gap-horizontal); } + & .kbq-tag-prefix { + flex-shrink: 0; + margin-inline-start: var(--kbq-tag-size-icon-margin-left); + } + + & .kbq-tag-suffix { + flex-shrink: 0; + } + + & .kbq-tag-suffix:not(.kbq-tag-remove, .kbq-tag-edit-submit) { + margin-inline-end: var(--kbq-tag-size-close-button-margin-right); + } + + // @deprecated Use `.kbq-tag-prefix` (`kbqTagPrefix`) instead. Will be removed in the next major version. & .kbq-icon_left { margin-left: var(--kbq-tag-size-icon-margin-left); } diff --git a/packages/docs-examples/components/select/select-custom-tag-content/select-custom-tag-content-example.ts b/packages/docs-examples/components/select/select-custom-tag-content/select-custom-tag-content-example.ts index 6e27fb7f1a..b52506d47b 100644 --- a/packages/docs-examples/components/select/select-custom-tag-content/select-custom-tag-content-example.ts +++ b/packages/docs-examples/components/select/select-custom-tag-content/select-custom-tag-content-example.ts @@ -23,7 +23,7 @@ import { KbqTagsModule } from '@koobiq/components/tags'; [disabled]="option.disabled || select.disabled" [color]="severities[option.value].color" > - + {{ option.viewValue }} @if (!option.disabled && !select.disabled) { diff --git a/packages/docs-examples/components/tags/tag-fill-and-style/tag-fill-and-style-example.ts b/packages/docs-examples/components/tags/tag-fill-and-style/tag-fill-and-style-example.ts index 1b52c86a73..2adff74b2c 100644 --- a/packages/docs-examples/components/tags/tag-fill-and-style/tag-fill-and-style-example.ts +++ b/packages/docs-examples/components/tags/tag-fill-and-style/tag-fill-and-style-example.ts @@ -14,22 +14,22 @@ import { KbqTagsModule } from '@koobiq/components/tags'; ], template: ` - + Contrast - + Theme - + Error - + Warning diff --git a/packages/docs-examples/components/tags/tag-with-icon/tag-with-icon-example.ts b/packages/docs-examples/components/tags/tag-with-icon/tag-with-icon-example.ts index d56828e8b5..c4d2062093 100644 --- a/packages/docs-examples/components/tags/tag-with-icon/tag-with-icon-example.ts +++ b/packages/docs-examples/components/tags/tag-with-icon/tag-with-icon-example.ts @@ -14,8 +14,9 @@ import { KbqTagsModule } from '@koobiq/components/tags'; ], template: ` - + Tag + `, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/packages/schematics/src/collection.json b/packages/schematics/src/collection.json index 37ff7cdaab..407fc37a56 100644 --- a/packages/schematics/src/collection.json +++ b/packages/schematics/src/collection.json @@ -82,6 +82,11 @@ "factory": "./migrations/button-truncation/index", "schema": "./migrations/button-truncation/schema.json" }, + "tag-slots": { + "description": "Marks legacy implicit kbq-icon content in tags with kbqTagPrefix so tags can use explicit content slots", + "factory": "./migrations/tag-slots/index", + "schema": "./migrations/tag-slots/schema.json" + }, "button-supported-colors": { "description": "Removes button colors outside theme/theme-fade/contrast/contrast-fade from templates and warns about the ones it cannot resolve, plus the transparent default color change", "factory": "./migrations/button-supported-colors/index", diff --git a/packages/schematics/src/migrations.json b/packages/schematics/src/migrations.json index 29a7405feb..12f86ab295 100644 --- a/packages/schematics/src/migrations.json +++ b/packages/schematics/src/migrations.json @@ -56,6 +56,11 @@ "description": "Adds the packages that became mandatory peerDependencies of @koobiq/components — @angular/animations (at the range the project uses for @angular/core, because every animations release pins core exactly), overlayscrollbars and @koobiq/date-adapter — and schedules an install. npm adds a newly mandatory peer on upgrade by itself, but Yarn does not and pnpm only does with auto-install-peers, so those projects would otherwise upgrade into 'Cannot find module' at build time. Warns when @koobiq/date-adapter is already present, since an existing entry is left untouched and anything below 3.4.0 lacks the addCalendarUnits()/startOf() the date components call.", "factory": "./migrations/mandatory-peer-dependencies/index" }, + "tag-slots": { + "version": "20.3.0-0", + "description": "Migrates KbqTag consumers from the legacy implicit icon placement to explicit content slots. Adds kbqTagPrefix to every directly projected kbq-icon that is not already a prefix/suffix, remove control, or edit-submit control. The migration preserves current rendering even when the icon is written after the label, because the old component always projected such icons before the label; kbq-icon-button and kbq-icon-item without kbq-icon remain unchanged because they were not part of the legacy slot.", + "factory": "./migrations/tag-slots/index" + }, "button-supported-colors": { "version": "20.3.0-0", "description": "Migrates consumers to the narrowed button color set. `color` on KbqButton, KbqButtonGroupRoot and KbqSplitButton (and `kbqOkType` on KbqModalComponent / ModalOptions) now accepts theme, theme-fade, contrast and contrast-fade only — every other value matched no rule in kbq-button-theme() and rendered the button as a native one. Removes such a color written as a literal from templates, which is appearance-preserving now that each style falls back to its own default color, and warns about the ones it cannot resolve (enum members, programmatic assignment, members still typed KbqComponentColors / ThemePalette). Also flags the silent part: a transparent button with no explicit color now defaults to contrast instead of contrast-fade, so a `.kbq-button_transparent.kbq-contrast-fade` override stops matching.", diff --git a/packages/schematics/src/migrations/tag-slots/README.md b/packages/schematics/src/migrations/tag-slots/README.md new file mode 100644 index 0000000000..2ce1010f09 --- /dev/null +++ b/packages/schematics/src/migrations/tag-slots/README.md @@ -0,0 +1,41 @@ +### Tag slots schematic + +This schematic migrates legacy implicit icons in `KbqTag` to the explicit `kbqTagPrefix` slot. The old tag +template projected every element with `kbq-icon` before the label regardless of its source position, so every +matching element receives `kbqTagPrefix`; the migration never guesses a suffix from source order. Elements +with only `kbq-icon-button` or `kbq-icon-item` were not part of that legacy slot and remain unchanged. + +`kbqTagRemove`, `kbqTagEditSubmit`, and icons already marked with `kbqTagPrefix` or `kbqTagSuffix` are left +unchanged. + +Usage for Angular CLI: + +```shell +ng g @koobiq/components:tag-slots --project +``` + +Run without writing first: + +```shell +ng g @koobiq/components:tag-slots --project --fix=false +``` + +#### Before + +```html + + Tag + + + +``` + +#### After + +```html + + Tag + + + +``` diff --git a/packages/schematics/src/migrations/tag-slots/index.spec.ts b/packages/schematics/src/migrations/tag-slots/index.spec.ts new file mode 100644 index 0000000000..935d5716a4 --- /dev/null +++ b/packages/schematics/src/migrations/tag-slots/index.spec.ts @@ -0,0 +1,158 @@ +import { workspaces } from '@angular-devkit/core'; +import { Tree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { getWorkspace } from '@schematics/angular/utility/workspace'; +import * as path from 'path'; +import { createTestApp } from '../../utils/testing'; +import { Schema } from './schema'; + +const collectionPath = path.join(__dirname, '../../collection.json'); +const SCHEMATIC_NAME = 'tag-slots'; + +const getProjectContentPaths = (project: workspaces.ProjectDefinition, tree: Tree | UnitTestTree) => { + const root = `/${project.root}/src/app`; + + return { + templatePath: tree.exists(`${root}/app.html`) ? `${root}/app.html` : `${root}/app.component.html`, + tsPath: tree.exists(`${root}/app.ts`) ? `${root}/app.ts` : `${root}/app.component.ts` + }; +}; + +describe(SCHEMATIC_NAME, () => { + let runner: SchematicTestRunner; + let appTree: Tree; + let projects: workspaces.ProjectDefinitionCollection; + let projectKey: string; + + const migrateTemplate = async (template: string, options: Partial = {}) => { + const { templatePath } = getProjectContentPaths(projects.get(projectKey)!, appTree); + + appTree.overwrite(templatePath, template); + + const updatedTree = await runner.runSchematic( + SCHEMATIC_NAME, + { project: projectKey, fix: true, ...options } satisfies Schema, + appTree + ); + + return updatedTree.read(templatePath)!.toString(); + }; + + beforeEach(async () => { + runner = new SchematicTestRunner('schematics', collectionPath); + appTree = await createTestApp(runner, { style: 'scss' }); + + const workspace = await getWorkspace(appTree); + + projects = workspace.projects as unknown as workspaces.ProjectDefinitionCollection; + [projectKey] = projects.keys(); + }); + + it('should run for a specified project', async () => { + await runner.runSchematic(SCHEMATIC_NAME, { project: projectKey, fix: true } satisfies Schema, appTree); + }); + + it('should mark every legacy tag icon as prefix regardless of source order', async () => { + const result = await migrateTemplate( + 'Tag' + ); + + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).not.toContain('kbqTagSuffix kbq-icon'); + }); + + it.each([ + 'Tag', + '
Tag
', + 'Tag', + '
Tag
' + ])('should support every tag and icon selector in %s', async (template) => { + expect(await migrateTemplate(template)).toContain('kbqTagPrefix'); + }); + + it.each([ + 'Tag', + 'Tag' + ])('should leave icon selectors outside the legacy projection slot unchanged in %s', async (template) => { + expect(await migrateTemplate(template)).toBe(template); + }); + + it('should leave semantic and explicit slot icons unchanged', async () => { + const template = ` + + + + + + + `; + + expect(await migrateTemplate(template)).toBe(template); + }); + + it('should migrate icons inside control-flow blocks and ng-container', async () => { + const result = await migrateTemplate(` + + @if (showFirst()) { } + @if (showMore()) { + + + } + + Tag + + `); + + expect(result.match(/kbqTagPrefix/g)).toHaveLength(4); + }); + + it('should not migrate an icon nested in a regular consumer wrapper', async () => { + const template = 'Tag'; + + expect(await migrateTemplate(template)).toBe(template); + }); + + it('should not migrate an icon whose projection is overridden with ngProjectAs', async () => { + const template = 'Tag'; + + expect(await migrateTemplate(template)).toBe(template); + }); + + it('should leave icons outside tags unchanged', async () => { + const template = '
Text
'; + + expect(await migrateTemplate(template)).toBe(template); + }); + + it('should be idempotent', async () => { + const once = await migrateTemplate('Tag'); + + expect(await migrateTemplate(once)).toBe(once); + }); + + it('should migrate a static inline template', async () => { + const { tsPath } = getProjectContentPaths(projects.get(projectKey)!, appTree); + + appTree.overwrite( + tsPath, + 'import { Component } from "@angular/core";\n' + + '@Component({ template: `Tag` })\n' + + 'export class App {}\n' + ); + + const updatedTree = await runner.runSchematic( + SCHEMATIC_NAME, + { project: projectKey, fix: true } satisfies Schema, + appTree + ); + + expect(updatedTree.read(tsPath)!.toString()).toContain(''); + }); + + it('should not write with fix=false', async () => { + const template = 'Tag'; + + expect(await migrateTemplate(template, { fix: false })).toBe(template); + }); +}); diff --git a/packages/schematics/src/migrations/tag-slots/index.ts b/packages/schematics/src/migrations/tag-slots/index.ts new file mode 100644 index 0000000000..718672ce9f --- /dev/null +++ b/packages/schematics/src/migrations/tag-slots/index.ts @@ -0,0 +1,245 @@ +import { Path } from '@angular-devkit/core'; +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import ts from 'typescript'; +import { visitAll, Visitor } from '../../utils/ast'; +import { logMessage } from '../../utils/messages'; +import { setupOptions } from '../../utils/package-config'; +import { forEachClass, parseTemplate } from '../../utils/typescript'; +import { Schema } from './schema'; + +const TS_EXT = '.ts'; +const HTML_EXT = '.html'; +const PREFIX_ATTR = 'kbqTagPrefix'; +const TAG_ELEMENTS = new Set(['kbq-tag', 'kbq-basic-tag']); +const TAG_ATTRS = ['kbq-tag', 'kbq-basic-tag']; +const LEGACY_ICON_ATTR = 'kbq-icon'; +const SPECIAL_ICON_ATTRS = [PREFIX_ATTR, 'kbqTagSuffix', 'kbqTagRemove', 'kbqTagEditSubmit']; + +/** A text-span edit on the original content. Applied right-to-left so offsets stay valid. */ +interface Edit { + start: number; + end: number; + text: string; +} + +/** Minimal Angular HTML AST surface used by this migration. */ +interface Attr { + name: string; + keySpan?: { start: { offset: number } }; +} + +interface Node { + name?: string; + attrs?: Attr[]; + children?: Node[]; + startSourceSpan?: { start: { offset: number } }; +} + +const attrName = (attr: Attr): string => attr.name.replace(/[[\]()]/g, ''); +const hasAttr = (node: Node, name: string): boolean => !!node.attrs?.some((attr) => attrName(attr) === name); +const isElement = (node: Node): boolean => Array.isArray(node.attrs); +const isBlock = (node: Node): boolean => !isElement(node) && Array.isArray(node.children); +const isTagHost = (node: Node): boolean => + isElement(node) && ((!!node.name && TAG_ELEMENTS.has(node.name)) || TAG_ATTRS.some((name) => hasAttr(node, name))); +const isIcon = (node: Node): boolean => isElement(node) && hasAttr(node, LEGACY_ICON_ATTR); +const isSpecialIcon = (node: Node): boolean => SPECIAL_ICON_ATTRS.some((name) => hasAttr(node, name)); + +function applyEdits(content: string, edits: Edit[]): string { + let result = content; + + for (const { start, end, text } of [...edits].sort((a, b) => b.start - a.start || b.end - a.end)) { + result = result.slice(0, start) + text + result.slice(end); + } + + return result; +} + +/** Offset just after the element name, or immediately before its first existing attribute. */ +function attributeInsertOffset(element: Node): number | undefined { + const firstAttr = element.attrs?.find((attr) => attr.keySpan?.start.offset !== undefined); + + if (firstAttr) return firstAttr.keySpan!.start.offset; + + const start = element.startSourceSpan?.start.offset; + + return start === undefined ? undefined : start + 1 + (element.name?.length ?? 0); +} + +/** + * Finds icons that participated in the old implicit leading slot. + * + * Control-flow blocks and `ng-container` are transparent for this purpose. Regular consumer wrappers are + * not traversed because their nested icons did not match the tag's direct projection selector either. + */ +function collectLegacyIcons(children: Node[] = [], result: Node[] = []): Node[] { + for (const child of children) { + if (isBlock(child) || child.name === 'ng-container') { + collectLegacyIcons(child.children, result); + } else if (isIcon(child) && !isSpecialIcon(child) && !hasAttr(child, 'ngProjectAs')) { + result.push(child); + } + } + + return result; +} + +class TagHostCollector implements Visitor { + readonly hosts: Node[] = []; + + visitElement(element: Node): void { + if (isTagHost(element)) this.hosts.push(element); + + this.visitChildren(element); + } + + visitChildren(node: Node): void { + for (const child of node.children ?? []) { + (child as unknown as { visit: (visitor: Visitor) => void }).visit(this); + } + } + + visitBlock(block: Node): void { + this.visitChildren(block); + } + + visitAttribute(): void {} + visitText(): void {} + visitComment(): void {} + visitExpansion(): void {} + visitExpansionCase(): void {} + visitBlockParameter(): void {} + visitLetDeclaration(): void {} +} + +async function migrateTemplate(template: string): Promise<{ content: string; changed: boolean }> { + if (![...TAG_ELEMENTS, ...TAG_ATTRS].some((selector) => template.includes(selector))) { + return { content: template, changed: false }; + } + + const parsed = await parseTemplate(template); + + if (!parsed.tree) return { content: template, changed: false }; + + const collector = new TagHostCollector(); + + visitAll(collector, (parsed.tree as { rootNodes: Node[] }).rootNodes); + + const edits: Edit[] = []; + + for (const host of collector.hosts) { + for (const icon of collectLegacyIcons(host.children)) { + const offset = attributeInsertOffset(icon); + + if (offset !== undefined) edits.push({ start: offset, end: offset, text: `${PREFIX_ATTR} ` }); + } + } + + return { content: applyEdits(template, edits), changed: edits.length > 0 }; +} + +/** Interior ranges of static inline `@Component({ template: '…' })` string literals. */ +function collectInlineTemplateRanges(sourceFile: ts.SourceFile): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = []; + + forEachClass(sourceFile, (node) => { + const decorator = ts + .getDecorators(node) + ?.find( + (current) => + ts.isCallExpression(current.expression) && + ts.isIdentifier(current.expression.expression) && + current.expression.expression.text === 'Component' + ); + + if (!decorator || !ts.isCallExpression(decorator.expression)) return; + + const [metadata] = decorator.expression.arguments; + + if (!metadata || !ts.isObjectLiteralExpression(metadata)) return; + + for (const property of metadata.properties) { + if ( + ts.isPropertyAssignment(property) && + (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) && + property.name.text === 'template' && + ts.isStringLiteralLike(property.initializer) && + property.initializer.text + ) { + ranges.push({ + start: property.initializer.getStart(sourceFile) + 1, + end: property.initializer.getEnd() - 1 + }); + } + } + }); + + return ranges; +} + +async function migrateInlineTemplates(content: string, fileName: string): Promise { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const ranges = collectInlineTemplateRanges(sourceFile).sort((a, b) => b.start - a.start); + let result = content; + + for (const { start, end } of ranges) { + const migrated = await migrateTemplate(result.slice(start, end)); + + if (migrated.changed) result = result.slice(0, start) + migrated.content + result.slice(end); + } + + return result; +} + +export default function tagSlots(options: Schema): Rule { + return async (tree: Tree, context: SchematicContext) => { + const { project } = options; + const fix = options.fix ?? true; + const projectDefinition = await setupOptions(project, tree); + const root = projectDefinition?.root ?? ''; + const rootDir = root ? tree.getDir(root as Path) : tree.root; + const tsPaths: string[] = []; + const htmlPaths: string[] = []; + + rootDir.visit((filePath) => { + if (filePath.includes('node_modules') || filePath.includes('/dist/')) return; + + if (filePath.endsWith(TS_EXT)) tsPaths.push(filePath); + else if (filePath.endsWith(HTML_EXT)) htmlPaths.push(filePath); + }); + + let touched = 0; + + const commit = (filePath: string, original: string, updated: string) => { + if (updated === original) return; + + touched++; + + if (fix) { + tree.overwrite(filePath, updated); + } else { + logMessage(context.logger, [`[tag-slots] would update ${filePath} (run with --fix to apply)`]); + } + }; + + for (const filePath of tsPaths) { + const original = tree.read(filePath)?.toString(); + + if (original) commit(filePath, original, await migrateInlineTemplates(original, filePath)); + } + + for (const filePath of htmlPaths) { + const original = tree.read(filePath)?.toString(); + + if (!original) continue; + + const migrated = await migrateTemplate(original); + + if (migrated.changed) commit(filePath, original, migrated.content); + } + + logMessage(context.logger, [ + `[tag-slots] processed tree under "${root || ''}", ` + + `${fix ? 'updated' : 'would update'} ${touched} file(s).` + ]); + }; +} diff --git a/packages/schematics/src/migrations/tag-slots/schema.json b/packages/schematics/src/migrations/tag-slots/schema.json new file mode 100644 index 0000000000..859bf040a3 --- /dev/null +++ b/packages/schematics/src/migrations/tag-slots/schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "koobiq-components-tag-slots", + "title": "Koobiq components tag slots migration", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Name of the project to migrate. If omitted, the migration runs over the whole tree.", + "$default": { + "$source": "projectName" + } + }, + "fix": { + "type": "boolean", + "default": true, + "description": "When true, marks legacy kbq-icon content in tags with kbqTagPrefix. When false, prints what would change without writing." + } + } +} diff --git a/packages/schematics/src/migrations/tag-slots/schema.ts b/packages/schematics/src/migrations/tag-slots/schema.ts new file mode 100644 index 0000000000..fc442cf1e3 --- /dev/null +++ b/packages/schematics/src/migrations/tag-slots/schema.ts @@ -0,0 +1,6 @@ +export interface Schema { + /** Name of the project to migrate. */ + project?: string; + /** When true, marks legacy kbq-icon content with the explicit tag-prefix slot; otherwise only logs. Defaults to true. */ + fix?: boolean; +} diff --git a/tools/public_api_guard/components/tags.api.md b/tools/public_api_guard/components/tags.api.md index d95ac27d11..9e2ff9c3d4 100644 --- a/tools/public_api_guard/components/tags.api.md +++ b/tools/public_api_guard/components/tags.api.md @@ -6,7 +6,6 @@ import { AfterContentInit } from '@angular/core'; import { AfterViewInit } from '@angular/core'; -import * as _angular_core from '@angular/core'; import { CanUpdateErrorState } from '@koobiq/components/core'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { ChangeDetectorRef } from '@angular/core'; @@ -17,6 +16,7 @@ import { ErrorStateMatcher } from '@koobiq/components/core'; import { FocusKeyManager } from '@koobiq/components/core'; import { FocusOrigin } from '@angular/cdk/a11y'; import { FormGroupDirective } from '@angular/forms'; +import * as i0 from '@angular/core'; import * as i1$1 from '@angular/cdk/platform'; import * as i1 from '@koobiq/components/core'; import * as i2 from '@angular/cdk/drag-drop'; @@ -40,26 +40,27 @@ import { Subject } from 'rxjs'; export const KBQ_TAGS_DEFAULT_OPTIONS: InjectionToken; // @public (undocumented) -export class KbqTag extends KbqColorDirective implements IFocusableOption, OnDestroy, KbqTitleTextRef, AfterContentInit, AfterViewInit { +export class KbqTag extends KbqColorDirective implements IFocusableOption, OnDestroy, KbqTitleTextRef, AfterViewInit { constructor(); + // @deprecated addClassModificatorForIcons(): void; addHostClassName(): void; - readonly avatar: _angular_core.Signal; + readonly avatar: i0.Signal; blur(): void; cancelEditing(reason: string): void; // (undocumented) changeDetectorRef: ChangeDetectorRef; // (undocumented) - readonly contentChildren: _angular_core.Signal; + readonly contentChildren: i0.Signal; deselect(): void; - readonly destroyed: _angular_core.OutputEmitterRef; + readonly destroyed: i0.OutputEmitterRef; get disabled(): boolean; set disabled(value: boolean); protected get draggable(): boolean; get editable(): boolean; set editable(value: boolean); - readonly editChange: _angular_core.OutputEmitterRef; - protected readonly editing: _angular_core.WritableSignal; + readonly editChange: i0.OutputEmitterRef; + protected readonly editing: i0.WritableSignal; readonly elementRef: ElementRef; focus(): void; handleClick(event: MouseEvent): void; @@ -78,25 +79,23 @@ export class KbqTag extends KbqColorDirective implements IFocusableOption, OnDes // (undocumented) static ngAcceptInputType_selected: unknown; // (undocumented) - ngAfterContentInit(): void; - // (undocumented) ngAfterViewInit(): void; // (undocumented) ngOnDestroy(): void; readonly onBlur: Subject; readonly onFocus: Subject; - readonly preventEditSubmit: _angular_core.InputSignalWithTransform; + readonly preventEditSubmit: i0.InputSignalWithTransform; get removable(): boolean; set removable(value: boolean); remove(): void; - readonly removed: _angular_core.OutputEmitterRef; - readonly removeIcon: _angular_core.Signal; + readonly removed: i0.OutputEmitterRef; + readonly removeIcon: i0.Signal; select(): void; get selectable(): boolean; set selectable(value: boolean); get selected(): boolean; set selected(value: boolean); - readonly selectionChange: _angular_core.OutputEmitterRef; + readonly selectionChange: i0.OutputEmitterRef; selectViaInteraction(): void; setSelectedState(selected: boolean, options?: Partial<{ isUserInput: boolean; @@ -108,21 +107,21 @@ export class KbqTag extends KbqColorDirective implements IFocusableOption, OnDes set tabindex(value: any); readonly textElement: ElementRef; toggleSelected(isUserInput?: boolean): boolean; - readonly trailingIcon: _angular_core.Signal; + readonly trailingIcon: i0.Signal; get value(): any; set value(value: any); // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public export class KbqTagAvatar { // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public @@ -144,18 +143,18 @@ export class KbqTagEditInput { protected handleClick(event: MouseEvent): void; protected handleKeydown(event: KeyboardEvent): void; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public export class KbqTagEditSubmit { protected readonly tag: KbqTag; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public @@ -175,13 +174,13 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { constructor(); get addOnBlur(): boolean; set addOnBlur(value: boolean); - readonly addOnPaste: _angular_core.InputSignalWithTransform; + readonly addOnPaste: i0.InputSignalWithTransform; // (undocumented) autocompleteTrigger?: KbqAutocompleteTrigger | null | undefined; blur(event: FocusEvent): void; get disabled(): boolean; set disabled(value: boolean); - readonly distinct: _angular_core.InputSignal; + readonly distinct: i0.InputSignal; emitTagEnd(): void; get empty(): boolean; focus(): void; @@ -202,14 +201,14 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { placeholder: string; set separatorKeyCodes(value: number[]); get separators(): KbqTagSeparator[]; - readonly tagEnd: _angular_core.OutputEmitterRef; + readonly tagEnd: i0.OutputEmitterRef; set tagList(value: KbqTagList); // (undocumented) triggerValidation(): void; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public @@ -223,8 +222,8 @@ export class KbqTagList implements KbqFormFieldControl, ControlValueAccesso constructor(); blur(): void; get canShowCleaner(): boolean; - readonly change: _angular_core.OutputEmitterRef; - readonly cleaner: _angular_core.Signal; + readonly change: i0.OutputEmitterRef; + readonly cleaner: i0.Signal; readonly controlType: string; // (undocumented) defaultErrorStateMatcher: ErrorStateMatcher; @@ -233,8 +232,8 @@ export class KbqTagList implements KbqFormFieldControl, ControlValueAccesso // (undocumented) get draggable(): boolean; set draggable(value: boolean); - readonly dropped: _angular_core.OutputEmitterRef; - readonly editable: _angular_core.InputSignalWithTransform; + readonly dropped: i0.OutputEmitterRef; + readonly editable: i0.InputSignalWithTransform; // (undocumented) protected elementRef: ElementRef; get empty(): boolean; @@ -282,7 +281,7 @@ export class KbqTagList implements KbqFormFieldControl, ControlValueAccesso removeSelected(): void; get required(): boolean; set required(value: boolean); - readonly selectable: _angular_core.InputSignalWithTransform; + readonly selectable: i0.InputSignalWithTransform; get selected(): KbqTag[]; setDisabledState(isDisabled: boolean): void; get shouldLabelFloat(): boolean; @@ -304,12 +303,12 @@ export class KbqTagList implements KbqFormFieldControl, ControlValueAccesso userTabIndex: number | null; get value(): any; set value(value: any); - readonly valueChange: _angular_core.OutputEmitterRef; + readonly valueChange: i0.OutputEmitterRef; writeValue(value: any): void; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public @@ -326,6 +325,14 @@ export type KbqTagListDroppedEvent = Pick, 'event' | 'previ tag: KbqTag; }; +// @public +export class KbqTagPrefix { + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public export class KbqTagRemove { focus(event: FocusEvent): void; @@ -333,9 +340,9 @@ export class KbqTagRemove { // (undocumented) protected parentTag: KbqTag; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public @@ -371,19 +378,27 @@ export interface KbqTagSeparator { // @public (undocumented) export class KbqTagsModule { // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵinj: i0.ɵɵInjectorDeclaration; + // (undocumented) + static ɵmod: i0.ɵɵNgModuleDeclaration; +} + +// @public +export class KbqTagSuffix { // (undocumented) - static ɵinj: _angular_core.ɵɵInjectorDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵmod: _angular_core.ɵɵNgModuleDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public export class KbqTagTrailingIcon { // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // (No @packageDocumentation comment for this package)