From e86c4a9c030b5b35d48d062b71c5b25416433ae1 Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Thu, 13 Aug 2026 17:29:58 +0300 Subject: [PATCH 1/4] feat(tags): add appliesTo scoping to KbqTagSeparator(#DS-4712) --- packages/components-dev/tag/module.ts | 5 +- .../components/tags/e2e.playwright-spec.ts | 52 ++++++ packages/components/tags/e2e.ts | 57 +++++- packages/components/tags/public-api.ts | 1 - .../components/tags/tag-default-options.ts | 13 -- packages/components/tags/tag-input.en.md | 25 +++ packages/components/tags/tag-input.ru.md | 25 +++ packages/components/tags/tag-input.spec.ts | 121 +++++++++++-- packages/components/tags/tag-input.ts | 167 +++++++++++++----- packages/components/tags/tag.module.ts | 10 +- .../tag-autocomplete-onpaste-off-example.ts | 12 +- .../tag-input-onpaste-off-example.ts | 15 +- packages/e2e/routes.ts | 2 + tools/public_api_guard/components/tags.api.md | 29 ++- 14 files changed, 412 insertions(+), 122 deletions(-) delete mode 100644 packages/components/tags/tag-default-options.ts diff --git a/packages/components-dev/tag/module.ts b/packages/components-dev/tag/module.ts index 1c2a0a3317..bcf04080bc 100644 --- a/packages/components-dev/tag/module.ts +++ b/packages/components-dev/tag/module.ts @@ -154,10 +154,7 @@ export class DevDocsExamples {} templateUrl: 'template.html', styleUrls: ['styles.scss'], providers: [ - // { - // provide: KBQ_TAGS_DEFAULT_OPTIONS, - // useValue: { separatorKeyCodes: [ENTER], addOnPaste: false } satisfies KbqTagsDefaultOptions - // } + // kbqTagsDefaultOptionsProvider({ separatorKeyCodes: [ENTER], addOnPaste: false }) ], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None diff --git a/packages/components/tags/e2e.playwright-spec.ts b/packages/components/tags/e2e.playwright-spec.ts index 0614fc535b..fae1329715 100644 --- a/packages/components/tags/e2e.playwright-spec.ts +++ b/packages/components/tags/e2e.playwright-spec.ts @@ -67,4 +67,56 @@ test.describe('KbqTagModule', () => { await expect(getComponent(page)).toHaveScreenshot('05-dark.png'); }); }); + + test.describe('E2eTagInputSeparators', () => { + test.use({ permissions: ['clipboard-read', 'clipboard-write'] }); + + const getComponent = (page: Page): Locator => page.getByTestId('e2eTagInputSeparators'); + const getInput = (page: Page): Locator => page.getByTestId('e2eTagInputSeparatorsInput'); + const getTags = (component: Locator): Locator => component.locator('kbq-tag'); + + const pasteFromClipboard = async (page: Page, target: Locator, text: string) => { + await page.evaluate((value) => navigator.clipboard.writeText(value), text); + await target.click(); + await page.keyboard.press('ControlOrMeta+v'); + }; + + test('creates a tag on Enter while typing', async ({ page }) => { + await page.goto('/E2eTagInputSeparators'); + const input = getInput(page); + + await input.fill('typed-tag'); + await input.press('Enter'); + + await expect(getTags(getComponent(page))).toHaveText(['typed-tag']); + }); + + test('does not end the tag on Space while typing, since it is a paste-only separator', async ({ page }) => { + await page.goto('/E2eTagInputSeparators'); + const input = getInput(page); + + await input.pressSequentially('two words'); + + await expect(getTags(getComponent(page))).toHaveCount(0); + await expect(input).toHaveValue('two words'); + }); + + test('splits pasted text on whitespace, via the keyless `appliesTo: paste` separator', async ({ page }) => { + await page.goto('/E2eTagInputSeparators'); + const input = getInput(page); + + await pasteFromClipboard(page, input, 'alpha beta\tgamma\ndelta'); + + await expect(getTags(getComponent(page))).toHaveText(['alpha', 'beta', 'gamma', 'delta']); + }); + + test('splits pasted text on Enter, a separator shared by both contexts', async ({ page }) => { + await page.goto('/E2eTagInputSeparators'); + const input = getInput(page); + + await pasteFromClipboard(page, input, 'first\nsecond'); + + await expect(getTags(getComponent(page))).toHaveText(['first', 'second']); + }); + }); }); diff --git a/packages/components/tags/e2e.ts b/packages/components/tags/e2e.ts index 1c502fc2c8..231ac53485 100644 --- a/packages/components/tags/e2e.ts +++ b/packages/components/tags/e2e.ts @@ -1,10 +1,10 @@ -import { ChangeDetectionStrategy, Component, model } from '@angular/core'; +import { ChangeDetectionStrategy, Component, model, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { KbqAutocompleteModule } from '@koobiq/components/autocomplete'; -import { KbqComponentColors } from '@koobiq/components/core'; +import { ENTER, KbqComponentColors } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; -import { KbqTagsModule } from '@koobiq/components/tags'; +import { KbqTagInputEvent, KbqTagsModule, kbqTagsDefaultOptionsProvider } from '@koobiq/components/tags'; @Component({ selector: 'e2e-tag-state-and-style', @@ -353,3 +353,54 @@ export class E2eTagInputStates {} } }) export class E2eTagAutocompleteStates {} + +/** + * Demonstrates `KbqTagSeparator.appliesTo` scoping: `Enter` creates a tag both while typing and + * on paste, while a keyless whitespace separator (`/\s+/`) only splits pasted text — typing a + * space does not end the current tag. + */ +@Component({ + selector: 'e2e-tag-input-separators', + imports: [KbqTagsModule, KbqInputModule], + template: ` + + + @for (tag of tags(); track tag) { + {{ tag }} + } + + + + + `, + providers: [ + kbqTagsDefaultOptionsProvider({ + separatorKeyCodes: [ENTER], + separators: [ + { symbol: /\r?\n/, key: 'Enter', keyCode: ENTER, appliesTo: ['input', 'paste'] }, + { symbol: /\s+/, appliesTo: ['paste'] } + ] + }) + ], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eTagInputSeparators' + } +}) +export class E2eTagInputSeparators { + readonly tags = signal([]); + + add({ input, value }: KbqTagInputEvent): void { + if (value) { + this.tags.update((tags) => [...tags, value]); + input.value = ''; + } + } +} diff --git a/packages/components/tags/public-api.ts b/packages/components/tags/public-api.ts index 8d78921574..771990e123 100644 --- a/packages/components/tags/public-api.ts +++ b/packages/components/tags/public-api.ts @@ -1,4 +1,3 @@ -export * from './tag-default-options'; export * from './tag-input'; export * from './tag-list.component'; export * from './tag.component'; diff --git a/packages/components/tags/tag-default-options.ts b/packages/components/tags/tag-default-options.ts deleted file mode 100644 index 8a595b474d..0000000000 --- a/packages/components/tags/tag-default-options.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { InjectionToken } from '@angular/core'; -import { KbqTagSeparator } from './tag-input'; - -/** Default options, for the chips module, that can be overridden. */ -export interface KbqTagsDefaultOptions { - /** The list of key codes that will trigger a chipEnd event. */ - separatorKeyCodes: number[]; - separators?: { [key: number]: KbqTagSeparator }; - addOnPaste?: boolean; -} - -/** Injection token to be used to override the default options for the chips module. */ -export const KBQ_TAGS_DEFAULT_OPTIONS = new InjectionToken('kbq-tags-default-options'); diff --git a/packages/components/tags/tag-input.en.md b/packages/components/tags/tag-input.en.md index df1aad7d52..697cebfa43 100644 --- a/packages/components/tags/tag-input.en.md +++ b/packages/components/tags/tag-input.en.md @@ -61,6 +61,31 @@ To enable tag reordering, you need to set the `draggable` property for `kbq-tag- +### Separators + +By default, a tag is created on `Enter`. Additional separator keys are set via `kbqTagInputSeparatorKeyCodes` and apply both while typing and when pasting from the clipboard. + +If a separator should only apply on paste (e.g. a space, which is a common character inside a tag), mark it with `appliesTo: ['paste']`. Separators without a `key` (e.g. `/\s+/` for any run of whitespace) are paste-only by default, since no keystroke can ever match them. + +Application- or module-wide defaults are set via `kbqTagsDefaultOptionsProvider`: + +```ts +import { ENTER } from '@koobiq/components/core'; +import { kbqTagsDefaultOptionsProvider } from '@koobiq/components/tags'; + +@NgModule({ + providers: [ + kbqTagsDefaultOptionsProvider({ + separatorKeyCodes: [ENTER], + separators: [ + { symbol: /\r?\n/, key: 'Enter', keyCode: ENTER, appliesTo: ['input', 'paste'] }, + { symbol: /\s+/, appliesTo: ['paste'] } // paste-only — splits on any whitespace + ] + }) + ] +}) +``` + ### Keyboard navigation #### Focus in empty input area diff --git a/packages/components/tags/tag-input.ru.md b/packages/components/tags/tag-input.ru.md index 84583c4528..d490a2aa1b 100644 --- a/packages/components/tags/tag-input.ru.md +++ b/packages/components/tags/tag-input.ru.md @@ -61,6 +61,31 @@ +### Разделители + +По умолчанию тег создаётся по `Enter`. Дополнительные клавиши-разделители задаются через `kbqTagInputSeparatorKeyCodes` и работают как при наборе текста, так и при вставке из буфера обмена. + +Если разделитель нужен только при вставке (например, пробел — обычный символ внутри тега), укажите для него `appliesTo: ['paste']`. Разделители без `key` (например, `/\s+/` для любой последовательности пробельных символов) вставочные по умолчанию, так как им не может соответствовать нажатие клавиши. + +Настройки по умолчанию для приложения или модуля задаются через `kbqTagsDefaultOptionsProvider`: + +```ts +import { ENTER } from '@koobiq/components/core'; +import { kbqTagsDefaultOptionsProvider } from '@koobiq/components/tags'; + +@NgModule({ + providers: [ + kbqTagsDefaultOptionsProvider({ + separatorKeyCodes: [ENTER], + separators: [ + { symbol: /\r?\n/, key: 'Enter', keyCode: ENTER, appliesTo: ['input', 'paste'] }, + { symbol: /\s+/, appliesTo: ['paste'] } // только при вставке — разделяет по любым пробельным символам + ] + }) + ] +}) +``` + ### Навигация с клавиатуры #### Фокус в пустой области ввода diff --git a/packages/components/tags/tag-input.spec.ts b/packages/components/tags/tag-input.spec.ts index 555a5446f7..224aa79bb9 100644 --- a/packages/components/tags/tag-input.spec.ts +++ b/packages/components/tags/tag-input.spec.ts @@ -15,8 +15,7 @@ import { COMMA, createKeyboardEvent, dispatchFakeEvent, ENTER, SEMICOLON, SPACE, import { KbqFormFieldModule } from '@koobiq/components/form-field'; import { Subject } from 'rxjs'; import { KbqTagsModule } from './index'; -import { KBQ_TAGS_DEFAULT_OPTIONS, KbqTagsDefaultOptions } from './tag-default-options'; -import { KbqTagInput, KbqTagInputEvent } from './tag-input'; +import { KbqTagInput, KbqTagInputEvent, kbqTagsDefaultOptionsProvider } from './tag-input'; import { KbqTagList } from './tag-list.component'; const createComponent = (component: Type, providers: Provider[] = []): ComponentFixture => { @@ -88,14 +87,11 @@ class TestTagInputDefaultSeparators { `, providers: [ - { - provide: KBQ_TAGS_DEFAULT_OPTIONS, - useValue: { - separatorKeyCodes: [DASH], - separators: { [DASH]: { symbol: /-/, key: '-' } }, - addOnPaste: true - } as KbqTagsDefaultOptions - } + kbqTagsDefaultOptionsProvider({ + separatorKeyCodes: [DASH], + separators: [{ symbol: /-/, key: '-', keyCode: DASH }], + addOnPaste: true + }) ] }) class TestTagInputWithDashSeparator { @@ -103,6 +99,52 @@ class TestTagInputWithDashSeparator { readonly add = jest.fn(); } +@Component({ + imports: [KbqTagsModule], + template: ` + + + + `, + providers: [ + kbqTagsDefaultOptionsProvider({ + separatorKeyCodes: [ENTER, SPACE], + separators: [ + { symbol: /\r?\n/, key: 'Enter', keyCode: ENTER }, + { symbol: / /, key: ' ', keyCode: SPACE, appliesTo: ['paste'] } + ], + addOnPaste: true + }) + ] +}) +class TestTagInputWithPasteOnlySpace { + readonly tagInput = viewChild.required(KbqTagInput); + readonly add = jest.fn(); +} + +@Component({ + imports: [KbqTagsModule], + template: ` + + + + `, + providers: [ + kbqTagsDefaultOptionsProvider({ + separatorKeyCodes: [ENTER], + separators: [ + { symbol: /\r?\n/, key: 'Enter', keyCode: ENTER }, + { symbol: /\s+/, appliesTo: ['paste'] } + ], + addOnPaste: true + }) + ] +}) +class TestTagInputWithKeylessWhitespaceSeparator { + readonly tagInput = viewChild.required(KbqTagInput); + readonly add = jest.fn(); +} + @Component({ imports: [KbqTagsModule, KbqFormFieldModule], template: ` @@ -387,6 +429,65 @@ describe(KbqTagInput.name, () => { expect(fixture.componentInstance.add).toHaveBeenCalledWith(expect.objectContaining({ value: '' })); }); + describe('separator `appliesTo` scoping', () => { + it('should NOT split on keydown when a separator is scoped to `paste` only', () => { + const fixture = createComponent(TestTagInputWithPasteOnlySpace); + const directive = fixture.componentInstance.tagInput(); + const inputElement = getInputElement(fixture); + + inputElement.value = 'New York'; + directive.onKeydown(createKeyboardEvent('keydown', SPACE, inputElement, ' ')); + + expect(fixture.componentInstance.add).not.toHaveBeenCalled(); + }); + + it('should still split on paste when a separator is scoped to `paste` only', () => { + const fixture = createComponent(TestTagInputWithPasteOnlySpace); + const directive = fixture.componentInstance.tagInput(); + + directive.onPaste(createPasteEvent('New York Boston')); + + expect(fixture.componentInstance.add).toHaveBeenCalledTimes(3); + expect(fixture.componentInstance.add).toHaveBeenCalledWith(expect.objectContaining({ value: 'New' })); + expect(fixture.componentInstance.add).toHaveBeenCalledWith(expect.objectContaining({ value: 'York' })); + expect(fixture.componentInstance.add).toHaveBeenCalledWith(expect.objectContaining({ value: 'Boston' })); + }); + + it('should still trigger tagEnd on Enter keydown, which is not restricted to `paste`', () => { + const fixture = createComponent(TestTagInputWithPasteOnlySpace); + const directive = fixture.componentInstance.tagInput(); + const inputElement = getInputElement(fixture); + + inputElement.value = 'tag'; + directive.onKeydown(createKeyboardEvent('keydown', ENTER, inputElement, 'Enter')); + + expect(fixture.componentInstance.add).toHaveBeenCalledWith(expect.objectContaining({ value: 'tag' })); + }); + + it('should split pasted text using a keyless regex separator regardless of separatorKeyCodes', () => { + const fixture = createComponent(TestTagInputWithKeylessWhitespaceSeparator); + const directive = fixture.componentInstance.tagInput(); + + directive.onPaste(createPasteEvent('a b\tc')); + + expect(fixture.componentInstance.add).toHaveBeenCalledTimes(3); + expect(fixture.componentInstance.add).toHaveBeenCalledWith(expect.objectContaining({ value: 'a' })); + expect(fixture.componentInstance.add).toHaveBeenCalledWith(expect.objectContaining({ value: 'b' })); + expect(fixture.componentInstance.add).toHaveBeenCalledWith(expect.objectContaining({ value: 'c' })); + }); + + it('should not let a keyless separator affect keydown, since it can never match a KeyboardEvent.key', () => { + const fixture = createComponent(TestTagInputWithKeylessWhitespaceSeparator); + const directive = fixture.componentInstance.tagInput(); + const inputElement = getInputElement(fixture); + + inputElement.value = 'tag'; + directive.onKeydown(createKeyboardEvent('keydown', SPACE, inputElement, ' ')); + + expect(fixture.componentInstance.add).not.toHaveBeenCalled(); + }); + }); + it('should emit the whole pasted string as a single tag when no separator is found', () => { const fixture = createComponent(TestTagInputSeparators); const { componentInstance } = fixture; diff --git a/packages/components/tags/tag-input.ts b/packages/components/tags/tag-input.ts index 96973a83ed..6d25d01e5f 100644 --- a/packages/components/tags/tag-input.ts +++ b/packages/components/tags/tag-input.ts @@ -1,30 +1,31 @@ -import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { booleanAttribute, Directive, ElementRef, EventEmitter, inject, + InjectionToken, Input, input, OnChanges, - output + output, + Provider } from '@angular/core'; import { NgControl } from '@angular/forms'; import { KbqAutocompleteTrigger } from '@koobiq/components/autocomplete'; import { COMMA, ENTER, hasModifierKey, KbqFieldSizingContent, SEMICOLON, SPACE, TAB } from '@koobiq/components/core'; import { KbqTrim } from '@koobiq/components/form-field'; -import { KBQ_TAGS_DEFAULT_OPTIONS, KbqTagsDefaultOptions } from './tag-default-options'; import { KbqTagList } from './tag-list.component'; import { KbqTagTextControl } from './tag-text-control'; -const KBQ_TAG_INPUT_DEFAULT_SEPARATORS: { [key: number]: KbqTagSeparator } = { - [ENTER]: { symbol: /\r?\n/, key: 'Enter' }, - [TAB]: { symbol: /\t/, key: 'Tab' }, - [SPACE]: { symbol: / /, key: ' ' }, - [COMMA]: { symbol: /,/, key: ',' }, - [SEMICOLON]: { symbol: /;/, key: ';' } -}; +const KBQ_TAG_INPUT_DEFAULT_SEPARATORS: KbqTagSeparator[] = [ + { symbol: /\r?\n/, key: 'Enter', keyCode: ENTER }, + { symbol: /\t/, key: 'Tab', keyCode: TAB }, + { symbol: / /, key: ' ', keyCode: SPACE }, + { symbol: /,/, key: ',', keyCode: COMMA }, + { symbol: /;/, key: ';', keyCode: SEMICOLON } +]; /** Represents an input event on a `kbqTagInput`. */ export interface KbqTagInputEvent { @@ -35,10 +36,57 @@ export interface KbqTagInputEvent { value: string; } +/** Contexts a `KbqTagSeparator` can be active in. */ +export type KbqTagSeparatorContext = 'input' | 'paste'; + +/** Defines a character or pattern that ends a tag, and the contexts in which it applies. */ export interface KbqTagSeparator { + /** Regular expression used to match/split this separator in text. */ symbol: RegExp; - key: string; + + /** + * `KeyboardEvent.key` that triggers this separator while typing. + * Omit for separators with no single-keystroke equivalent (e.g. a run of whitespace) — + * those are implicitly paste-only, since they can never match a `keydown`. + */ + key?: string; + + /** + * Numeric key code gating whether this separator is enabled, set via `kbqTagInputSeparatorKeyCodes`. + * Omit together with `key` for separators that should always be active regardless of that input. + */ + keyCode?: number; + + /** + * Contexts this separator applies to. + * @default ['input', 'paste'] + */ + appliesTo?: KbqTagSeparatorContext[]; +} + +/** Default options, for the chips module, that can be overridden. */ +export interface KbqTagsDefaultOptions { + /** The list of key codes that will trigger a chipEnd event. */ + separatorKeyCodes: number[]; + + /** Custom separator definitions to use instead of the built-in defaults. */ + separators?: KbqTagSeparator[]; + + /** Whether the tagEnd event will be emitted when text is pasted. */ + addOnPaste?: boolean; } + +/** Injection token to be used to override the default options for the chips module. */ +export const KBQ_TAGS_DEFAULT_OPTIONS = new InjectionToken('kbq-tags-default-options'); + +const KBQ_TAGS_DEFAULT_OPTIONS_CONFIG: KbqTagsDefaultOptions = { separatorKeyCodes: [ENTER] }; + +/** Utility provider for `KBQ_TAGS_DEFAULT_OPTIONS`. */ +export const kbqTagsDefaultOptionsProvider = (options: Partial): Provider => ({ + provide: KBQ_TAGS_DEFAULT_OPTIONS, + useValue: { ...KBQ_TAGS_DEFAULT_OPTIONS_CONFIG, ...options } +}); + // Increasing integer for generating unique ids. let nextUniqueId = 0; @@ -66,9 +114,20 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { private elementRef = inject>(ElementRef); private defaultOptions = inject(KBQ_TAGS_DEFAULT_OPTIONS); private trimDirective = inject(KbqTrim, { optional: true, self: true }); + /** + * The form control instance bound to the input, if any. + * @docs-private + */ ngControl = inject(NgControl, { optional: true, self: true })!; + /** + * The autocomplete trigger attached to the input, if any. + * @docs-private + */ autocompleteTrigger? = inject(KbqAutocompleteTrigger, { optional: true, self: true }); - /** Whether the control is focused. */ + /** + * Whether the control is focused. + * @docs-private + */ focused: boolean = false; /** @@ -85,16 +144,19 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { private _separatorKeyCodes: number[] = this.defaultOptions.separatorKeyCodes; - /** @docs-private */ + /** + * The effective set of separators: entries gated by `keyCode` are included only when that + * code is present in `separatorKeyCodes`; entries without a `keyCode` (no single-keystroke + * equivalent, e.g. a run of whitespace) are always included. + * @docs-private + */ get separators(): KbqTagSeparator[] { - return this._separatorKeyCodes.reduce((acc: any, key) => { - const separator = this.getSeparatorByKeyCode(key); - - return separator ? [...acc, separator] : acc; - }, []); + return this._separators.filter( + (separator) => separator.keyCode === undefined || this._separatorKeyCodes.includes(separator.keyCode) + ); } - private _separators = this.defaultOptions.separators || KBQ_TAG_INPUT_DEFAULT_SEPARATORS; + private _separators: KbqTagSeparator[] = this.defaultOptions.separators || KBQ_TAG_INPUT_DEFAULT_SEPARATORS; /** Emitted when a tag is to be added. */ readonly tagEnd = output({ alias: 'kbqTagInputTokenEnd' }); @@ -178,13 +240,13 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { this.inputElement = this.elementRef.nativeElement as HTMLInputElement; } - ngOnChanges() { + ngOnChanges(): void { this._tagList.stateChanges.next(); } /** @docs-private */ - onKeydown(event: KeyboardEvent) { - const isSeparatorKey = this.isSeparatorKey(event); + onKeydown(event: KeyboardEvent): void { + const isSeparatorKey = this.matchesInputSeparator(event); if (!this.inputElement.value) { if (isSeparatorKey && event.keyCode !== TAB) { @@ -212,8 +274,11 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { } } - /** Checks to see if the blur should emit the (tagEnd) event. */ - blur(event: FocusEvent) { + /** + * Checks to see if the blur should emit the (tagEnd) event. + * @docs-private + */ + blur(event: FocusEvent): void { this.focused = false; // Blur the tag list if it is not focused @@ -230,7 +295,11 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { this._tagList.stateChanges.next(); } - triggerValidation() { + /** + * Notifies the associated NgControl of a validation status change. + * @docs-private + */ + triggerValidation(): void { if (!this.hasControl()) { return; } @@ -238,8 +307,11 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { (this.ngControl.statusChanges as EventEmitter).emit(this.ngControl.status); } - /** Checks to see if the (tagEnd) event needs to be emitted. */ - emitTagEnd() { + /** + * Checks to see if the (tagEnd) event needs to be emitted. + * @docs-private + */ + emitTagEnd(): void { if (!this.hasControl() || (this.hasControl() && !this.ngControl.invalid)) { if (this.distinct() && this.hasDuplicates) return; @@ -248,18 +320,24 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { } } + /** + * Whether the current input value duplicates an existing tag. + * @docs-private + */ get hasDuplicates(): boolean { return this._tagList.tags .map(({ value }) => value) .some((tagValue) => tagValue === this.trimValue(this.inputElement.value)); } - onInput() { + /** @docs-private */ + onInput(): void { // Let tag list know whenever the value changes. this._tagList.stateChanges.next(); } - onPaste($event: ClipboardEvent) { + /** @docs-private */ + onPaste($event: ClipboardEvent): void { if (!$event.clipboardData) { return; } @@ -270,7 +348,7 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { return; } - const separatorsInString = this.getSeparatorsForString(data); + const separatorsInString = this.getPasteSeparatorPatterns(data); // prettier-ignore const dividedString: string[] = separatorsInString.length > 0 ? @@ -311,9 +389,13 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { this.inputElement.focus(); } - private getSeparatorsForString(value: string): string[] { + private getPasteSeparatorPatterns(value: string): string[] { return this.separators - .filter((separator) => value.search(separator.symbol) > -1) + .filter( + (separator) => + (!separator.appliesTo || separator.appliesTo.includes('paste')) && + value.search(separator.symbol) > -1 + ) .map((separator) => separator.symbol.source); } @@ -321,22 +403,17 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { return this.trimDirective ? this.trimDirective.trim(value) : value; } - private getSeparatorByKeyCode(keyCode: number): KbqTagSeparator | null { - const sep = this._separators[keyCode]; - - if (sep) { - return sep; - } - - return null; - } - private hasControl(): boolean { return !!this.ngControl; } - /** Checks whether a keycode is one of the configured separators. */ - private isSeparatorKey(event: KeyboardEvent) { - return this.separators.some((separator) => separator.key === event.key && !hasModifierKey(event)); + /** Checks whether a keydown event matches a separator that applies to typed input. */ + private matchesInputSeparator(event: KeyboardEvent): boolean { + return this.separators.some( + (separator) => + separator.key === event.key && + !hasModifierKey(event) && + (!separator.appliesTo || separator.appliesTo.includes('input')) + ); } } diff --git a/packages/components/tags/tag.module.ts b/packages/components/tags/tag.module.ts index 9d3abe4044..1a6f7d97cc 100644 --- a/packages/components/tags/tag.module.ts +++ b/packages/components/tags/tag.module.ts @@ -1,8 +1,7 @@ import { PlatformModule } from '@angular/cdk/platform'; import { NgModule } from '@angular/core'; import { ENTER } from '@koobiq/components/core'; -import { KBQ_TAGS_DEFAULT_OPTIONS, KbqTagsDefaultOptions } from './tag-default-options'; -import { KbqTagInput } from './tag-input'; +import { KbqTagInput, kbqTagsDefaultOptionsProvider } from './tag-input'; import { KbqTagList } from './tag-list.component'; import { KbqTag, @@ -33,12 +32,7 @@ const COMPONENTS = [ KbqTagAvatar, KbqTagRemove ], - providers: [ - { - provide: KBQ_TAGS_DEFAULT_OPTIONS, - useValue: { separatorKeyCodes: [ENTER] } as KbqTagsDefaultOptions - } - ], + providers: [kbqTagsDefaultOptionsProvider({ separatorKeyCodes: [ENTER] })], exports: [ KbqTagList, KbqTag, diff --git a/packages/docs-examples/components/tags/tag-autocomplete-onpaste-off/tag-autocomplete-onpaste-off-example.ts b/packages/docs-examples/components/tags/tag-autocomplete-onpaste-off/tag-autocomplete-onpaste-off-example.ts index 8805d7ea1e..db8806348e 100644 --- a/packages/docs-examples/components/tags/tag-autocomplete-onpaste-off/tag-autocomplete-onpaste-off-example.ts +++ b/packages/docs-examples/components/tags/tag-autocomplete-onpaste-off/tag-autocomplete-onpaste-off-example.ts @@ -6,12 +6,11 @@ import { ENTER, KbqHighlightBackgroundPipe } from '@koobiq/components/core'; import { KbqFormFieldModule } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; import { - KBQ_TAGS_DEFAULT_OPTIONS, KbqTagInput, KbqTagInputEvent, KbqTagList, - KbqTagsDefaultOptions, - KbqTagsModule + KbqTagsModule, + kbqTagsDefaultOptionsProvider } from '@koobiq/components/tags'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -43,12 +42,7 @@ const autocompleteValueCoercion = (value): string => (value?.new ? value.value : } `, // turn off tag add on paste with InjectionToken - providers: [ - { - provide: KBQ_TAGS_DEFAULT_OPTIONS, - useValue: { separatorKeyCodes: [ENTER], addOnPaste: false } as KbqTagsDefaultOptions - } - ], + providers: [kbqTagsDefaultOptionsProvider({ separatorKeyCodes: [ENTER], addOnPaste: false })], changeDetection: ChangeDetectionStrategy.OnPush }) export class TagAutocompleteOnpasteOffExample implements AfterViewInit { diff --git a/packages/docs-examples/components/tags/tag-input-onpaste-off/tag-input-onpaste-off-example.ts b/packages/docs-examples/components/tags/tag-input-onpaste-off/tag-input-onpaste-off-example.ts index 9357560be5..3b5382bf2a 100644 --- a/packages/docs-examples/components/tags/tag-input-onpaste-off/tag-input-onpaste-off-example.ts +++ b/packages/docs-examples/components/tags/tag-input-onpaste-off/tag-input-onpaste-off-example.ts @@ -3,13 +3,7 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { COMMA, ENTER } from '@koobiq/components/core'; import { KbqFormFieldModule } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; -import { - KBQ_TAGS_DEFAULT_OPTIONS, - KbqTagInput, - KbqTagInputEvent, - KbqTagsDefaultOptions, - KbqTagsModule -} from '@koobiq/components/tags'; +import { KbqTagInput, KbqTagInputEvent, KbqTagsModule, kbqTagsDefaultOptionsProvider } from '@koobiq/components/tags'; /** * @title Tag input onpaste off @@ -24,12 +18,7 @@ import { ], templateUrl: 'tag-input-onpaste-off-example.html', // turn off tag add on paste with InjectionToken - providers: [ - { - provide: KBQ_TAGS_DEFAULT_OPTIONS, - useValue: { separatorKeyCodes: [ENTER], addOnPaste: false } as KbqTagsDefaultOptions - } - ], + providers: [kbqTagsDefaultOptionsProvider({ separatorKeyCodes: [ENTER], addOnPaste: false })], changeDetection: ChangeDetectionStrategy.OnPush }) export class TagInputOnpasteOffExample { diff --git a/packages/e2e/routes.ts b/packages/e2e/routes.ts index 9a4b5faadd..7305df7225 100644 --- a/packages/e2e/routes.ts +++ b/packages/e2e/routes.ts @@ -103,6 +103,7 @@ import { E2eTabNavBar, E2eTabsStates } from '../components/tabs/e2e'; import { E2eTagAutocompleteStates, E2eTagEditable, + E2eTagInputSeparators, E2eTagInputStates, E2eTagListStates, E2eTagStateAndStyle @@ -196,6 +197,7 @@ const components = [ E2eLinkStates, E2eLinkWithCaption, E2eTagInputStates, + E2eTagInputSeparators, E2eModalStates, E2eModalFullCustom, E2eListStates, diff --git a/tools/public_api_guard/components/tags.api.md b/tools/public_api_guard/components/tags.api.md index 9e2ff9c3d4..f945138ba6 100644 --- a/tools/public_api_guard/components/tags.api.md +++ b/tools/public_api_guard/components/tags.api.md @@ -33,6 +33,7 @@ import { NgForm } from '@angular/forms'; import { Observable } from 'rxjs'; import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; +import { Provider } from '@angular/core'; import { QueryList } from '@angular/core'; import { Subject } from 'rxjs'; @@ -174,8 +175,7 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { constructor(); get addOnBlur(): boolean; set addOnBlur(value: boolean); - readonly addOnPaste: i0.InputSignalWithTransform; - // (undocumented) + readonly addOnPaste: _angular_core.InputSignalWithTransform; autocompleteTrigger?: KbqAutocompleteTrigger | null | undefined; blur(event: FocusEvent): void; get disabled(): boolean; @@ -185,25 +185,20 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { get empty(): boolean; focus(): void; focused: boolean; - // (undocumented) get hasDuplicates(): boolean; id: string; - // (undocumented) ngControl: NgControl; // (undocumented) ngOnChanges(): void; onFocus(): void; - // (undocumented) onInput(): void; onKeydown(event: KeyboardEvent): void; - // (undocumented) onPaste($event: ClipboardEvent): void; placeholder: string; set separatorKeyCodes(value: number[]); get separators(): KbqTagSeparator[]; readonly tagEnd: i0.OutputEmitterRef; set tagList(value: KbqTagList); - // (undocumented) triggerValidation(): void; // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration; @@ -347,15 +342,14 @@ export class KbqTagRemove { // @public export interface KbqTagsDefaultOptions { - // (undocumented) addOnPaste?: boolean; separatorKeyCodes: number[]; - // (undocumented) - separators?: { - [key: number]: KbqTagSeparator; - }; + separators?: KbqTagSeparator[]; } +// @public +export const kbqTagsDefaultOptionsProvider: (options: Partial) => Provider; + // @public export class KbqTagSelectionChange { constructor(source: KbqTag, selected: boolean, isUserInput?: boolean); @@ -367,14 +361,17 @@ export class KbqTagSelectionChange { source: KbqTag; } -// @public (undocumented) +// @public export interface KbqTagSeparator { - // (undocumented) - key: string; - // (undocumented) + appliesTo?: KbqTagSeparatorContext[]; + key?: string; + keyCode?: number; symbol: RegExp; } +// @public +export type KbqTagSeparatorContext = 'input' | 'paste'; + // @public (undocumented) export class KbqTagsModule { // (undocumented) From 86a3dd7758a26f91d07d304b75817c5ef16ba168 Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Fri, 14 Aug 2026 10:13:07 +0300 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/components/tags/tag-input.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/tags/tag-input.ts b/packages/components/tags/tag-input.ts index 6d25d01e5f..75f8af4d07 100644 --- a/packages/components/tags/tag-input.ts +++ b/packages/components/tags/tag-input.ts @@ -64,7 +64,7 @@ export interface KbqTagSeparator { appliesTo?: KbqTagSeparatorContext[]; } -/** Default options, for the chips module, that can be overridden. */ +/** Default options for the tags module that can be overridden. */ export interface KbqTagsDefaultOptions { /** The list of key codes that will trigger a chipEnd event. */ separatorKeyCodes: number[]; From 2465144232268f7250ba043ae5d8dd6ef46ff7a4 Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Fri, 14 Aug 2026 10:36:03 +0300 Subject: [PATCH 3/4] refactor: review --- packages/components/tags/tag-input.spec.ts | 26 ++++++++++++++++++++++ packages/components/tags/tag-input.ts | 13 ++++++----- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/components/tags/tag-input.spec.ts b/packages/components/tags/tag-input.spec.ts index 224aa79bb9..ee4aeed70d 100644 --- a/packages/components/tags/tag-input.spec.ts +++ b/packages/components/tags/tag-input.spec.ts @@ -79,6 +79,22 @@ class TestTagInputDefaultSeparators { readonly add = jest.fn(); } +@Component({ + // Deliberately importing the standalone directive/component directly, without KbqTagsModule + // (the only place that provides KBQ_TAGS_DEFAULT_OPTIONS), to verify the token's own + // `providedIn: 'root'` factory default keeps KbqTagInput usable on its own. + imports: [KbqTagInput, KbqTagList], + template: ` + + + + ` +}) +class TestTagInputStandaloneWithoutModule { + readonly tagInput = viewChild.required(KbqTagInput); + readonly add = jest.fn(); +} + @Component({ imports: [KbqTagsModule], template: ` @@ -288,6 +304,16 @@ describe(KbqTagInput.name, () => { expect(fixture.componentInstance.add).toHaveBeenCalledWith(expect.objectContaining({ value: 'tag' })); }); + it('works standalone without KbqTagsModule providing KBQ_TAGS_DEFAULT_OPTIONS', () => { + const fixture = createComponent(TestTagInputStandaloneWithoutModule); + const inputElement = getInputElement(fixture); + + inputElement.value = 'tag'; + fixture.componentInstance.tagInput().onKeydown(createKeyboardEvent('keydown', ENTER, inputElement, 'Enter')); + + expect(fixture.componentInstance.add).toHaveBeenCalledWith(expect.objectContaining({ value: 'tag' })); + }); + it('should kbqTagInputAddOnPaste by default', () => { const fixture = createComponent(TestTagInputWithDashSeparator); const directive = fixture.componentInstance.tagInput(); diff --git a/packages/components/tags/tag-input.ts b/packages/components/tags/tag-input.ts index 75f8af4d07..e5f1e67a01 100644 --- a/packages/components/tags/tag-input.ts +++ b/packages/components/tags/tag-input.ts @@ -1,4 +1,4 @@ -import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { booleanAttribute, Directive, @@ -66,7 +66,7 @@ export interface KbqTagSeparator { /** Default options for the tags module that can be overridden. */ export interface KbqTagsDefaultOptions { - /** The list of key codes that will trigger a chipEnd event. */ + /** The list of key codes that will trigger a tagEnd event. */ separatorKeyCodes: number[]; /** Custom separator definitions to use instead of the built-in defaults. */ @@ -76,11 +76,14 @@ export interface KbqTagsDefaultOptions { addOnPaste?: boolean; } -/** Injection token to be used to override the default options for the chips module. */ -export const KBQ_TAGS_DEFAULT_OPTIONS = new InjectionToken('kbq-tags-default-options'); - const KBQ_TAGS_DEFAULT_OPTIONS_CONFIG: KbqTagsDefaultOptions = { separatorKeyCodes: [ENTER] }; +/** Injection token to be used to override the default options. */ +export const KBQ_TAGS_DEFAULT_OPTIONS = new InjectionToken('kbq-tags-default-options', { + providedIn: 'root', + factory: () => KBQ_TAGS_DEFAULT_OPTIONS_CONFIG +}); + /** Utility provider for `KBQ_TAGS_DEFAULT_OPTIONS`. */ export const kbqTagsDefaultOptionsProvider = (options: Partial): Provider => ({ provide: KBQ_TAGS_DEFAULT_OPTIONS, From 9ca8a2cbca91f49b0c4115f61a3280ce622ef716 Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Fri, 14 Aug 2026 10:43:00 +0300 Subject: [PATCH 4/4] refactor: review --- tools/public_api_guard/components/tags.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/public_api_guard/components/tags.api.md b/tools/public_api_guard/components/tags.api.md index f945138ba6..9aece38f2b 100644 --- a/tools/public_api_guard/components/tags.api.md +++ b/tools/public_api_guard/components/tags.api.md @@ -175,7 +175,7 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { constructor(); get addOnBlur(): boolean; set addOnBlur(value: boolean); - readonly addOnPaste: _angular_core.InputSignalWithTransform; + readonly addOnPaste: i0.InputSignalWithTransform; autocompleteTrigger?: KbqAutocompleteTrigger | null | undefined; blur(event: FocusEvent): void; get disabled(): boolean;