diff --git a/AGENTS.md b/AGENTS.md index 20134bfab0..de86b8ae62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ yarn install Setup git hooks: ```bash -yarn husky +npx husky ``` ## Package Structure @@ -152,6 +152,7 @@ You are an expert in TypeScript, Angular, and scalable web application developme - Do NOT use `ngClass`, use `class` bindings instead - Do NOT use `ngStyle`, use `style` bindings instead - When using external templates/styles, use paths relative to the component TS file. +- When a projected child depends on its host component, inject a narrow `InjectionToken` scoped to the members it uses, not the host's concrete class. ### Form Validation diff --git a/packages/components-dev/form-field/template.html b/packages/components-dev/form-field/template.html index c8603a9618..f6a26571ea 100644 --- a/packages/components-dev/form-field/template.html +++ b/packages/components-dev/form-field/template.html @@ -41,7 +41,7 @@ - + @for (option of options; track option) { @@ -55,7 +55,7 @@ - + @for (option of options; track option) { diff --git a/packages/components-dev/select/template.html b/packages/components-dev/select/template.html index a8d379d21c..1c3cba3dee 100644 --- a/packages/components-dev/select/template.html +++ b/packages/components-dev/select/template.html @@ -24,7 +24,7 @@ - + @@ -53,7 +53,7 @@ - + @@ -82,7 +82,7 @@ - + @@ -134,7 +134,7 @@ - + @@ -160,7 +160,7 @@ - + @@ -409,7 +409,7 @@ - + diff --git a/packages/components-dev/tag/template.html b/packages/components-dev/tag/template.html index 2f416d93fc..ddf3ea06b4 100644 --- a/packages/components-dev/tag/template.html +++ b/packages/components-dev/tag/template.html @@ -78,7 +78,7 @@

Tags with input

[kbqTagInputSeparatorKeyCodes]="separatorKeysCodes" (kbqTagInputTokenEnd)="inputOnCreate($event)" /> - +
@@ -105,7 +105,7 @@

Tags with autocomplete

