From 7df6b63a223c3520c489910f7b5e4e12f6acb06a Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Wed, 5 Nov 2025 14:14:19 +0300 Subject: [PATCH] feat(tags): apply KbqFieldSizingContent directive to KbqTagInput (#DS-4204) --- .../form-field/field-sizing-content.spec.ts | 46 ++++++++++++++++ .../core/form-field/field-sizing-content.ts | 4 +- packages/components/tags/tag-input.ts | 55 +++++-------------- packages/components/tags/tag-list.scss | 27 +++++---- ...-autocomplete-option-operations-example.ts | 9 ++- .../tag-input-draggable-example.ts | 3 +- ...ut-with-form-control-validators-example.ts | 2 +- ...tags-autocomplete-onpaste-off-example.html | 2 +- .../tags-input-onpaste-off-example.html | 2 +- tools/jest/setup.ts | 2 +- tools/public_api_guard/components/tags.api.md | 11 ++-- 11 files changed, 95 insertions(+), 68 deletions(-) create mode 100644 packages/components/core/form-field/field-sizing-content.spec.ts diff --git a/packages/components/core/form-field/field-sizing-content.spec.ts b/packages/components/core/form-field/field-sizing-content.spec.ts new file mode 100644 index 0000000000..d9aca92e9f --- /dev/null +++ b/packages/components/core/form-field/field-sizing-content.spec.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, Type } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { KbqFieldSizingContent } from './field-sizing-content'; + +const createComponent = (component: Type): ComponentFixture => { + TestBed.configureTestingModule({ imports: [component] }).compileComponents(); + const fixture = TestBed.createComponent(component); + + fixture.autoDetectChanges(); + + return fixture; +}; + +const getInputNativeElement = ({ debugElement }: ComponentFixture): HTMLInputElement => { + return debugElement.query(By.directive(KbqFieldSizingContent)).nativeElement; +}; + +@Component({ + selector: 'test-field-sizing-content', + standalone: true, + imports: [KbqFieldSizingContent], + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TestFieldSizingContent {} + +describe(KbqFieldSizingContent.name, () => { + it('should apply kbq-field-sizing-content class', () => { + const fixture = createComponent(TestFieldSizingContent); + const input = getInputNativeElement(fixture); + + expect(input.classList).toContain('kbq-field-sizing-content'); + }); + + it('should use native field-sizing when browser supports it', () => { + (CSS.supports as jest.Mock).mockReturnValue(true); + + const fixture = createComponent(TestFieldSizingContent); + const input = getInputNativeElement(fixture); + + expect(input.style['fieldSizing']).toBe('content'); + }); +}); diff --git a/packages/components/core/form-field/field-sizing-content.ts b/packages/components/core/form-field/field-sizing-content.ts index 3fe03f8b8d..2e839680ec 100644 --- a/packages/components/core/form-field/field-sizing-content.ts +++ b/packages/components/core/form-field/field-sizing-content.ts @@ -51,7 +51,7 @@ const BOX_SIZING_BORDER_BOX_WIDTH_PROPERTIES = [ 'borderRightWidth' ] as const satisfies Array; -const FIELD_RESIZE_EVENTS = ['input', 'change', 'focus'] as const; +const FIELD_RESIZE_EVENTS = ['input', 'change', 'focus', 'blur'] as const; /** * Emulates [`field-sizing: content`](https://developer.mozilla.org/en-US/docs/Web/CSS/field-sizing) CSS property for @@ -97,6 +97,8 @@ export class KbqFieldSizingContent { const ruler = this.createRuler(computedStyle); ruler.textContent = this.element.value || this.element.placeholder || ''; + // We should add space to prevent text truncation in Safari/Firefox + if (ruler.textContent) ruler.textContent += ' '; this.renderer.appendChild(this.document.body, ruler); diff --git a/packages/components/tags/tag-input.ts b/packages/components/tags/tag-input.ts index c127402cd2..9ff741fb24 100644 --- a/packages/components/tags/tag-input.ts +++ b/packages/components/tags/tag-input.ts @@ -1,20 +1,9 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; -import { - Directive, - ElementRef, - EventEmitter, - Inject, - Input, - OnChanges, - Optional, - Output, - Renderer2, - Self -} from '@angular/core'; +import { Directive, ElementRef, EventEmitter, Inject, Input, OnChanges, Optional, Output, Self } from '@angular/core'; import { NgControl } from '@angular/forms'; import { COMMA, ENTER, SEMICOLON, SPACE, TAB } from '@koobiq/cdk/keycodes'; import { KbqAutocompleteTrigger } from '@koobiq/components/autocomplete'; -import { isBoolean } from '@koobiq/components/core'; +import { isBoolean, KbqFieldSizingContent } 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'; @@ -63,7 +52,8 @@ let nextUniqueId = 0; '(focus)': 'onFocus()', '(input)': 'onInput()', '(paste)': 'onPaste($event)' - } + }, + hostDirectives: [KbqFieldSizingContent] }) export class KbqTagInput implements KbqTagTextControl, OnChanges { /** Whether the control is focused. */ @@ -160,16 +150,18 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { return !this.inputElement.value; } + /** + * @docs-private + * + * @deprecated Unused. Will be removed in next major release. + */ countOfSymbolsForUpdateWidth: number = 3; - private oneSymbolWidth: number; - /** The native input element to which this directive is attached. */ private inputElement: HTMLInputElement; constructor( private elementRef: ElementRef, - private renderer: Renderer2, @Inject(KBQ_TAGS_DEFAULT_OPTIONS) private defaultOptions: KbqTagsDefaultOptions, @Optional() @Self() private trimDirective: KbqTrim, @Optional() @Self() public ngControl: NgControl, @@ -177,8 +169,6 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { ) { this.inputElement = this.elementRef.nativeElement as HTMLInputElement; - this.setDefaultInputWidth(); - this._separators = this.defaultOptions.separators || KbqTagInputDefaultSeparators; this._addOnPaste = isBoolean(this.defaultOptions.addOnPaste) ? this.defaultOptions.addOnPaste : true; } @@ -231,7 +221,6 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { if (this.distinct && this.hasDuplicates) return; this.tagEnd.emit({ input: this.inputElement, value: this.trimValue(this.inputElement.value) }); - this.updateInputWidth(); } } @@ -242,7 +231,6 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { } onInput() { - this.updateInputWidth(); // Let tag list know whenever the value changes. this._tagList.stateChanges.next(); } @@ -277,25 +265,16 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { .filter((item) => !tagValues.includes(item)) .forEach((item) => this.tagEnd.emit({ input: this.inputElement, value: item })); - this.updateInputWidth(); - $event.preventDefault(); $event.stopPropagation(); } - updateInputWidth(): void { - const length = this.inputElement.value.length; - - this.renderer.setStyle(this.inputElement, 'max-width', 0); - this.oneSymbolWidth = this.inputElement.scrollWidth / length; - this.renderer.setStyle(this.inputElement, 'max-width', ''); - - if (length > this.countOfSymbolsForUpdateWidth) { - this.renderer.setStyle(this.inputElement, 'width', `${length * this.oneSymbolWidth}px`); - } else { - this.setDefaultInputWidth(); - } - } + /** + * @docs-private + * + * @deprecated Unused. Will be removed in next major release. + */ + updateInputWidth(): void {} /** @docs-private */ onFocus(): void { @@ -333,10 +312,6 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { return !!this.ngControl; } - private setDefaultInputWidth() { - this.renderer.setStyle(this.inputElement, 'width', '30px'); - } - /** Checks whether a keycode is one of the configured separators. */ private isSeparatorKey(event: KeyboardEvent) { return this.separators.some((separator) => separator.key === event.key && !event.shiftKey); diff --git a/packages/components/tags/tag-list.scss b/packages/components/tags/tag-list.scss index 6416b4c855..301ae68b90 100644 --- a/packages/components/tags/tag-list.scss +++ b/packages/components/tags/tag-list.scss @@ -1,4 +1,5 @@ @use '../core/styles/common/tokens' as *; +@use '../core/styles/common/input'; .kbq-tag-list { display: flex; @@ -17,22 +18,24 @@ } .kbq-tag-input { - border: none; - outline: none; - background: transparent; - - text-overflow: ellipsis; - - min-height: unset; + @include input.kbq-reset-input(); padding-top: var(--kbq-tag-size-padding-vertical); padding-bottom: var(--kbq-tag-size-padding-vertical); - padding-left: 4px; + padding-left: var(--kbq-size-xxs); padding-right: unset; + min-width: var(--kbq-size-3xl); + max-width: 100%; + flex-grow: 1; + &.kbq-input { - padding: unset; - min-height: unset; + --kbq-input-size-padding-vertical: 0; + --kbq-form-field-size-border-radius: 0; + + padding-left: var(--kbq-size-xxs); + min-height: unset !important; + width: auto; } } @@ -48,10 +51,6 @@ gap: var(--kbq-tag-list-size-content-gap); .kbq-tag-input { - max-width: 100%; - - flex: 1 1 auto; - // indicating tag list is empty &:first-child { padding-left: var(--kbq-size-s); diff --git a/packages/docs-examples/components/tag/tag-autocomplete-option-operations/tag-autocomplete-option-operations-example.ts b/packages/docs-examples/components/tag/tag-autocomplete-option-operations/tag-autocomplete-option-operations-example.ts index 873e2c833b..12194887d1 100644 --- a/packages/docs-examples/components/tag/tag-autocomplete-option-operations/tag-autocomplete-option-operations-example.ts +++ b/packages/docs-examples/components/tag/tag-autocomplete-option-operations/tag-autocomplete-option-operations-example.ts @@ -2,8 +2,10 @@ import { AsyncPipe } from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, ViewChild } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { KbqAutocomplete, KbqAutocompleteModule, KbqAutocompleteSelectedEvent } from '@koobiq/components/autocomplete'; +import { kbqDisableLegacyValidationDirectiveProvider } from '@koobiq/components/core'; import { KbqFormFieldModule } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqInputModule } from '@koobiq/components/input'; import { KbqTag, KbqTagInput, KbqTagInputEvent, KbqTagList, KbqTagsModule } from '@koobiq/components/tags'; import { Observable, merge } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -24,8 +26,10 @@ const autocompleteValueCoercion = (value): string => (value?.new ? value.value : ReactiveFormsModule, KbqAutocompleteModule, KbqIconModule, - AsyncPipe + AsyncPipe, + KbqInputModule ], + providers: [kbqDisableLegacyValidationDirectiveProvider()], template: ` @@ -38,7 +42,8 @@ const autocompleteValueCoercion = (value): string => (value?.new ? value.value : Array.from({ length: 3 }, (_, id) => ({ id, value: `Dragga @Component({ standalone: true, selector: 'tag-input-draggable-example', - imports: [KbqTagsModule, KbqFormFieldModule, KbqIconModule], + imports: [KbqTagsModule, KbqFormFieldModule, KbqIconModule, KbqInputModule], providers: [kbqDisableLegacyValidationDirectiveProvider()], template: ` diff --git a/packages/docs-examples/components/tag/tag-input-with-form-control-validators/tag-input-with-form-control-validators-example.ts b/packages/docs-examples/components/tag/tag-input-with-form-control-validators/tag-input-with-form-control-validators-example.ts index d30ad9dedb..c53af4ec9a 100644 --- a/packages/docs-examples/components/tag/tag-input-with-form-control-validators/tag-input-with-form-control-validators-example.ts +++ b/packages/docs-examples/components/tag/tag-input-with-form-control-validators/tag-input-with-form-control-validators-example.ts @@ -48,7 +48,7 @@ const customMaxLengthValidator = (max: number): ValidatorFn => { diff --git a/packages/docs-examples/components/tag/tags-autocomplete-onpaste-off/tags-autocomplete-onpaste-off-example.html b/packages/docs-examples/components/tag/tags-autocomplete-onpaste-off/tags-autocomplete-onpaste-off-example.html index be2018aec0..64effd213e 100644 --- a/packages/docs-examples/components/tag/tags-autocomplete-onpaste-off/tags-autocomplete-onpaste-off-example.html +++ b/packages/docs-examples/components/tag/tags-autocomplete-onpaste-off/tags-autocomplete-onpaste-off-example.html @@ -11,7 +11,7 @@ false + supports: jest.fn().mockReturnValue(false) as typeof CSS.supports } as typeof CSS; diff --git a/tools/public_api_guard/components/tags.api.md b/tools/public_api_guard/components/tags.api.md index eaebdc14b0..874beb6956 100644 --- a/tools/public_api_guard/components/tags.api.md +++ b/tools/public_api_guard/components/tags.api.md @@ -37,7 +37,6 @@ import { Observable } from 'rxjs'; import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { QueryList } from '@angular/core'; -import { Renderer2 } from '@angular/core'; import { SelectionModel } from '@angular/cdk/collections'; import { Subject } from 'rxjs'; import { WritableSignal } from '@angular/core'; @@ -177,7 +176,7 @@ export interface KbqTagEvent { // // @public export class KbqTagInput implements KbqTagTextControl, OnChanges { - constructor(elementRef: ElementRef, renderer: Renderer2, defaultOptions: KbqTagsDefaultOptions, trimDirective: KbqTrim, ngControl: NgControl, autocompleteTrigger?: KbqAutocompleteTrigger | undefined); + constructor(elementRef: ElementRef, defaultOptions: KbqTagsDefaultOptions, trimDirective: KbqTrim, ngControl: NgControl, autocompleteTrigger?: KbqAutocompleteTrigger | undefined); get addOnBlur(): boolean; set addOnBlur(value: boolean); get addOnPaste(): boolean; @@ -185,7 +184,7 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { // (undocumented) autocompleteTrigger?: KbqAutocompleteTrigger | undefined; blur(event: FocusEvent): void; - // (undocumented) + // @deprecated countOfSymbolsForUpdateWidth: number; get disabled(): boolean; set disabled(value: boolean); @@ -216,12 +215,12 @@ export class KbqTagInput implements KbqTagTextControl, OnChanges { set tagList(value: KbqTagList); // (undocumented) triggerValidation(): void; - // (undocumented) + // @deprecated updateInputWidth(): void; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public