diff --git a/packages/components-dev/tag/module.ts b/packages/components-dev/tag/module.ts
index 1c2a0a331..bcf04080b 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 0614fc535..fae132971 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 1c502fc2c..231ac5348 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 8d7892157..771990e12 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 8a595b474..000000000
--- 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 df1aad7d5..697cebfa4 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 84583c452..d490a2aa1 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 555a5446f..ee4aeed70 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 => {
@@ -80,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: `
@@ -88,14 +103,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 +115,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: `
@@ -246,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();
@@ -387,6 +455,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 96973a83e..e5f1e67a0 100644
--- a/packages/components/tags/tag-input.ts
+++ b/packages/components/tags/tag-input.ts
@@ -5,26 +5,27 @@ import {
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,60 @@ 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 tags module that can be overridden. */
+export interface KbqTagsDefaultOptions {
+ /** The list of key codes that will trigger a tagEnd 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;
}
+
+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,
+ useValue: { ...KBQ_TAGS_DEFAULT_OPTIONS_CONFIG, ...options }
+});
+
// Increasing integer for generating unique ids.
let nextUniqueId = 0;
@@ -66,9 +117,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 +147,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 +243,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 +277,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 +298,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 +310,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 +323,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 +351,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 +392,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 +406,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 9d3abe404..1a6f7d97c 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 8805d7ea1..db8806348 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 9357560be..3b5382bf2 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 9a4b5faad..7305df722 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 9e2ff9c3d..9aece38f2 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';
@@ -175,7 +176,6 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges {
get addOnBlur(): boolean;
set addOnBlur(value: boolean);
readonly addOnPaste: i0.InputSignalWithTransform;
- // (undocumented)
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)