(kbqTagInputTokenEnd)="autocompleteOnCreate($event)" /> - + @if (canCreate) { @@ -145,7 +145,7 @@

Tags with tag adding on blur disabled

(kbqTagInputTokenEnd)="enterOnCreate($event)" /> - +
diff --git a/packages/components-dev/tree-select/template.html b/packages/components-dev/tree-select/template.html index b37aa12a1e..965440c564 100644 --- a/packages/components-dev/tree-select/template.html +++ b/packages/components-dev/tree-select/template.html @@ -23,7 +23,7 @@

Custom footer

Ничего не найдено
- + diff --git a/packages/components/autocomplete/autocomplete-trigger.directive.ts b/packages/components/autocomplete/autocomplete-trigger.directive.ts index 24ef478e2d..cdd6b509b1 100644 --- a/packages/components/autocomplete/autocomplete-trigger.directive.ts +++ b/packages/components/autocomplete/autocomplete-trigger.directive.ts @@ -50,7 +50,7 @@ import { kbqResolvePanelWidth, kbqSiblingPopupProvider } from '@koobiq/components/core'; -import { KbqFormField } from '@koobiq/components/form-field'; +import { KBQ_FORM_FIELD } from '@koobiq/components/form-field'; import { Observable, Subject, Subscription, defer, fromEvent, merge, of as observableOf } from 'rxjs'; import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { KbqAutocompleteOrigin } from './autocomplete-origin.directive'; @@ -127,7 +127,7 @@ export class KbqAutocompleteTrigger private overlay = inject(Overlay); private zone = inject(NgZone); private dir = inject(Directionality, { optional: true })!; - private formField = inject(KbqFormField, { optional: true, host: true }); + private readonly formField = inject(KBQ_FORM_FIELD, { optional: true, host: true }); private viewportRuler = inject(ViewportRuler); protected readonly document = inject(DOCUMENT); diff --git a/packages/components/autocomplete/autocomplete.component.ts b/packages/components/autocomplete/autocomplete.component.ts index c0c6ec140b..0c4124d070 100644 --- a/packages/components/autocomplete/autocomplete.component.ts +++ b/packages/components/autocomplete/autocomplete.component.ts @@ -30,7 +30,7 @@ import { KbqPanelMaxWidth, KbqPanelWidth } from '@koobiq/components/core'; -import { KbqFormField } from '@koobiq/components/form-field'; +import { KBQ_FORM_FIELD } from '@koobiq/components/form-field'; import { delay, filter } from 'rxjs/operators'; /** @@ -96,7 +96,7 @@ export function KBQ_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY(): KbqAutocompleteDefau export class KbqAutocomplete implements AfterContentInit { private changeDetectorRef = inject(ChangeDetectorRef); private elementRef = inject>(ElementRef); - private readonly parentFormField = inject(KbqFormField, { host: true, optional: true }); + private readonly parentFormField = inject(KBQ_FORM_FIELD, { host: true, optional: true }); private readonly destroyRef = inject(DestroyRef); /** Unique ID to be used by autocomplete trigger's "aria-owns" property. */ id: string = `kbq-autocomplete-${uniqueAutocompleteIdCounter++}`; diff --git a/packages/components/core/form-field/form-field-ref.ts b/packages/components/core/form-field/form-field-ref.ts index 321e3af245..4ee4794d3d 100644 --- a/packages/components/core/form-field/form-field-ref.ts +++ b/packages/components/core/form-field/form-field-ref.ts @@ -40,6 +40,8 @@ export interface KbqFormFieldControlRef { * Internal contract for `KbqFormField` used by code that cannot import the form-field * class directly because of a circular dependency (e.g. `core/select/common.ts`). * + * @deprecated Use KbqFormField from `@koobiq/components/form-field`. + * * @docs-private */ export interface KbqFormFieldRef { @@ -56,7 +58,9 @@ export interface KbqFormFieldRef { * as alternative token to the actual `KbqFormField` class which would cause unnecessary * retention of the `KbqFormField` class and its component metadata. * - * @TODO move into form-field.ts, add correct type for `InjectionToken` (#DS-2915) + * @deprecated Use `KBQ_FORM_FIELD` from `@koobiq/components/form-field`. + * + * @docs-private */ export const KBQ_FORM_FIELD_REF = new InjectionToken('KbqFormFieldRef'); diff --git a/packages/components/datepicker/datepicker-input.directive.ts b/packages/components/datepicker/datepicker-input.directive.ts index 53bf77e387..4c30c76925 100644 --- a/packages/components/datepicker/datepicker-input.directive.ts +++ b/packages/components/datepicker/datepicker-input.directive.ts @@ -55,7 +55,7 @@ import { validationTooltipHideDelay, validationTooltipShowDelay } from '@koobiq/components/core'; -import { KbqFormField, KbqFormFieldControl } from '@koobiq/components/form-field'; +import { KBQ_FORM_FIELD, KbqFormFieldControl } from '@koobiq/components/form-field'; import type { KbqTooltipTrigger } from '@koobiq/components/tooltip'; import { Subject, Subscription } from 'rxjs'; import { KbqCalendar } from './calendar.component'; @@ -251,7 +251,7 @@ export class KbqDatepickerInput readonly adapter = inject>(DateAdapter, { optional: true })!; private readonly dateFormats = inject(KBQ_DATE_FORMATS, { optional: true }); /** @docs-private */ - protected readonly formField = inject(KbqFormField, { optional: true, host: true }); + protected readonly formField = inject(KBQ_FORM_FIELD, { optional: true, host: true }); /** @docs-private */ protected readonly localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); /** @docs-private */ diff --git a/packages/components/filter-bar/pipes/pipe-input.spec.ts b/packages/components/filter-bar/pipes/pipe-input.spec.ts index 8884518ae7..3e569cb441 100644 --- a/packages/components/filter-bar/pipes/pipe-input.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-input.spec.ts @@ -398,8 +398,8 @@ describe('KbqPipeInputComponent', () => { describe('clearing', () => { beforeEach(createFixture); - // `KbqFormField.clearValue()` and the Escape handler only reset the control; the pipe bridges that - // back into `data`. Driving `reset()` directly covers both entry points at the seam the pipe owns. + // `KbqCleaner` resets the control for pointer and Escape activation; the pipe bridges that back into + // `data`. Driving `reset()` directly covers both entry points at the seam the pipe owns. it('should propagate a control reset to data and emit onClearPipe', () => { fixture.componentInstance.activeFilter = createFilter([createPipe({ value: 'some text' })]); fixture.detectChanges(); diff --git a/packages/components/filter-bar/pipes/pipe-input.ts b/packages/components/filter-bar/pipes/pipe-input.ts index cb8d68132d..2470712396 100644 --- a/packages/components/filter-bar/pipes/pipe-input.ts +++ b/packages/components/filter-bar/pipes/pipe-input.ts @@ -45,9 +45,9 @@ export class KbqPipeInputComponent extends KbqBasePipe implements /** * Input control. * - * Must stay nullable: `reset()` — all `KbqFormField.clearValue()` and its Escape handler ever call — - * resolves to `defaultValue`, which is `null` only while `nonNullable` is unset. `{ nonNullable: true }` - * would make `reset()` yield `''` and silently break the cleaner bridge below. + * Must stay nullable: `KbqCleaner` resets the control, and `reset()` resolves to `defaultValue`, which is + * `null` only while `nonNullable` is unset. `{ nonNullable: true }` would make `reset()` yield `''` and silently + * break the cleaner bridge below. */ readonly control = new FormControl(''); @@ -83,8 +83,8 @@ export class KbqPipeInputComponent extends KbqBasePipe implements this.control.disable({ emitEvent: false }); } - // Cleaner and Escape bridge. Both `KbqFormField.clearValue()` and its Escape handler only call - // `ngControl.reset()`, which nulls the control but never touches `data.value`. `reset()` yields null + // Cleaner and Escape bridge. `KbqCleaner` calls `ngControl.reset()`, which nulls the control but never + // touches `data.value`. `reset()` yields null // while the view can only ever push a string, so `=== null` isolates those two paths from typing. this.control.valueChanges .pipe( diff --git a/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts b/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts index f5c5a1db88..b02b574266 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts @@ -950,7 +950,7 @@ describe('KbqPipeMultiTreeSelectComponent', () => { const select = getPipeComponent().select(); // `KbqTreeSelect.clearValue()` empties the selection model directly, bypassing the pipe's - // `onClear()` and taking the locked values with it — hence no `kbqSelectCleaner` in the + // `onClear()` and taking the locked values with it — hence no `KbqCleaner` in the // template, which is what leaves `canShowCleaner` permanently false. expect(select.cleaner()).toBeUndefined(); expect(select.canShowCleaner).toBe(false); diff --git a/packages/components/filter-bar/pipes/pipe-tree-select.html b/packages/components/filter-bar/pipes/pipe-tree-select.html index 576ece8030..b8a8949444 100644 --- a/packages/components/filter-bar/pipes/pipe-tree-select.html +++ b/packages/components/filter-bar/pipes/pipe-tree-select.html @@ -28,7 +28,7 @@
{{ localeData.pipe.emptySearchResult }}
- + + `, + styles: ` + :host { + display: flex; + flex-direction: column; + gap: var(--kbq-size-m); + width: 320px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-testid': 'e2eFormFieldAddons' + } +}) +export class E2eFormFieldAddons { + protected textValue = 'Koobiq'; + protected passwordValue = 'password'; + protected numberValue = 10; + protected readonly showStepper = signal(false); +} diff --git a/packages/components/form-field/form-field.html b/packages/components/form-field/form-field.html index 30ff4c2351..15b8182725 100644 --- a/packages/components/form-field/form-field.html +++ b/packages/components/form-field/form-field.html @@ -19,12 +19,7 @@ @if (hasSuffix() || hasCleaner() || hasPasswordToggle() || hasStepper()) {
@if (canShowCleaner) { -
+
} diff --git a/packages/components/form-field/form-field.spec.ts b/packages/components/form-field/form-field.spec.ts index 99e8883c1d..e02a163c8d 100644 --- a/packages/components/form-field/form-field.spec.ts +++ b/packages/components/form-field/form-field.spec.ts @@ -12,6 +12,8 @@ import { import { By } from '@angular/platform-browser'; import { ErrorStateMatcher, + ESCAPE, + KBQ_FORM_FIELD_REF, PasswordValidators, ShowOnFormSubmitErrorStateMatcher, ShowRequiredOnSubmitErrorStateMatcher @@ -20,7 +22,12 @@ import { KbqInput, KbqInputModule, KbqInputPassword } from '@koobiq/components/i import { Subject } from 'rxjs'; import { KbqCleaner } from './cleaner'; import { KbqError } from './error'; -import { getKbqFormFieldMissingControlError, KbqFormField, kbqFormFieldDefaultOptionsProvider } from './form-field'; +import { + getKbqFormFieldMissingControlError, + KBQ_FORM_FIELD, + KbqFormField, + kbqFormFieldDefaultOptionsProvider +} from './form-field'; import { KbqFormFieldModule } from './form-field.module'; import { KbqHint } from './hint'; import { KbqLabel } from './label'; @@ -339,6 +346,14 @@ class PasswordFormFieldWithConditionalContent { } describe(KbqFormField.name, () => { + it('should provide typed and legacy form-field tokens', () => { + const { debugElement } = createComponent(InputFormFieldWithHintAndError); + const formField = getFormFieldDebugElement(debugElement); + + expect(formField.injector.get(KBQ_FORM_FIELD)).toBe(formField.componentInstance); + expect(formField.injector.get(KBQ_FORM_FIELD_REF)).toBe(formField.componentInstance); + }); + it('should display KbqHint', () => { const { debugElement } = createComponent(InputFormFieldWithHintAndError); const hint = getHintDebugElement(debugElement).nativeElement; @@ -428,6 +443,32 @@ describe(KbqFormField.name, () => { expect(componentInstance.control.value).toBeNull(); }); + it('should clean field by KbqCleaner on Space', () => { + const { debugElement, componentInstance } = createComponent(InputFormFieldWithCleaner); + const input = getInputNativeElement(debugElement); + + input.value = 'koobiq'; + input.dispatchEvent(new Event('input')); + + getCleanerDebugElement(debugElement).nativeElement.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', bubbles: true }) + ); + + expect(componentInstance.control.value).toBeNull(); + }); + + it('should clean focused field by KbqCleaner on Escape', () => { + const { debugElement, componentInstance } = createComponent(InputFormFieldWithCleaner); + const input = getInputNativeElement(debugElement); + + input.value = 'koobiq'; + input.dispatchEvent(new Event('input')); + input.focus(); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: ESCAPE, bubbles: true })); + + expect(componentInstance.control.value).toBeNull(); + }); + it('should apply kbq-error class to KbqCleaner icon when control is invalid', () => { const { debugElement } = createComponent(InputFormFieldWithCleanerAndCustomErrorStateMatcher); diff --git a/packages/components/form-field/form-field.ts b/packages/components/form-field/form-field.ts index b0ae3b10c9..4a80291ef4 100644 --- a/packages/components/form-field/form-field.ts +++ b/packages/components/form-field/form-field.ts @@ -28,9 +28,10 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NgControl } from '@angular/forms'; import { KBQ_CONNECTED_OVERLAY_ORIGIN, KBQ_FORM_FIELD_REF, KbqColorDirective } from '@koobiq/components/core'; +import { kbqIconErrorStateContextFactoryProvider } from '@koobiq/components/icon'; import { EMPTY, merge } from 'rxjs'; import { delay, startWith } from 'rxjs/operators'; -import { KbqCleaner } from './cleaner'; +import { KbqCleaner, kbqCleanerFactoryProvider } from './cleaner'; import { KbqError } from './error'; import { KbqFormFieldControl, kbqSetDescribedByIds } from './form-field-control'; import { KbqHint } from './hint'; @@ -39,7 +40,7 @@ import { hasPasswordStrengthError, KbqPasswordHint } from './password-hint'; import { KbqPasswordToggle } from './password-toggle'; import { KbqPrefix } from './prefix'; import { KbqReactivePasswordHint } from './reactive-password-hint'; -import { KbqNumberInputControl, KbqStepper } from './stepper'; +import { KbqStepper } from './stepper'; import { KbqSuffix } from './suffix'; /** @docs-private */ @@ -47,10 +48,11 @@ export function getKbqFormFieldMissingControlError(): Error { return Error('kbq-form-field must contain a KbqFormFieldControl'); } -/** @docs-private */ -export function getKbqFormFieldYouCanNotUseCleanerInNumberInputError(): Error { - return Error(`You can't use kbq-cleaner with input that have type="number"`); -} +/** + * Injection token used to access the owning `KbqFormField`. + * @docs-private + */ +export const KBQ_FORM_FIELD = new InjectionToken('KbqFormField'); /** Error key set on the control by the legacy `KbqPasswordHint` when the password is not strong enough. */ const PASSWORD_STRENGTH_ERROR = 'passwordStrength'; @@ -74,6 +76,7 @@ export type KbqFormFieldDefaultOptions = Partial<{ /** * Injection token that can be used to configure the default options for all kbq-form-field's. + * @docs-private */ export const KBQ_FORM_FIELD_DEFAULT_OPTIONS = new InjectionToken( 'KBQ_FORM_FIELD_DEFAULT_OPTIONS' @@ -108,7 +111,36 @@ export const kbqFormFieldDefaultOptionsProvider = (options: KbqFormFieldDefaultO // KbqTagInput '../tags/tag-input-tokens.scss' ], - providers: [{ provide: KBQ_FORM_FIELD_REF, useExisting: KbqFormField }], + providers: [ + // Kept for consumers that cannot depend on the form-field entry point. + { provide: KBQ_FORM_FIELD_REF, useExisting: KbqFormField }, + { provide: KBQ_FORM_FIELD, useExisting: KbqFormField }, + kbqCleanerFactoryProvider(() => { + const formField = inject(KBQ_FORM_FIELD); + + return { + get control() { + return formField.control(); + }, + get keydownTarget() { + return formField.elementRef.nativeElement; + }, + clearByEscape: true + }; + }), + kbqIconErrorStateContextFactoryProvider(() => { + const formField = inject(KBQ_FORM_FIELD); + + return { + get errorState() { + return formField.control().errorState; + }, + get stateChanges() { + return formField.control().stateChanges; + } + }; + }) + ], changeDetection: ChangeDetectionStrategy.OnPush, // Every component of this package renders unencapsulated, on purpose: the `kbq-form-field__*` and // `kbq-hint*` classes are a public contract that themes, the controls projected into the field and @@ -221,6 +253,7 @@ export class KbqFormField /** * @docs-private + * @deprecated Unused. This property is no longer used by the form field and will be removed in a future version. */ canCleanerClearByEsc: boolean = true; @@ -324,15 +357,7 @@ export class KbqFormField * @docs-private */ get canShowCleaner(): boolean { - const ngControl = this.control()?.ngControl; - - if (!this.hasCleaner() || !ngControl) { - return false; - } - - const { value } = ngControl; - - return value !== null && value !== undefined && value !== '' && !this.disabled; + return !!this.cleaner()?.canShow; } /** Whether the form field is disabled. */ @@ -346,10 +371,6 @@ export class KbqFormField ngAfterContentInit(): void { this.validateControlChild(); - if (this.numberInput() && this.hasCleaner()) { - throw getKbqFormFieldYouCanNotUseCleanerInNumberInputError(); - } - // Subscribe to changes in the child control state in order to update the form field UI. this.control() .stateChanges.pipe(startWith(), delay(0), takeUntilDestroyed(this.destroyRef)) @@ -361,10 +382,6 @@ export class KbqFormField } }); - if (this.hasStepper()) { - this.stepper()!.connectTo(this.numberInput()!); - } - this.initializeControl(); } @@ -392,17 +409,7 @@ export class KbqFormField * @docs-private */ clearValue(event: Event): void { - event.stopPropagation(); - - // Keyboard activation of the cleaner must not also scroll the page (Space) or submit the form (Enter). - if (event.type === 'keydown') { - event.preventDefault(); - } - - const control = this.control(); - - control?.ngControl?.reset(); - control?.focus(); + this.cleaner()?.clear(event); } /** @@ -420,22 +427,10 @@ export class KbqFormField /** * Handles keydown events. - * + * @deprecated Unused. This method is no longer called by the form field and will be removed in a future version. * @docs-private */ - onKeyDown(event: KeyboardEvent): void { - const control = this.control(); - - if (control.controlType === 'input-password' && event.altKey && event.key === 'F8') { - (control as unknown as { toggleType(): void }).toggleType(); - } - - if (this.canCleanerClearByEsc && event.key === 'Escape' && control.focused && this.hasCleaner()) { - control?.ngControl?.reset(); - - event.preventDefault(); - } - } + onKeyDown(_event: KeyboardEvent): void {} /** * @docs-private @@ -505,11 +500,6 @@ export class KbqFormField } } - /** Resolves the number input hosted by the control, `null` when the control is not a `kbqNumberInput`. */ - private numberInput(): KbqNumberInputControl | null { - return (this.control() as unknown as { numberInput?: KbqNumberInputControl }).numberInput || null; - } - /** * Links the hints and the error rendered by the form field to the control via `aria-describedby`, * so assistive technology announces them when the control gets focused. diff --git a/packages/components/form-field/password-toggle.ts b/packages/components/form-field/password-toggle.ts index ec3de93e03..2eea358037 100644 --- a/packages/components/form-field/password-toggle.ts +++ b/packages/components/form-field/password-toggle.ts @@ -1,5 +1,6 @@ import { FocusMonitor } from '@angular/cdk/a11y'; +import { F8 } from '@angular/cdk/keycodes'; import { AfterContentInit, afterNextRender, @@ -17,31 +18,40 @@ import { ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { - KBQ_FORM_FIELD_REF, - kbqInjectA11yLocaleConfiguration, - kbqInjectNativeElement, - PopUpTriggers -} from '@koobiq/components/core'; +import { kbqInjectA11yLocaleConfiguration, kbqInjectNativeElement, PopUpTriggers } from '@koobiq/components/core'; import { KbqIconButton, KbqIconModule } from '@koobiq/components/icon'; import { KbqToolTipModule, KbqTooltipTrigger } from '@koobiq/components/tooltip'; -import { EMPTY } from 'rxjs'; -import { KbqFormField } from './form-field'; +import { EMPTY, fromEvent } from 'rxjs'; +import { KBQ_FORM_FIELD } from './form-field'; import { KbqFormFieldControl } from './form-field-control'; -// @TODO Temporary solution to resolve circular dependency (#DS-3893) -type KbqInputPassword = KbqFormFieldControl & { - elementType: string; +/** + * Narrow structural contract for `kbqInputPassword`, duck-typed here to avoid a circular + * dependency between `@koobiq/components/form-field` and `@koobiq/components/input`. + */ +type KbqPasswordToggleControl = KbqFormFieldControl & { + readonly controlType: 'input-password'; + elementType: 'text' | 'password'; toggleType: () => void; }; -// @TODO Temporary solution to resolve circular dependency (#DS-3893) -const isInputPassword = (control: KbqFormFieldControl): control is KbqInputPassword => { - return 'elementType' in control; +/** + * Checks whether the given control structurally matches `KbqPasswordToggleControl`. + */ +const isPasswordToggleControl = (control: unknown): control is KbqPasswordToggleControl => { + return ( + !!control && + typeof control === 'object' && + 'controlType' in control && + control.controlType === 'input-password' && + 'elementType' in control && + 'toggleType' in control && + typeof control.toggleType === 'function' + ); }; const getKbqPasswordToggleMissingControlError = (): Error => { - return Error('kbq-password-toggle should use with kbqInputPassword'); + return Error('You should use kbq-password-toggle with kbqInputPassword'); }; /** Component which changes password visibility. */ @@ -82,9 +92,7 @@ export class KbqPasswordToggle extends KbqTooltipTrigger implements AfterViewIni protected readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly a11yLocaleConfiguration = kbqInjectA11yLocaleConfiguration(); - - // @TODO fix types (#DS-2915) - private readonly formField = inject(KBQ_FORM_FIELD_REF, { optional: true }) as unknown as KbqFormField | undefined; + private readonly formField = inject(KBQ_FORM_FIELD, { optional: true }); readonly tabindex = input(0, { transform: numberAttribute }); @@ -116,10 +124,10 @@ export class KbqPasswordToggle extends KbqTooltipTrigger implements AfterViewIni protected hasError: boolean = false; /** Form field password control. */ - private get control(): KbqInputPassword { + private get control(): KbqPasswordToggleControl { const control = this.formField?.control(); - if (!control || !isInputPassword(control)) { + if (!isPasswordToggleControl(control)) { throw getKbqPasswordToggleMissingControlError(); } @@ -163,6 +171,14 @@ export class KbqPasswordToggle extends KbqTooltipTrigger implements AfterViewIni this.trigger = `${PopUpTriggers.Hover}`; + const keydownTarget = this.formField?.elementRef.nativeElement; + + if (keydownTarget) { + fromEvent(keydownTarget, 'keydown') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => this.onFormFieldKeyDown(event)); + } + // `stateChanges` is owned by the control and outlives the toggle, so the subscription has to be torn // down explicitly. Subscribing after render also keeps it off the server, matching KbqReactivePasswordHint. afterNextRender(() => { @@ -172,23 +188,14 @@ export class KbqPasswordToggle extends KbqTooltipTrigger implements AfterViewIni }); } - /** - * @docs-private - */ ngAfterContentInit(): void { this.updateState(); } - /** - * @docs-private - */ ngAfterViewInit(): void { this.focusMonitor.monitor(this.nativeElement, true); } - /** - * @docs-private - */ ngOnDestroy() { this.focusMonitor.stopMonitoring(this.nativeElement); } @@ -196,7 +203,9 @@ export class KbqPasswordToggle extends KbqTooltipTrigger implements AfterViewIni /** * @docs-private */ - toggle(event: Event) { + toggle(event: Event): void { + if (this.control.disabled) return; + this.hide(); this.control.toggleType(); @@ -206,8 +215,17 @@ export class KbqPasswordToggle extends KbqTooltipTrigger implements AfterViewIni event.preventDefault(); } + /** + * Toggles password visibility for the Alt+F8 shortcut. + */ + private onFormFieldKeyDown(event: KeyboardEvent): void { + if (event.altKey && event.keyCode === F8) { + this.toggle(event); + } + } + private updateState = () => { - this.hasError = !!this.formField?.control()?.errorState; + this.hasError = !!this.control.errorState; this.changeDetectorRef.markForCheck(); }; diff --git a/packages/components/form-field/reactive-password-hint.ts b/packages/components/form-field/reactive-password-hint.ts index 0194131f59..c26664a298 100644 --- a/packages/components/form-field/reactive-password-hint.ts +++ b/packages/components/form-field/reactive-password-hint.ts @@ -12,11 +12,11 @@ import { ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { KBQ_FORM_FIELD_REF, KbqComponentColors } from '@koobiq/components/core'; +import { KbqComponentColors } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { EMPTY } from 'rxjs'; import { delay } from 'rxjs/operators'; -import { KbqFormField } from './form-field'; +import { KBQ_FORM_FIELD } from './form-field'; import { KbqHint } from './hint'; /** Password hint to be shown below the password form field control. */ @@ -42,8 +42,7 @@ import { KbqHint } from './hint'; exportAs: 'kbqReactivePasswordHint' }) export class KbqReactivePasswordHint extends KbqHint { - // @TODO fix types (#DS-2915) - private readonly formField = inject(KBQ_FORM_FIELD_REF, { optional: true }); + private readonly formField = inject(KBQ_FORM_FIELD, { optional: true }); private readonly destroyRef = inject(DestroyRef); private readonly changeDetectorRef = inject(ChangeDetectorRef); diff --git a/packages/components/form-field/stepper.ts b/packages/components/form-field/stepper.ts index 76273ab47e..2948c4d4d7 100644 --- a/packages/components/form-field/stepper.ts +++ b/packages/components/form-field/stepper.ts @@ -10,33 +10,42 @@ import { ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { KBQ_FORM_FIELD_REF } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { concatMap, fromEvent, interval, Subject, timer } from 'rxjs'; import { take, takeUntil } from 'rxjs/operators'; -import { KbqFormFieldControl } from './form-field-control'; +import { KBQ_FORM_FIELD } from './form-field'; +import type { KbqFormFieldControl } from './form-field-control'; /** - * The subset of `KbqNumberInput` the stepper drives. - * - * Declared here instead of imported from `@koobiq/components/input` to break the circular dependency - * between the packages (#DS-3893). + * Narrow structural contract for the control hosted by `kbqNumberInput`, duck-typed here to avoid + * a circular dependency between `@koobiq/components/form-field` and `@koobiq/components/input`. */ -export interface KbqNumberInputControl extends KbqFormFieldControl { - /** Increases the value by `step`. */ +type KbqStepperControl = KbqFormFieldControl & { + readonly controlType: 'input-number'; + readonly step: number; stepUp: (step: number) => void; /** Decreases the value by `step`. */ stepDown: (step: number) => void; - /** Amount the value changes by on a single step. */ - step: number; -} +}; -const isNumberInput = (control: KbqFormFieldControl): control is KbqNumberInputControl => { - return 'stepUp' in control && 'stepDown' in control; +/** + * Checks whether the given value structurally matches `KbqStepperControl`. + */ +const isStepperControl = (control: unknown): control is KbqStepperControl => { + return ( + !!control && + typeof control === 'object' && + 'controlType' in control && + control.controlType === 'input-number' && + 'stepUp' in control && + typeof control.stepUp === 'function' && + 'stepDown' in control && + typeof control.stepDown === 'function' + ); }; const getKbqStepperToggleMissingControlError = (): Error => { - return Error('kbq-stepper should use with kbqNumberInput'); + return Error('You should use kbq-stepper with kbqNumberInput'); }; /** @@ -86,8 +95,8 @@ export const KBQ_STEPPER_INTERVAL_DELAY = 75; exportAs: 'kbqStepper' }) export class KbqStepper { - private readonly formField = inject(KBQ_FORM_FIELD_REF, { optional: true }); - private readonly document = inject(DOCUMENT); + private readonly formField = inject(KBQ_FORM_FIELD, { optional: true }); + private readonly document = inject(DOCUMENT); private readonly destroyRef = inject(DestroyRef); /** Emitted when the stepper is incremented. */ @@ -115,23 +124,24 @@ export class KbqStepper { takeUntil(this.mouseUp) ); - /** Whether `connectTo` has already wired the number input. */ - private connected = false; - /** * Form field number control. Resolved once per control change instead of on every template read. * * @docs-private */ - protected readonly control = computed(() => { + protected readonly control = computed(() => { const control = this.formField?.control(); - const input = (control as { numberInput?: KbqFormFieldControl })?.numberInput; + // `formFieldControl` here is `KbqInput` (it owns the `KbqFormFieldControl` provider on + // ``), not the number-input itself: `stepUp`/`stepDown`/`step` + // live on the sibling `KbqNumberInput` directive, reachable only via `KbqInput.numberInput`. + const inputNumberControl = + control?.controlType === 'input-number' && 'numberInput' in control ? control.numberInput : null; - if (!input || !isNumberInput(input)) { + if (!isStepperControl(inputNumberControl)) { throw getKbqStepperToggleMissingControlError(); } - return input; + return inputNumberControl; }); constructor() { @@ -139,42 +149,41 @@ export class KbqStepper { } /** + * @deprecated No longer required — `KbqStepper` resolves its control automatically via DI. * @docs-private */ - connectTo(numberInput: KbqNumberInputControl): void { - // The form field calls it on every content init, and the outputs must not be wired twice. - if (!numberInput || this.connected) return; - - this.connected = true; - - this.stepUp.subscribe(() => { - numberInput.stepUp(numberInput.step); - }); - - this.stepDown.subscribe(() => { - numberInput.stepDown(numberInput.step); - }); - } + connectTo(_: any): void {} /** @docs-private */ - onStepUp($event: MouseEvent): void { - this.handleStep($event, this.stepUp); + onStepUp(event: MouseEvent): void { + this.handleStep(event, (control) => control.stepUp(control.step), this.stepUp); } /** @docs-private */ - onStepDown($event: MouseEvent): void { - this.handleStep($event, this.stepDown); + onStepDown(event: MouseEvent): void { + this.handleStep(event, (control) => control.stepDown(control.step), this.stepDown); } - private handleStep($event: MouseEvent, emitter: OutputEmitterRef): void { - if (this.control().disabled) return; + private handleStep( + event: MouseEvent, + step: (control: KbqStepperControl) => void, + emitter: OutputEmitterRef + ): void { + const control = this.control(); + + if (control.disabled) return; + + const performStep = () => { + step(control); + emitter.emit(); + }; - emitter.emit(); + performStep(); // handle case when cursor is out of viewport. fromEvent(this.document, 'mouseup') .pipe(take(1), takeUntilDestroyed(this.destroyRef)) .subscribe(() => this.mouseUp.next()); - this.longPress.subscribe(() => emitter.emit()); - $event.preventDefault(); + this.longPress.subscribe(performStep); + event.preventDefault(); } } diff --git a/packages/components/icon/icon-error-state-context.ts b/packages/components/icon/icon-error-state-context.ts new file mode 100644 index 0000000000..889f3f36b0 --- /dev/null +++ b/packages/components/icon/icon-error-state-context.ts @@ -0,0 +1,29 @@ +import { InjectionToken, Provider } from '@angular/core'; +import type { Observable } from 'rxjs'; + +/** + * Narrow contract that lets `KbqIcon` react to a host's error state (e.g. `autoColor`) without + * depending on the host's concrete class. + * @docs-private + */ +export interface KbqIconErrorStateContext { + /** Whether the host is currently in an error state. */ + readonly errorState: boolean; + /** Emits whenever the host's state changes, prompting `KbqIcon` to re-check `errorState`. */ + readonly stateChanges: Observable; +} + +/** + * Injection token used by `KbqIcon` to look up its `KbqIconErrorStateContext`. + * @docs-private + */ +export const KBQ_ICON_ERROR_STATE_CONTEXT = new InjectionToken('KbqIconErrorStateContext'); + +/** + * Utility provider for `KBQ_ICON_ERROR_STATE_CONTEXT`, built from a factory that resolves the current host's `KbqIconErrorStateContext`. + * @docs-private + */ +export const kbqIconErrorStateContextFactoryProvider = (factory: () => KbqIconErrorStateContext): Provider => ({ + provide: KBQ_ICON_ERROR_STATE_CONTEXT, + useFactory: factory +}); diff --git a/packages/components/icon/icon.component.ts b/packages/components/icon/icon.component.ts index 0d799c5365..72c2401e60 100644 --- a/packages/components/icon/icon.component.ts +++ b/packages/components/icon/icon.component.ts @@ -13,9 +13,10 @@ import { ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { KBQ_FORM_FIELD_REF, KbqColorDirective } from '@koobiq/components/core'; +import { KbqColorDirective } from '@koobiq/components/core'; import { EMPTY, ReplaySubject } from 'rxjs'; import { switchMap } from 'rxjs/operators'; +import { KBQ_ICON_ERROR_STATE_CONTEXT } from './icon-error-state-context'; import { KbqIconRegistry } from './icon-registry'; @Component({ @@ -33,7 +34,11 @@ import { KbqIconRegistry } from './icon-registry'; export class KbqIcon extends KbqColorDirective implements AfterContentInit, OnChanges { readonly elementRef = inject>(ElementRef); - protected readonly formField = inject(KBQ_FORM_FIELD_REF, { optional: true }); + /** + * Host providing error state for `autoColor`, when the icon sits inside one (e.g. a form field). + * @docs-private + */ + protected readonly errorStateContext = inject(KBQ_ICON_ERROR_STATE_CONTEXT, { optional: true }); protected readonly changeDetectorRef = inject(ChangeDetectorRef); protected readonly registry = inject(KbqIconRegistry, { optional: true }); protected readonly destroyRef = inject(DestroyRef); @@ -98,7 +103,7 @@ export class KbqIcon extends KbqColorDirective implements AfterContentInit, OnCh ngAfterContentInit(): void { if (this.autoColor) { - this.formField?.control()?.stateChanges.subscribe(this.updateState); + this.errorStateContext?.stateChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(this.updateState); this.updateState(); } @@ -149,7 +154,7 @@ export class KbqIcon extends KbqColorDirective implements AfterContentInit, OnCh } private updateState = () => { - this.hasError = !!this.formField?.control()?.errorState; + this.hasError = !!this.errorStateContext?.errorState; this.changeDetectorRef.markForCheck(); }; diff --git a/packages/components/icon/public-api.ts b/packages/components/icon/public-api.ts index 9ed421379b..a4aafc0654 100644 --- a/packages/components/icon/public-api.ts +++ b/packages/components/icon/public-api.ts @@ -1,4 +1,5 @@ export * from './icon-button.component'; +export * from './icon-error-state-context'; export * from './icon-item.component'; export * from './icon-registry'; export * from './icon-registry-providers'; diff --git a/packages/components/inline-edit/inline-edit.spec.ts b/packages/components/inline-edit/inline-edit.spec.ts index 2b8e1cb743..6a85bc2523 100644 --- a/packages/components/inline-edit/inline-edit.spec.ts +++ b/packages/components/inline-edit/inline-edit.spec.ts @@ -1218,7 +1218,7 @@ export class TestWithFullTabOrder {
- + @for (option of options; track option) { {{ option }} } diff --git a/packages/components/input/__screenshots__/04-light.png b/packages/components/input/__screenshots__/04-light.png index 1db569eb02..8346ece5e1 100644 Binary files a/packages/components/input/__screenshots__/04-light.png and b/packages/components/input/__screenshots__/04-light.png differ diff --git a/packages/components/input/e2e.playwright-spec.ts b/packages/components/input/e2e.playwright-spec.ts index ba2739c521..4ca27f6935 100644 --- a/packages/components/input/e2e.playwright-spec.ts +++ b/packages/components/input/e2e.playwright-spec.ts @@ -47,6 +47,8 @@ test.describe('KbqInputModule', () => { await page.goto('/E2eInputStateAndStyle'); const locator = getComponent(page); const screenshotTarget = getInputPasswordTestRow(locator); + + await screenshotTarget.locator('input').focus(); const toggle = screenshotTarget.locator('kbq-password-toggle'); await toggle.click(); diff --git a/packages/components/input/input-number.spec.ts b/packages/components/input/input-number.spec.ts index 3ed2606a08..804ba919c1 100644 --- a/packages/components/input/input-number.spec.ts +++ b/packages/components/input/input-number.spec.ts @@ -104,6 +104,22 @@ class TestNumberInputConditional { stepDown = jest.fn().mockImplementation(() => false); } +@Component({ + imports: [FormsModule, KbqInputModule], + template: ` + + + @if (showStepper) { + + } + + ` +}) +class KbqNumberInputWithDynamicStepper { + value: number | null = 10; + showStepper = false; +} + @Component({ imports: [ ReactiveFormsModule, @@ -277,6 +293,22 @@ class KbqNumberInputWithInteger { } describe('KbqNumberInput', () => { + it('should use input-number control type', fakeAsync(() => { + const fixture = createComponent(KbqNumberInputTestComponent); + + fixture.detectChanges(); + flush(); + + const inputElement = fixture.debugElement.query(By.directive(KbqInput)); + const input = inputElement.injector.get(KbqInput); + const numberInput = inputElement.injector.get(KbqNumberInput); + const formField = fixture.debugElement.query(By.css('kbq-form-field')).nativeElement; + + expect(input.controlType).toBe('input-number'); + expect(numberInput.controlType).toBe('input-number'); + expect(formField.classList).toContain('kbq-form-field-type-input-number'); + })); + it('should have stepper on focus', fakeAsync(() => { const fixture = createComponent(KbqNumberInputTestComponent); @@ -309,9 +341,9 @@ describe('KbqNumberInput', () => { expect(icons.every((icon) => icon.nativeElement.classList.contains('kbq-error'))).toBe(true); })); - it('should throw error with stepper', () => { - // KbqFormField.ngAfterContentInit() throws when it detects a kbq-cleaner inside - // a number input. Override ComponentFixtureAutoDetect so CD doesn't run inside + it('should throw error with cleaner', () => { + // KbqCleaner.ngAfterContentInit() throws when it detects a number input. + // Override ComponentFixtureAutoDetect so CD doesn't run inside // TestBed.createComponent — that way the throw originates from our explicit // fixture.detectChanges() call, where expect-to-throw can capture it. jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -369,6 +401,24 @@ describe('KbqNumberInput', () => { expect(fixture.componentInstance.value).toEqual(initialValue); })); + it('should connect a stepper added after form-field initialization', fakeAsync(() => { + const fixture = createComponent(KbqNumberInputWithDynamicStepper); + + fixture.detectChanges(); + flush(); + fixture.componentInstance.showStepper = true; + fixture.detectChanges(); + + const stepper = fixture.debugElement.query(By.css('kbq-stepper')); + const iconUp = stepper.queryAll(By.css('.kbq-icon'))[0]; + + dispatchFakeEvent(iconUp.nativeElement, 'mousedown'); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.value).toBe(11); + })); + describe('with long press on stepper', () => { const initialValue = 0; diff --git a/packages/components/input/input-number.ts b/packages/components/input/input-number.ts index cd5d09d3a3..0093177078 100644 --- a/packages/components/input/input-number.ts +++ b/packages/components/input/input-number.ts @@ -122,7 +122,11 @@ export class KbqNumberInput implements KbqFormFieldControl, ControlValueAcc errorState: boolean; - controlType?: string | undefined; + /** + * Implemented as part of KbqFormFieldControl. + * @docs-private + */ + controlType: string = 'input-number'; /** * Allows input and pasting of integers only. diff --git a/packages/components/input/input-password.spec.ts b/packages/components/input/input-password.spec.ts index 634cee0255..41a23016fc 100644 --- a/packages/components/input/input-password.spec.ts +++ b/packages/components/input/input-password.spec.ts @@ -1,4 +1,5 @@ -import { Component, Provider, Type, viewChild } from '@angular/core'; +import { F8 } from '@angular/cdk/keycodes'; +import { Component, Provider, Type, viewChild } from '@angular/core'; import { ComponentFixture, ComponentFixtureAutoDetect, TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { By } from '@angular/platform-browser'; @@ -142,7 +143,39 @@ class PasswordInputWithReactiveControl { }); } +@Component({ + imports: [KbqInputModule, FormsModule], + template: ` + + + @if (showToggle) { + + } + + ` +}) +class PasswordInputWithDynamicToggle { + value = 'password'; + showToggle = false; +} + describe('KbqPasswordInput', () => { + it('should handle Alt+F8 only when KbqPasswordToggle is present', () => { + const fixture = createComponent(PasswordInputWithDynamicToggle); + const input = fixture.debugElement.query(By.directive(KbqInputPassword)).nativeElement as HTMLInputElement; + const togglePassword = () => + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'F8', keyCode: F8, altKey: true, bubbles: true })); + + fixture.detectChanges(); + togglePassword(); + expect(input.type).toBe('password'); + + fixture.componentInstance.showToggle = true; + fixture.detectChanges(); + togglePassword(); + expect(input.type).toBe('text'); + }); + it('should have toggle', fakeAsync(() => { const fixture = createComponent(KbqPasswordInputDefault); diff --git a/packages/components/input/input-password.ts b/packages/components/input/input-password.ts index 13295d8e92..f1ccf62da3 100644 --- a/packages/components/input/input-password.ts +++ b/packages/components/input/input-password.ts @@ -230,8 +230,10 @@ export class KbqInputPassword this.checkRule.next(); } - toggleType() { + /** Toggles the native input type between password and text. */ + toggleType(): void { this.elementType = this.elementType === 'password' ? 'text' : 'password'; + this.stateChanges.next(null); } /** Focuses the input. */ diff --git a/packages/components/input/input.spec.ts b/packages/components/input/input.spec.ts index b299e8533f..48f57f249e 100644 --- a/packages/components/input/input.spec.ts +++ b/packages/components/input/input.spec.ts @@ -299,7 +299,7 @@ describe('KbqInput', () => { expect(formFieldElement.querySelectorAll('.kbq-form-field__cleaner').length).toBe(1); - const cleanerElement = fixture.debugElement.query(By.css('.kbq-form-field__cleaner')).nativeElement; + const cleanerElement = fixture.debugElement.query(By.css('.kbq-cleaner')).nativeElement; cleanerElement.click(); fixture.detectChanges(); diff --git a/packages/components/input/input.ts b/packages/components/input/input.ts index 2e0981cce6..38c6e206f1 100644 --- a/packages/components/input/input.ts +++ b/packages/components/input/input.ts @@ -80,7 +80,7 @@ export class KbqInput * Implemented as part of KbqFormFieldControl. * @docs-private */ - controlType: string = 'input'; + controlType: string = this.numberInput ? 'input-number' : 'input'; /** * Implemented as part of KbqFormFieldControl. diff --git a/packages/components/select/select.component.spec.ts b/packages/components/select/select.component.spec.ts index 4b3637d833..1720831489 100644 --- a/packages/components/select/select.component.spec.ts +++ b/packages/components/select/select.component.spec.ts @@ -63,7 +63,7 @@ import { kbqErrorStateMatcherProvider, wrappedErrorMessage } from '@koobiq/components/core'; -import { KbqFormField, KbqFormFieldModule } from '@koobiq/components/form-field'; +import { KbqCleaner, KbqFormField, KbqFormFieldModule } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; import { KbqTagsModule } from '@koobiq/components/tags'; @@ -305,7 +305,7 @@ const OPTIONS = [ - +
@@ -2035,7 +2035,7 @@ class VirtualSelectWithScrolledToBottom { @for (food of foods; track food) { {{ food.viewValue }} } - + ` @@ -2050,6 +2050,22 @@ class MultiSelectWithCleaner { readonly select = viewChild.required(KbqSelect); } +@Component({ + selector: 'select-with-cleaner-without-template-ref', + imports: [KbqSelectModule, ReactiveFormsModule], + template: ` + + + Pizza + + + + ` +}) +class SelectWithCleanerWithoutTemplateRef { + readonly control = new UntypedFormControl('pizza'); +} + describe('KbqSelect', () => { let overlayContainer: OverlayContainer; let overlayContainerElement: HTMLElement; @@ -3422,7 +3438,7 @@ describe('KbqSelect', () => { expect(value.nativeElement.textContent).toContain('Pizza'); - cleaner = fixture.debugElement.query(By.css('.kbq-select__cleaner')).nativeElement; + cleaner = fixture.debugElement.query(By.directive(KbqCleaner)).nativeElement; cleaner.click(); fixture.detectChanges(); flush(); @@ -3437,13 +3453,33 @@ describe('KbqSelect', () => { expect(fixture.componentInstance.control.value).toBe('pizza-1'); - cleaner = fixture.debugElement.query(By.css('.kbq-select__cleaner')).nativeElement; + cleaner = fixture.debugElement.query(By.directive(KbqCleaner)).nativeElement; cleaner.click(); fixture.detectChanges(); flush(); expect(fixture.componentInstance.control.value).toBeUndefined(); })); + + it('should discover cleaner by component type without a template reference', fakeAsync(() => { + const cleanerFixture = TestBed.createComponent(SelectWithCleanerWithoutTemplateRef); + + cleanerFixture.detectChanges(); + flush(); + cleanerFixture.componentInstance.control.setValue('pizza'); + cleanerFixture.detectChanges(); + flush(); + + const cleaner = cleanerFixture.debugElement.query(By.directive(KbqCleaner)); + + expect(cleanerFixture.debugElement.query(By.directive(KbqCleaner))).not.toBeNull(); + expect(cleaner).not.toBeNull(); + cleaner.nativeElement.click(); + cleanerFixture.detectChanges(); + flush(); + + expect(cleanerFixture.componentInstance.control.value).toBeUndefined(); + })); }); describe('Clear value — multiple select', () => { @@ -3459,7 +3495,7 @@ describe('KbqSelect', () => { it('should update FormControl value to empty array after clear', fakeAsync(() => { expect(multiFixture.componentInstance.control.value).toEqual(['steak-0', 'pizza-1']); - const multiCleaner = multiFixture.debugElement.query(By.css('.kbq-select__cleaner')).nativeElement; + const multiCleaner = multiFixture.debugElement.query(By.directive(KbqCleaner)).nativeElement; multiCleaner.click(); multiFixture.detectChanges(); @@ -3483,7 +3519,7 @@ describe('KbqSelect', () => { multiFixture.detectChanges(); flush(); - multiFixture.debugElement.query(By.css('.kbq-select__cleaner')).nativeElement.click(); + multiFixture.debugElement.query(By.directive(KbqCleaner)).nativeElement.click(); multiFixture.detectChanges(); flush(); diff --git a/packages/components/select/select.component.ts b/packages/components/select/select.component.ts index ec3ff0f389..986c72bbfc 100644 --- a/packages/components/select/select.component.ts +++ b/packages/components/select/select.component.ts @@ -101,7 +101,12 @@ import { shouldSelectSearchText, toggleSelectAll } from '@koobiq/components/core'; -import { KbqCleaner, KbqFormField, KbqFormFieldControl } from '@koobiq/components/form-field'; +import { + KBQ_FORM_FIELD, + KbqCleaner, + KbqFormFieldControl, + kbqCleanerFactoryProvider +} from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqTag } from '@koobiq/components/tags'; import { SizeXxs as SelectSizeMultipleContentGap } from '@koobiq/design-tokens'; @@ -198,6 +203,20 @@ export const minimumTimeToDisplayLoading = 300; styleUrls: ['./select.scss', './select-tokens.scss'], providers: [ { provide: KbqFormFieldControl, useExisting: KbqSelect }, + kbqCleanerFactoryProvider(() => { + const select = inject(KbqSelect); + + return { + get control() { + return select; + }, + get keydownTarget() { + return select.elementRef.nativeElement; + }, + clearByEscape: false, + clear: () => select.clear() + }; + }), { provide: KBQ_OPTION_PARENT_COMPONENT, useExisting: KbqSelect }, { provide: KBQ_PARENT_POPUP, useExisting: KbqSelect }, kbqSiblingPopupProvider(KbqSelect) @@ -247,7 +266,7 @@ export class KbqSelect private readonly _dir = inject(Directionality, { optional: true }); parentForm = inject(NgForm, { optional: true }); parentFormGroup = inject(FormGroupDirective, { optional: true }); - private readonly parentFormField = inject(KbqFormField, { host: true, optional: true })!; + private readonly parentFormField = inject(KBQ_FORM_FIELD, { host: true, optional: true })!; ngControl = inject(NgControl, { self: true, optional: true }); private readonly scrollStrategyFactory = inject(KBQ_SELECT_SCROLL_STRATEGY); protected localeService? = inject(KBQ_LOCALE_SERVICE, { optional: true }); @@ -372,8 +391,11 @@ export class KbqSelect /** Custom template reference for rendering tag content. */ readonly customTagTemplateRef = contentChild('kbqSelectTagContent', { read: TemplateRef }); - /** Reference to the optional cleaner element for clearing selection. */ - readonly cleaner = contentChild('kbqSelectCleaner'); + /** + * Reference to the optional cleaner element for clearing selection. + * @docs-private + */ + readonly cleaner = contentChild(KbqCleaner, { descendants: false }); /** All of the defined select options. */ @ContentChildren(KbqOption, { descendants: true }) options: QueryList; @@ -840,9 +862,12 @@ export class KbqSelect return !!search && this.options?.filter((option) => option.selectable()).length === 0 && !!search.value(); } - /** Whether the cleaner (clear button) should be shown. */ + /** + * Whether the cleaner (clear button) should be shown. + * @docs-private + */ get canShowCleaner(): boolean { - return !this.disabled && !!this.cleaner() && this.selectionModel.hasValue(); + return !!this.cleaner()?.canShow; } /** Returns the currently selected option(s). Single value or array for multiple selection. */ @@ -1065,20 +1090,31 @@ export class KbqSelect /** * Clears the current selection. - * @param $event Mouse event to prevent default behavior. + * @docs-private */ - clearValue($event): void { - // need to prevent opening - $event.stopPropagation(); - // need to prevent scrolling - $event.preventDefault(); - + clear(): void { this.selectionModel.clear(); this.keyManager.setActiveItem(-1); this.propagateChanges(); + } - this.focus(); + /** + * Clears the current selection. + * @deprecated Activate the projected `KbqCleaner` instead. + * @docs-private + */ + clearValue(event: Event): void { + const cleaner = this.cleaner(); + + if (cleaner) { + cleaner.clear(event); + } else { + event.stopPropagation(); + event.preventDefault(); + this.clear(); + this.focus(); + } } /** `View -> model callback called when value changes` */ diff --git a/packages/components/select/select.html b/packages/components/select/select.html index cbee46cd01..df15b2339a 100644 --- a/packages/components/select/select.html +++ b/packages/components/select/select.html @@ -71,12 +71,7 @@ } @if (canShowCleaner) { -
+
} diff --git a/packages/components/tags/e2e.ts b/packages/components/tags/e2e.ts index 1db6587378..577457ffd5 100644 --- a/packages/components/tags/e2e.ts +++ b/packages/components/tags/e2e.ts @@ -204,7 +204,7 @@ export class E2eTagListStates {} [kbqTagInputFor]="tagList" /> - + Label @@ -222,7 +222,7 @@ export class E2eTagListStates {} - + @@ -239,7 +239,7 @@ export class E2eTagListStates {} - + @@ -256,7 +256,7 @@ export class E2eTagListStates {} - + `, @@ -297,7 +297,7 @@ export class E2eTagInputStates {} [kbqAutocomplete]="autocomplete" /> - + Label @@ -327,7 +327,7 @@ export class E2eTagInputStates {} [kbqAutocomplete]="autocomplete2" /> - + diff --git a/packages/components/tags/tag-list.component.ts b/packages/components/tags/tag-list.component.ts index bf8e21b6ff..c948cded78 100644 --- a/packages/components/tags/tag-list.component.ts +++ b/packages/components/tags/tag-list.component.ts @@ -27,7 +27,7 @@ import { import { outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ControlValueAccessor, FormGroupDirective, NgControl, NgForm, UntypedFormControl } from '@angular/forms'; import { CanUpdateErrorState, ErrorStateMatcher, FocusKeyManager, isNull, isSelectAll } from '@koobiq/components/core'; -import { KbqCleaner, KbqFormFieldControl } from '@koobiq/components/form-field'; +import { KBQ_CLEANER_CONTEXT, KbqCleaner, KbqFormFieldControl } from '@koobiq/components/form-field'; import { merge, Observable, Subject } from 'rxjs'; import { filter, startWith, takeUntil } from 'rxjs/operators'; import { KbqTagTextControl } from './tag-text-control'; @@ -69,7 +69,11 @@ export type KbqTagListDroppedEvent = Pick, 'event' | 'previ } `, styleUrls: ['tag-list.scss', 'tag-tokens.scss'], - providers: [{ provide: KbqFormFieldControl, useExisting: KbqTagList }], + providers: [ + { provide: KbqFormFieldControl, useExisting: KbqTagList }, + // Tag-list cleaners use consumer-provided click handlers; do not also reset the outer form control. + { provide: KBQ_CLEANER_CONTEXT, useValue: null } + ], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { @@ -387,7 +391,7 @@ export class KbqTagList readonly change = output(); /** @docs-private */ - readonly cleaner = contentChild('kbqTagListCleaner'); + readonly cleaner = contentChild(KbqCleaner, { descendants: false }); /** * The tag components contained within this tag list. diff --git a/packages/components/timezone/e2e.ts b/packages/components/timezone/e2e.ts index 10d1351c19..da41297100 100644 --- a/packages/components/timezone/e2e.ts +++ b/packages/components/timezone/e2e.ts @@ -65,7 +65,7 @@ class BaseTimezoneStates { } - + @@ -195,7 +195,7 @@ export class E2eTimezonePanelStates extends BaseTimezoneStates {} [placeholder]="'Город или часовой пояс'" [(ngModel)]="control" /> - +
Ничего не найдено
diff --git a/packages/components/timezone/timezone-select.component.html b/packages/components/timezone/timezone-select.component.html index 6d81d0575d..90d387d128 100644 --- a/packages/components/timezone/timezone-select.component.html +++ b/packages/components/timezone/timezone-select.component.html @@ -25,7 +25,7 @@ } @if (canShowCleaner) { -
+
} diff --git a/packages/components/timezone/timezone-select.component.ts b/packages/components/timezone/timezone-select.component.ts index b9fd3ef00e..b4e070704f 100644 --- a/packages/components/timezone/timezone-select.component.ts +++ b/packages/components/timezone/timezone-select.component.ts @@ -4,13 +4,14 @@ import { AfterContentInit, ChangeDetectionStrategy, Component, + contentChild, Directive, - ViewEncapsulation, - contentChild + inject, + ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KBQ_OPTION_PARENT_COMPONENT, kbqSiblingPopupProvider, ruRULocaleData } from '@koobiq/components/core'; -import { KbqFormFieldControl } from '@koobiq/components/form-field'; +import { kbqCleanerFactoryProvider, KbqFormFieldControl } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqSelect } from '@koobiq/components/select'; @@ -38,6 +39,20 @@ const defaultSearchPlaceholder = ruRULocaleData.timezone.searchPlaceholder; ], providers: [ { provide: KbqFormFieldControl, useExisting: KbqTimezoneSelect }, + kbqCleanerFactoryProvider(() => { + const timezoneSelect = inject(KbqTimezoneSelect); + + return { + get control() { + return timezoneSelect; + }, + get keydownTarget() { + return timezoneSelect.elementRef.nativeElement; + }, + clearByEscape: false, + clear: () => timezoneSelect.clear() + }; + }), { provide: KBQ_OPTION_PARENT_COMPONENT, useExisting: KbqTimezoneSelect }, // Declared again rather than inherited from `KbqSelect`: Angular copies `providers` to a subclass only // when that subclass has no decorator of its own. diff --git a/packages/components/tree-select/e2e.ts b/packages/components/tree-select/e2e.ts index 02c5c176e7..e0419d6557 100644 --- a/packages/components/tree-select/e2e.ts +++ b/packages/components/tree-select/e2e.ts @@ -177,7 +177,7 @@ class BaseTreeSelectStates {
Nothing found
- + @@ -241,7 +241,7 @@ export class E2eTreeSelectStates extends BaseTreeSelectStates {}
Nothing found
- + @@ -302,7 +302,7 @@ export class E2eMultiTreeSelectStates extends BaseTreeSelectStates {}
Nothing found
- + diff --git a/packages/components/tree-select/tree-select.component.spec.ts b/packages/components/tree-select/tree-select.component.spec.ts index 969b1344ff..3a53aeafe5 100644 --- a/packages/components/tree-select/tree-select.component.spec.ts +++ b/packages/components/tree-select/tree-select.component.spec.ts @@ -62,7 +62,7 @@ import { kbqErrorStateMatcherProvider, wrappedErrorMessage } from '@koobiq/components/core'; -import { KbqFormFieldModule } from '@koobiq/components/form-field'; +import { KbqCleaner, KbqFormFieldModule } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; import { KbqSelectModule } from '@koobiq/components/select'; @@ -326,7 +326,7 @@ const getChildren = (node: FileNode): Observable => { - +
@@ -2865,7 +2865,7 @@ describe('KbqTreeSelect', () => { expect(value.nativeElement.textContent).toContain('rootNode_1'); - cleaner = fixture.debugElement.query(By.css('.kbq-select__cleaner')).nativeElement; + cleaner = fixture.debugElement.query(By.directive(KbqCleaner)).nativeElement; cleaner.click(); fixture.detectChanges(); tick(); diff --git a/packages/components/tree-select/tree-select.component.ts b/packages/components/tree-select/tree-select.component.ts index 7235cea36c..b8ae8e9ea8 100644 --- a/packages/components/tree-select/tree-select.component.ts +++ b/packages/components/tree-select/tree-select.component.ts @@ -87,7 +87,12 @@ import { kbqSiblingPopupProvider, shouldSelectSearchText } from '@koobiq/components/core'; -import { KbqCleaner, KbqFormField, KbqFormFieldControl } from '@koobiq/components/form-field'; +import { + KBQ_FORM_FIELD, + KbqCleaner, + KbqFormFieldControl, + kbqCleanerFactoryProvider +} from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqTag, KbqTagRemove } from '@koobiq/components/tags'; import { KbqTree, KbqTreeOption, KbqTreeSelection } from '@koobiq/components/tree'; @@ -177,6 +182,20 @@ export class KbqTreeSelectChange { styleUrls: ['./tree-select.scss', './tree-select-tokens.scss', '../select/select-tokens.scss'], providers: [ { provide: KbqFormFieldControl, useExisting: KbqTreeSelect }, + kbqCleanerFactoryProvider(() => { + const treeSelect = inject(KbqTreeSelect); + + return { + get control() { + return treeSelect; + }, + get keydownTarget() { + return treeSelect.elementRef.nativeElement; + }, + clearByEscape: false, + clear: () => treeSelect.clear() + }; + }), { provide: KbqTree, useExisting: KbqTreeSelect }, { provide: KBQ_PARENT_POPUP, useExisting: KbqTreeSelect }, kbqSiblingPopupProvider(KbqTreeSelect) @@ -224,7 +243,7 @@ export class KbqTreeSelect private readonly dir = inject(Directionality, { optional: true }); parentForm = inject(NgForm, { optional: true }); parentFormGroup = inject(FormGroupDirective, { optional: true }); - private readonly parentFormField = inject(KbqFormField, { host: true, optional: true })!; + private readonly parentFormField = inject(KBQ_FORM_FIELD, { host: true, optional: true })!; ngControl = inject(NgControl, { optional: true, self: true }); private localeService = inject(KBQ_LOCALE_SERVICE, { optional: true }); protected readonly isBrowser = inject(Platform).isBrowser; @@ -316,7 +335,11 @@ export class KbqTreeSelect @ViewChildren(KbqTag) tags: QueryList; - readonly cleaner = contentChild('kbqSelectCleaner'); + /** + * Reference to the optional cleaner element for clearing selection. + * @docs-private + */ + readonly cleaner = contentChild(KbqCleaner, { descendants: false }); /** User-supplied override of the trigger element. */ readonly customTrigger = contentChild(KbqSelectTrigger); @@ -709,8 +732,12 @@ export class KbqTreeSelect return this._panelOpen; } + /** + * Whether the cleaner (clear button) should be shown. + * @docs-private + */ get canShowCleaner(): boolean { - return !this.disabled && !!this.cleaner() && this.selectionModel.hasValue(); + return !!this.cleaner()?.canShow; } /** @docs-private */ @@ -920,22 +947,34 @@ export class KbqTreeSelect return hiddenItemsText.replace('{{ number }}', hiddenItems.toString()); } - clearValue($event): void { - // need to prevent opening - $event.stopPropagation(); - // need to prevent scrolling - $event.preventDefault(); - + /** + * Clears the current selection. + * @docs-private + */ + clear(): void { this.selectionModel.clear(); this.tree()!.keyManager.setActiveItem(-1); - this.setSelectionByValue([]); - this.onChange(this.selectedValues); - this.selectionChange.emit(new KbqTreeSelectChange(this, this.selectedValues)); + } - this.focus(); + /** + * Clears the current selection. + * @deprecated Activate the projected `KbqCleaner` instead. + * @docs-private + */ + clearValue(event: Event): void { + const cleaner = this.cleaner(); + + if (cleaner) { + cleaner.clear(event); + } else { + event.stopPropagation(); + event.preventDefault(); + this.clear(); + this.focus(); + } } /** `View -> model callback called when value changes` */ diff --git a/packages/components/tree-select/tree-select.html b/packages/components/tree-select/tree-select.html index 291aecc077..ad725a2491 100644 --- a/packages/components/tree-select/tree-select.html +++ b/packages/components/tree-select/tree-select.html @@ -70,12 +70,7 @@ } @if (canShowCleaner) { -
+
} diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index 6fa82e00e6..545cc03d45 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -39,7 +39,6 @@ import { isCopy, isSelectAll, isVerticalMovement, - KBQ_FORM_FIELD_REF, LEFT_ARROW, MultipleMode, PAGE_DOWN, @@ -50,6 +49,7 @@ import { toggleSelectAll, UP_ARROW } from '@koobiq/components/core'; +import { KBQ_FORM_FIELD } from '@koobiq/components/form-field'; import { merge, Observable, Subscription } from 'rxjs'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; import { delay } from 'rxjs/operators'; @@ -148,7 +148,7 @@ export class KbqTreeSelection * by the host `KbqTreeSelect` so hover works even when the tree-select has no form-field * wrapper (e.g. the filter-bar pipes render the tree-select bare). */ - inSelect = !!inject(KBQ_FORM_FIELD_REF, { optional: true, host: true }); + inSelect = !!inject(KBQ_FORM_FIELD, { optional: true, host: true }); renderedOptions = new QueryList(); diff --git a/packages/docs-examples/components/ag-grid/ag-grid-external-filter-state/ag-grid-external-filter-state-example.ts b/packages/docs-examples/components/ag-grid/ag-grid-external-filter-state/ag-grid-external-filter-state-example.ts index 72ab9534f8..1b6637742e 100644 --- a/packages/docs-examples/components/ag-grid/ag-grid-external-filter-state/ag-grid-external-filter-state-example.ts +++ b/packages/docs-examples/components/ag-grid/ag-grid-external-filter-state/ag-grid-external-filter-state-example.ts @@ -21,7 +21,7 @@ ModuleRegistry.registerModules([AllCommunityModule]); template: ` - + @for (option of options; track option) { {{ option }} } diff --git a/packages/docs-examples/components/form-field/form-field-with-cleaner/form-field-with-cleaner-example.ts b/packages/docs-examples/components/form-field/form-field-with-cleaner/form-field-with-cleaner-example.ts index 27f5b92766..2e62d0b89c 100644 --- a/packages/docs-examples/components/form-field/form-field-with-cleaner/form-field-with-cleaner-example.ts +++ b/packages/docs-examples/components/form-field/form-field-with-cleaner/form-field-with-cleaner-example.ts @@ -15,7 +15,7 @@ import { KbqSelectModule } from '@koobiq/components/select'; - + Option #1 Option #2 diff --git a/packages/docs-examples/components/inline-edit/inline-edit-controls/inline-edit-controls-example.ts b/packages/docs-examples/components/inline-edit/inline-edit-controls/inline-edit-controls-example.ts index 840caecd80..1a60f51887 100644 --- a/packages/docs-examples/components/inline-edit/inline-edit-controls/inline-edit-controls-example.ts +++ b/packages/docs-examples/components/inline-edit/inline-edit-controls/inline-edit-controls-example.ts @@ -93,7 +93,7 @@ import { KbqTextareaModule } from '@koobiq/components/textarea'; @for (option of options; track option) { {{ option }} } - + diff --git a/packages/docs-examples/components/inline-edit/inline-edit-overview/inline-edit-overview-example.ts b/packages/docs-examples/components/inline-edit/inline-edit-overview/inline-edit-overview-example.ts index 5c2c107cdc..6587985e8a 100644 --- a/packages/docs-examples/components/inline-edit/inline-edit-overview/inline-edit-overview-example.ts +++ b/packages/docs-examples/components/inline-edit/inline-edit-overview/inline-edit-overview-example.ts @@ -35,7 +35,7 @@ import { KbqSelectModule } from '@koobiq/components/select'; @for (option of options; track option) { {{ option }} } - + diff --git a/packages/docs-examples/components/select/select-cleaner/select-cleaner-example.ts b/packages/docs-examples/components/select/select-cleaner/select-cleaner-example.ts index abc5216cdc..45a0b727dd 100644 --- a/packages/docs-examples/components/select/select-cleaner/select-cleaner-example.ts +++ b/packages/docs-examples/components/select/select-cleaner/select-cleaner-example.ts @@ -10,7 +10,7 @@ import { KbqSelectModule } from '@koobiq/components/select'; template: ` - + @for (option of options; track option) { {{ option }} } diff --git a/packages/docs-examples/components/select/select-multiple/select-multiple-example.ts b/packages/docs-examples/components/select/select-multiple/select-multiple-example.ts index fc8f17451a..d8f0d0d333 100644 --- a/packages/docs-examples/components/select/select-multiple/select-multiple-example.ts +++ b/packages/docs-examples/components/select/select-multiple/select-multiple-example.ts @@ -14,7 +14,7 @@ import { KbqSelectModule } from '@koobiq/components/select'; {{ option }} } - + `, diff --git a/packages/docs-examples/components/select/select-preselected-values/select-preselected-values-example.ts b/packages/docs-examples/components/select/select-preselected-values/select-preselected-values-example.ts index 0df7062309..e2df8f4fb1 100644 --- a/packages/docs-examples/components/select/select-preselected-values/select-preselected-values-example.ts +++ b/packages/docs-examples/components/select/select-preselected-values/select-preselected-values-example.ts @@ -15,7 +15,7 @@ import { KbqSelectModule } from '@koobiq/components/select'; {{ option }} } - + `, diff --git a/packages/docs-examples/components/select/select-prioritized-selected/select-prioritized-selected-example.ts b/packages/docs-examples/components/select/select-prioritized-selected/select-prioritized-selected-example.ts index f7923141b3..39a9931d70 100644 --- a/packages/docs-examples/components/select/select-prioritized-selected/select-prioritized-selected-example.ts +++ b/packages/docs-examples/components/select/select-prioritized-selected/select-prioritized-selected-example.ts @@ -14,7 +14,7 @@ import { KbqSelectModule } from '@koobiq/components/select'; {{ option }} } - + `, diff --git a/packages/docs-examples/components/select/select-with-multiline-matcher/select-with-multiline-matcher-example.ts b/packages/docs-examples/components/select/select-with-multiline-matcher/select-with-multiline-matcher-example.ts index 7df07619c7..0ba5231d1b 100644 --- a/packages/docs-examples/components/select/select-with-multiline-matcher/select-with-multiline-matcher-example.ts +++ b/packages/docs-examples/components/select/select-with-multiline-matcher/select-with-multiline-matcher-example.ts @@ -16,7 +16,7 @@ import { KbqSelectModule } from '@koobiq/components/select'; @for (option of options; track option) { {{ option }} } - +
diff --git a/packages/docs-examples/components/tags/tag-autocomplete-draggable/tag-autocomplete-draggable-example.ts b/packages/docs-examples/components/tags/tag-autocomplete-draggable/tag-autocomplete-draggable-example.ts index 5286809310..e39231b4af 100644 --- a/packages/docs-examples/components/tags/tag-autocomplete-draggable/tag-autocomplete-draggable-example.ts +++ b/packages/docs-examples/components/tags/tag-autocomplete-draggable/tag-autocomplete-draggable-example.ts @@ -62,7 +62,7 @@ const getAutocompleteOptions = () => [ (kbqTagInputTokenEnd)="create($event)" /> - + diff --git a/packages/docs-examples/components/tags/tag-autocomplete-editable/tag-autocomplete-editable-example.ts b/packages/docs-examples/components/tags/tag-autocomplete-editable/tag-autocomplete-editable-example.ts index 7cd63f7991..68c3d621f5 100644 --- a/packages/docs-examples/components/tags/tag-autocomplete-editable/tag-autocomplete-editable-example.ts +++ b/packages/docs-examples/components/tags/tag-autocomplete-editable/tag-autocomplete-editable-example.ts @@ -68,7 +68,7 @@ const getAutocompleteOptions = () => [ (kbqTagInputTokenEnd)="create($event)" /> - + diff --git a/packages/docs-examples/components/tags/tag-autocomplete-onpaste-off/tag-autocomplete-onpaste-off-example.html b/packages/docs-examples/components/tags/tag-autocomplete-onpaste-off/tag-autocomplete-onpaste-off-example.html index 9cf53e7993..e94c1747d8 100644 --- a/packages/docs-examples/components/tags/tag-autocomplete-onpaste-off/tag-autocomplete-onpaste-off-example.html +++ b/packages/docs-examples/components/tags/tag-autocomplete-onpaste-off/tag-autocomplete-onpaste-off-example.html @@ -23,7 +23,7 @@ (kbqTagInputTokenEnd)="onCreate($event)" /> - + @if (canCreate) { diff --git a/packages/docs-examples/components/tags/tag-autocomplete-option-operations/tag-autocomplete-option-operations-example.ts b/packages/docs-examples/components/tags/tag-autocomplete-option-operations/tag-autocomplete-option-operations-example.ts index 8063d9dd4b..344a741ed3 100644 --- a/packages/docs-examples/components/tags/tag-autocomplete-option-operations/tag-autocomplete-option-operations-example.ts +++ b/packages/docs-examples/components/tags/tag-autocomplete-option-operations/tag-autocomplete-option-operations-example.ts @@ -62,7 +62,7 @@ const getAutocompleteOptions = () => [ (kbqTagInputTokenEnd)="onCreate($event)" /> - + @if (canCreate) { diff --git a/packages/docs-examples/components/tags/tag-autocomplete-overview/tag-autocomplete-overview-example.ts b/packages/docs-examples/components/tags/tag-autocomplete-overview/tag-autocomplete-overview-example.ts index 8265adaf92..48d948a855 100644 --- a/packages/docs-examples/components/tags/tag-autocomplete-overview/tag-autocomplete-overview-example.ts +++ b/packages/docs-examples/components/tags/tag-autocomplete-overview/tag-autocomplete-overview-example.ts @@ -62,7 +62,7 @@ const getAutocompleteOptions = () => [ (kbqTagInputTokenEnd)="create($event)" /> - + diff --git a/packages/docs-examples/components/tags/tag-autocomplete-removable/tag-autocomplete-removable-example.ts b/packages/docs-examples/components/tags/tag-autocomplete-removable/tag-autocomplete-removable-example.ts index 7fb05ec13e..b9e2517667 100644 --- a/packages/docs-examples/components/tags/tag-autocomplete-removable/tag-autocomplete-removable-example.ts +++ b/packages/docs-examples/components/tags/tag-autocomplete-removable/tag-autocomplete-removable-example.ts @@ -55,7 +55,7 @@ const getAutocompleteOptions = () => [ (kbqTagInputTokenEnd)="create($event)" /> - + diff --git a/packages/docs-examples/components/tags/tag-input-draggable/tag-input-draggable-example.ts b/packages/docs-examples/components/tags/tag-input-draggable/tag-input-draggable-example.ts index ebd3ae8658..44250a73db 100644 --- a/packages/docs-examples/components/tags/tag-input-draggable/tag-input-draggable-example.ts +++ b/packages/docs-examples/components/tags/tag-input-draggable/tag-input-draggable-example.ts @@ -38,7 +38,7 @@ const getTags = () => Array.from({ length: 3 }, (_, id) => ({ id, value: `Dragga (kbqTagInputTokenEnd)="create($event)" /> - + `, diff --git a/packages/docs-examples/components/tags/tag-input-editable/tag-input-editable-example.ts b/packages/docs-examples/components/tags/tag-input-editable/tag-input-editable-example.ts index 12fdaec489..da757726c5 100644 --- a/packages/docs-examples/components/tags/tag-input-editable/tag-input-editable-example.ts +++ b/packages/docs-examples/components/tags/tag-input-editable/tag-input-editable-example.ts @@ -45,7 +45,7 @@ const getTags = () => Array.from({ length: 3 }, (_, i) => ({ value: `Editable ta (kbqTagInputTokenEnd)="create($event)" /> - + `, diff --git a/packages/docs-examples/components/tags/tag-input-onpaste-off/tag-input-onpaste-off-example.html b/packages/docs-examples/components/tags/tag-input-onpaste-off/tag-input-onpaste-off-example.html index 0324752f4a..c81fd3c57b 100644 --- a/packages/docs-examples/components/tags/tag-input-onpaste-off/tag-input-onpaste-off-example.html +++ b/packages/docs-examples/components/tags/tag-input-onpaste-off/tag-input-onpaste-off-example.html @@ -17,6 +17,6 @@ [kbqTagInputSeparatorKeyCodes]="separatorKeysCodes" (kbqTagInputTokenEnd)="onCreate($event)" /> - + diff --git a/packages/docs-examples/components/tags/tag-input-overview/tag-input-overview-example.ts b/packages/docs-examples/components/tags/tag-input-overview/tag-input-overview-example.ts index 22467ab628..f2f029023b 100644 --- a/packages/docs-examples/components/tags/tag-input-overview/tag-input-overview-example.ts +++ b/packages/docs-examples/components/tags/tag-input-overview/tag-input-overview-example.ts @@ -39,7 +39,7 @@ const getTags = () => Array.from({ length: 3 }, (_, i) => ({ value: `Tag ${i}` } (kbqTagInputTokenEnd)="create($event)" /> - + `, diff --git a/packages/docs-examples/components/tags/tag-input-removable/tag-input-removable-example.ts b/packages/docs-examples/components/tags/tag-input-removable/tag-input-removable-example.ts index e5396e4647..5091548276 100644 --- a/packages/docs-examples/components/tags/tag-input-removable/tag-input-removable-example.ts +++ b/packages/docs-examples/components/tags/tag-input-removable/tag-input-removable-example.ts @@ -32,7 +32,7 @@ const getTags = () => Array.from({ length: 3 }, (_, i) => ({ value: `Removable t (kbqTagInputTokenEnd)="create($event)" /> - + `, diff --git a/packages/docs-examples/components/tree-select/tree-select-child-selection-overview/tree-select-child-selection-overview-example.ts b/packages/docs-examples/components/tree-select/tree-select-child-selection-overview/tree-select-child-selection-overview-example.ts index 7fcf660648..36cafc5bb3 100644 --- a/packages/docs-examples/components/tree-select/tree-select-child-selection-overview/tree-select-child-selection-overview-example.ts +++ b/packages/docs-examples/components/tree-select/tree-select-child-selection-overview/tree-select-child-selection-overview-example.ts @@ -131,7 +131,7 @@ export const DATA_OBJECT = {
- + `, diff --git a/packages/docs-examples/components/tree-select/tree-select-footer-overview/tree-select-footer-overview-example.ts b/packages/docs-examples/components/tree-select/tree-select-footer-overview/tree-select-footer-overview-example.ts index 591dc14677..97fbfa42c0 100644 --- a/packages/docs-examples/components/tree-select/tree-select-footer-overview/tree-select-footer-overview-example.ts +++ b/packages/docs-examples/components/tree-select/tree-select-footer-overview/tree-select-footer-overview-example.ts @@ -121,7 +121,7 @@ export const DATA_OBJECT = {
- + extends KbqTreeFlatDataSource { - + `, diff --git a/packages/docs-examples/components/tree-select/tree-select-multiple-overview/tree-select-multiple-overview-example.ts b/packages/docs-examples/components/tree-select/tree-select-multiple-overview/tree-select-multiple-overview-example.ts index 570922c891..78ecf38ccf 100644 --- a/packages/docs-examples/components/tree-select/tree-select-multiple-overview/tree-select-multiple-overview-example.ts +++ b/packages/docs-examples/components/tree-select/tree-select-multiple-overview/tree-select-multiple-overview-example.ts @@ -130,7 +130,7 @@ export const DATA_OBJECT = { - + `, diff --git a/packages/docs-examples/components/tree-select/tree-select-overview/tree-select-overview-example.ts b/packages/docs-examples/components/tree-select/tree-select-overview/tree-select-overview-example.ts index 82c7344c14..0c209c9948 100644 --- a/packages/docs-examples/components/tree-select/tree-select-overview/tree-select-overview-example.ts +++ b/packages/docs-examples/components/tree-select/tree-select-overview/tree-select-overview-example.ts @@ -120,7 +120,7 @@ export const DATA_OBJECT = { - + `, diff --git a/packages/docs-examples/components/tree-select/tree-select-search-overview/tree-select-search-overview-example.ts b/packages/docs-examples/components/tree-select/tree-select-search-overview/tree-select-search-overview-example.ts index 163409f3a3..cd49f6a423 100644 --- a/packages/docs-examples/components/tree-select/tree-select-search-overview/tree-select-search-overview-example.ts +++ b/packages/docs-examples/components/tree-select/tree-select-search-overview/tree-select-search-overview-example.ts @@ -146,7 +146,7 @@ export const DATA_OBJECT = { - + `, diff --git a/packages/docs-examples/components/tree-select/tree-select-with-multiline-matcher-overview/tree-select-with-multiline-matcher-example.ts b/packages/docs-examples/components/tree-select/tree-select-with-multiline-matcher-overview/tree-select-with-multiline-matcher-example.ts index 5b0a613bba..32c9144a13 100644 --- a/packages/docs-examples/components/tree-select/tree-select-with-multiline-matcher-overview/tree-select-with-multiline-matcher-example.ts +++ b/packages/docs-examples/components/tree-select/tree-select-with-multiline-matcher-overview/tree-select-with-multiline-matcher-example.ts @@ -120,7 +120,7 @@ export const DATA_OBJECT = { - + `, diff --git a/packages/e2e/routes.ts b/packages/e2e/routes.ts index 36320faae6..69fea1e671 100644 --- a/packages/e2e/routes.ts +++ b/packages/e2e/routes.ts @@ -41,7 +41,7 @@ import { E2eFilterBarStates } from '../components/filter-bar/e2e'; import { E2eFlagStyles } from '../components/flag/e2e'; -import { E2eFormFieldGroup, E2eFormFieldset } from '../components/form-field/e2e'; +import { E2eFormFieldAddons, E2eFormFieldGroup, E2eFormFieldset } from '../components/form-field/e2e'; import { E2eIconStateAndStyle, E2eIconSvg } from '../components/icon/e2e'; import { E2eInlineEditActionButtons, @@ -145,6 +145,7 @@ const components = [ E2eSplitterGhost, E2eFileUploadStateAndStyle, E2eFileUploadDropzone, + E2eFormFieldAddons, E2eFormFieldGroup, E2eFormFieldset, E2eActionsPanelWithOverlayContainer, diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 4bf59f8a4e..bec0b7e218 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -2356,7 +2356,7 @@ export const KBQ_DEFAULT_LOCALE_ID = "ru-RU"; // @public export const KBQ_DEFAULT_PRECISION_SEPARATOR = "."; -// @public +// @public @deprecated export const KBQ_FORM_FIELD_REF: InjectionToken; // @public (undocumented) @@ -2869,7 +2869,7 @@ export interface KbqFormFieldControlRef { value: T | null; } -// @public +// @public @deprecated export interface KbqFormFieldRef { // (undocumented) canCleanerClearByEsc: boolean; diff --git a/tools/public_api_guard/components/datepicker.api.md b/tools/public_api_guard/components/datepicker.api.md index 9ff6d469b5..96709b0e76 100644 --- a/tools/public_api_guard/components/datepicker.api.md +++ b/tools/public_api_guard/components/datepicker.api.md @@ -16,7 +16,6 @@ import { ElementRef } from '@angular/core'; import { ErrorStateMatcher } from '@koobiq/components/core'; import { EventEmitter } from '@angular/core'; import * as i1 from '@koobiq/components/button'; -import * as i15 from '@koobiq/components/form-field'; import * as i2 from '@angular/cdk/overlay'; import * as i3 from '@angular/cdk/a11y'; import * as i4 from '@angular/cdk/portal'; @@ -24,11 +23,11 @@ import * as i5 from '@koobiq/components/select'; import * as i6 from '@koobiq/components/icon'; import * as i8 from '@angular/common'; import { InjectionToken } from '@angular/core'; -import { KbqFormField } from '@koobiq/components/form-field'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; import { KbqSiblingPopup } from '@koobiq/components/core'; import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; import * as _koobiq_components_core from '@koobiq/components/core'; +import * as _koobiq_components_form_field from '@koobiq/components/form-field'; import { Observable } from 'rxjs'; import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; @@ -311,7 +310,7 @@ export class KbqDatepickerInput implements KbqFormFieldControl, ControlVal focusChanged(isFocused: boolean): void; // (undocumented) focused: boolean; - protected readonly formField: KbqFormField | null; + protected readonly formField: _koobiq_components_form_field.KbqFormField | null; getOrigin(): ElementRef; // (undocumented) get id(): string; @@ -421,7 +420,7 @@ export class KbqDatepickerModule { // (undocumented) static ɵinj: _angular_core.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: _angular_core.ɵɵNgModuleDeclaration; + static ɵmod: _angular_core.ɵɵNgModuleDeclaration; } // @public diff --git a/tools/public_api_guard/components/form-field.api.md b/tools/public_api_guard/components/form-field.api.md index 0062d2abaf..6094eaf5eb 100644 --- a/tools/public_api_guard/components/form-field.api.md +++ b/tools/public_api_guard/components/form-field.api.md @@ -36,6 +36,12 @@ export function getKbqFormFieldYouCanNotUseCleanerInNumberInputError(): Error; // @public @deprecated export const hasPasswordStrengthError: (passwordHints: QueryList | readonly KbqPasswordHint[]) => boolean; +// @public +export const KBQ_CLEANER_CONTEXT: InjectionToken; + +// @public +export const KBQ_FORM_FIELD: InjectionToken; + // @public export const KBQ_FORM_FIELD_DEFAULT_OPTIONS: InjectionToken; readonly ariaLabel: i0.InputSignal; + get canShow(): boolean; + clear(event: Event): void; + // (undocumented) + ngAfterContentInit(): void; // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export interface KbqCleanerContext { + clear?(): void; + // (undocumented) + readonly clearByEscape: boolean; + // (undocumented) + readonly control: KbqFormFieldControl; + // (undocumented) + readonly keydownTarget: HTMLElement; +} + +// @public +export const kbqCleanerFactoryProvider: (factory: () => KbqCleanerContext) => Provider; + // @public export class KbqError extends KbqHint { constructor(); @@ -90,6 +114,7 @@ export class KbqFieldsetItem { // @public export class KbqFormField extends KbqColorDirective implements AfterContentInit, AfterViewInit, OnDestroy, AfterContentChecked { + // @deprecated canCleanerClearByEsc: boolean; get canShowCleaner(): boolean; readonly cleaner: Signal; @@ -130,7 +155,8 @@ export class KbqFormField extends KbqColorDirective implements AfterContentInit, readonly noBorders: i0.InputSignalWithTransform; onContainerClick(event: MouseEvent): void; onHoverChanged(isHovered: boolean): void; - onKeyDown(event: KeyboardEvent): void; + // @deprecated + onKeyDown(_event: KeyboardEvent): void; readonly passwordHints: Signal; readonly passwordToggle: Signal; readonly prefix: Signal; @@ -215,13 +241,6 @@ export class KbqLegend { static ɵfac: i0.ɵɵFactoryDeclaration; } -// @public -export interface KbqNumberInputControl extends KbqFormFieldControl { - step: number; - stepDown: (step: number) => void; - stepUp: (step: number) => void; -} - // @public @deprecated export class KbqPasswordHint extends KbqHint implements AfterContentInit { constructor(); @@ -263,8 +282,11 @@ export class KbqPasswordToggle extends KbqTooltipTrigger implements AfterViewIni readonly kbqTooltipHidden: i0.InputSignal>; // (undocumented) protected readonly nativeElement: HTMLElement; + // (undocumented) ngAfterContentInit(): void; + // (undocumented) ngAfterViewInit(): void; + // (undocumented) ngOnDestroy(): void; // (undocumented) readonly tabindex: i0.InputSignalWithTransform; @@ -302,11 +324,13 @@ export function kbqSetDescribedByIds(element: HTMLElement, ids: string[]): void; // @public export class KbqStepper { constructor(); - connectTo(numberInput: KbqNumberInputControl): void; - protected readonly control: i0.Signal; + // @deprecated (undocumented) + connectTo(_: any): void; + // Warning: (ae-forgotten-export) The symbol "KbqStepperControl" needs to be exported by the entry point index.d.ts + protected readonly control: i0.Signal; protected readonly mouseUp: Subject; - onStepDown($event: MouseEvent): void; - onStepUp($event: MouseEvent): void; + onStepDown(event: MouseEvent): void; + onStepUp(event: MouseEvent): void; readonly stepDown: OutputEmitterRef; readonly stepUp: OutputEmitterRef; // (undocumented) diff --git a/tools/public_api_guard/components/icon.api.md b/tools/public_api_guard/components/icon.api.md index 4c7ae1a780..7d8a862669 100644 --- a/tools/public_api_guard/components/icon.api.md +++ b/tools/public_api_guard/components/icon.api.md @@ -13,7 +13,6 @@ import { FocusMonitor } from '@angular/cdk/a11y'; import * as i0 from '@angular/core'; import { InjectionToken } from '@angular/core'; import { KbqColorDirective } from '@koobiq/components/core'; -import * as _koobiq_components_core from '@koobiq/components/core'; import { Observable } from 'rxjs'; import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; @@ -23,6 +22,9 @@ import { SafeHtml } from '@angular/platform-browser'; import { SafeResourceUrl } from '@angular/platform-browser'; import { SimpleChanges } from '@angular/core'; +// @public +export const KBQ_ICON_ERROR_STATE_CONTEXT: InjectionToken; + // @public export const KBQ_ICON_RESOLVER: InjectionToken; @@ -39,8 +41,7 @@ export class KbqIcon extends KbqColorDirective implements AfterContentInit, OnCh protected readonly destroyRef: DestroyRef; // (undocumented) readonly elementRef: ElementRef; - // (undocumented) - protected readonly formField: _koobiq_components_core.KbqFormFieldRef | null; + protected readonly errorStateContext: KbqIconErrorStateContext | null; // (undocumented) getHostElement(): HTMLElement; // (undocumented) @@ -100,6 +101,15 @@ export class KbqIconButton extends KbqIcon implements AfterViewInit, OnDestroy { // @public export type KbqIconButtonSize = 'compact' | 'normal'; +// @public +export interface KbqIconErrorStateContext { + readonly errorState: boolean; + readonly stateChanges: Observable; +} + +// @public +export const kbqIconErrorStateContextFactoryProvider: (factory: () => KbqIconErrorStateContext) => Provider; + // @public (undocumented) export class KbqIconItem extends KbqIcon { // (undocumented) diff --git a/tools/public_api_guard/components/input.api.md b/tools/public_api_guard/components/input.api.md index 3ead8c47b9..a267605958 100644 --- a/tools/public_api_guard/components/input.api.md +++ b/tools/public_api_guard/components/input.api.md @@ -192,7 +192,6 @@ export class KbqInputPassword implements KbqFormFieldControl, OnChanges, On get required(): boolean; set required(value: boolean); readonly stateChanges: Subject; - // (undocumented) toggleType(): void; // (undocumented) protected uid: string; @@ -211,8 +210,7 @@ export class KbqNumberInput implements KbqFormFieldControl, ControlValueAcc constructor(); // (undocumented) bigStep: number; - // (undocumented) - controlType?: string | undefined; + controlType: string; // (undocumented) get disabled(): boolean; set disabled(value: boolean); diff --git a/tools/public_api_guard/components/select.api.md b/tools/public_api_guard/components/select.api.md index 9be1beeb7a..180f2d95e5 100644 --- a/tools/public_api_guard/components/select.api.md +++ b/tools/public_api_guard/components/select.api.md @@ -106,7 +106,9 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On get canShowCleaner(): boolean; readonly cdkVirtualForOf: _angular_core.Signal | undefined>; readonly cleaner: _angular_core.Signal; - clearValue($event: any): void; + clear(): void; + // @deprecated + clearValue(event: Event): void; close(): void; readonly closedStream: Observable; get colorForState(): KbqComponentColors; diff --git a/tools/public_api_guard/components/tree-select.api.md b/tools/public_api_guard/components/tree-select.api.md index bef89764b7..9bf9b57e14 100644 --- a/tools/public_api_guard/components/tree-select.api.md +++ b/tools/public_api_guard/components/tree-select.api.md @@ -74,14 +74,13 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterContentInit readonly backdropClass: _angular_core.InputSignal; // (undocumented) calculateHiddenItems: () => void; - // (undocumented) get canShowCleaner(): boolean; // (undocumented) readonly changeDetectorRef: ChangeDetectorRef; - // (undocumented) readonly cleaner: _angular_core.Signal; - // (undocumented) - clearValue($event: any): void; + clear(): void; + // @deprecated + clearValue(event: Event): void; close(): void; readonly closedStream: Observable; get colorForState(): KbqComponentColors;