Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ yarn install
Setup git hooks:

```bash
yarn husky
npx husky
```

## Package Structure
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions packages/components-dev/form-field/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@

<kbq-form-field>
<kbq-select [multiple]="true" [placeholder]="placeholder" [(value)]="emptyMultipleValue">
<kbq-cleaner #kbqSelectCleaner />
<kbq-cleaner />

@for (option of options; track option) {
<kbq-option [value]="option">
Expand All @@ -55,7 +55,7 @@

<kbq-form-field>
<kbq-select [multiple]="true" [(value)]="multipleSelected">
<kbq-cleaner #kbqSelectCleaner />
<kbq-cleaner />

@for (option of options; track option) {
<kbq-option [showCheckbox]="false" [value]="option">
Expand Down
12 changes: 6 additions & 6 deletions packages/components-dev/select/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<!-- </kbq-form-field>-->
<!-- <kbq-form-field>-->
<!-- <kbq-select (openedChange)="openedChange($event)">-->
<!-- <kbq-cleaner #kbqSelectCleaner />-->
<!-- <kbq-cleaner />-->

<!-- <cdk-virtual-scroll-viewport [itemSize]="32" [maxBufferPx]="400" [minBufferPx]="100">-->
<!-- <kbq-option *cdkVirtualFor="let option of options; templateCacheSize: 0" [value]="option">-->
Expand Down Expand Up @@ -53,7 +53,7 @@
<!-- <kbq-cleaner />-->
<!-- </kbq-form-field>-->

<!-- <kbq-cleaner #kbqSelectCleaner />-->
<!-- <kbq-cleaner />-->

<!-- <div kbq-select-search-empty-result>Ничего не найдено</div>-->

Expand Down Expand Up @@ -82,7 +82,7 @@
<!-- <kbq-cleaner />-->
<!-- </kbq-form-field>-->

<!-- <kbq-cleaner #kbqSelectCleaner />-->
<!-- <kbq-cleaner />-->

<!-- <div kbq-select-search-empty-result>Ничего не найдено</div>-->
<!-- </kbq-select>-->
Expand Down Expand Up @@ -134,7 +134,7 @@
<!-- <kbq-cleaner />-->
<!-- </kbq-form-field>-->

<!-- <kbq-cleaner #kbqSelectCleaner />-->
<!-- <kbq-cleaner />-->

<!-- <div kbq-select-search-empty-result>Ничего не найдено</div>-->

Expand All @@ -160,7 +160,7 @@
<!-- <kbq-cleaner />-->
<!-- </kbq-form-field>-->

<!-- <kbq-cleaner #kbqSelectCleaner />-->
<!-- <kbq-cleaner />-->

<!-- <div kbq-select-search-empty-result>Ничего не найдено</div>-->

Expand Down Expand Up @@ -409,7 +409,7 @@
<!--<div class="dev-container">-->
<!-- <kbq-form-field>-->
<!-- <kbq-select [multiple]="true" [(value)]="selected">-->
<!-- <kbq-cleaner #kbqSelectCleaner />-->
<!-- <kbq-cleaner />-->

<!-- <cdk-virtual-scroll-viewport [itemSize]="32" [maxBufferPx]="400" [minBufferPx]="100">-->
<!-- <kbq-option *cdkVirtualFor="let option of options; templateCacheSize: 0" [value]="option">-->
Expand Down
6 changes: 3 additions & 3 deletions packages/components-dev/tag/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ <h4 class="kbq-title">Tags with input</h4>
[kbqTagInputSeparatorKeyCodes]="separatorKeysCodes"
(kbqTagInputTokenEnd)="inputOnCreate($event)"
/>
<kbq-cleaner #kbqTagListCleaner (click)="inputTags.length = 0" />
<kbq-cleaner (click)="inputTags.length = 0" />
</kbq-tag-list>
</kbq-form-field>

Expand All @@ -105,7 +105,7 @@ <h4 class="kbq-title">Tags with autocomplete</h4>
(kbqTagInputTokenEnd)="autocompleteOnCreate($event)"
/>

<kbq-cleaner #kbqTagListCleaner (click)="autocompleteSelectedTags.length = 0" />
<kbq-cleaner (click)="autocompleteSelectedTags.length = 0" />
</kbq-tag-list>
<kbq-autocomplete #autocomplete (optionSelected)="autocompleteOnSelect($event)">
@if (canCreate) {
Expand Down Expand Up @@ -145,7 +145,7 @@ <h4 class="kbq-title">Tags with tag adding on blur disabled</h4>
(kbqTagInputTokenEnd)="enterOnCreate($event)"
/>

<kbq-cleaner #kbqTagListCleaner (click)="enterTags.length = 0" />
<kbq-cleaner (click)="enterTags.length = 0" />
</kbq-tag-list>
</kbq-form-field>

Expand Down
2 changes: 1 addition & 1 deletion packages/components-dev/tree-select/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ <h2 class="kbq-subheading">Custom footer</h2>

<div kbq-select-search-empty-result>Ничего не найдено</div>

<kbq-cleaner #kbqSelectCleaner />
<kbq-cleaner />

<kbq-tree-selection [dataSource]="dataSource" [treeControl]="treeControl">
<kbq-tree-option *kbqTreeNodeDef="let node" kbqTreeNodePadding [checkboxThirdState]="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>(DOCUMENT);
Expand Down
4 changes: 2 additions & 2 deletions packages/components/autocomplete/autocomplete.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<HTMLElement>>(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++}`;
Expand Down
6 changes: 5 additions & 1 deletion packages/components/core/form-field/form-field-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export interface KbqFormFieldControlRef<T = unknown> {
* 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 {
Expand All @@ -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<KbqFormField>` (#DS-2915)
* @deprecated Use `KBQ_FORM_FIELD` from `@koobiq/components/form-field`.
*
* @docs-private
*/
export const KBQ_FORM_FIELD_REF = new InjectionToken<KbqFormFieldRef>('KbqFormFieldRef');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -251,7 +251,7 @@ export class KbqDatepickerInput<D>
readonly adapter = inject<DateAdapter<D>>(DateAdapter, { optional: true })!;
private readonly dateFormats = inject<KbqDateFormats>(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 */
Expand Down
4 changes: 2 additions & 2 deletions packages/components/filter-bar/pipes/pipe-input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 5 additions & 5 deletions packages/components/filter-bar/pipes/pipe-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ export class KbqPipeInputComponent extends KbqBasePipe<string | null> 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<string | null>('');

Expand Down Expand Up @@ -83,8 +83,8 @@ export class KbqPipeInputComponent extends KbqBasePipe<string | null> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

<div kbq-select-search-empty-result>{{ localeData.pipe.emptySearchResult }}</div>

<kbq-cleaner #kbqSelectCleaner />
<kbq-cleaner />

<button
kbq-button
Expand Down
1 change: 1 addition & 0 deletions packages/components/form-field/_form-field-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
}

&.kbq-form-field-type-input,
&.kbq-form-field-type-input-number,
&.kbq-form-field-type-textarea,
&.kbq-form-field-type-timepicker,
&.kbq-form-field-type-datepicker,
Expand Down
106 changes: 102 additions & 4 deletions packages/components/form-field/cleaner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,45 @@
import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core';
import { KbqComponentColors, kbqInjectA11yLocaleConfiguration } from '@koobiq/components/core';
import {
AfterContentInit,
ChangeDetectionStrategy,
Component,
computed,
inject,
InjectionToken,
input,
Provider,
ViewEncapsulation
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ESCAPE, KbqComponentColors, kbqInjectA11yLocaleConfiguration } from '@koobiq/components/core';
import { KbqIconButton } from '@koobiq/components/icon';
import { fromEvent } from 'rxjs';
import { KbqFormFieldControl } from './form-field-control';

Comment thread
artembelik marked this conversation as resolved.
/** @docs-private */
export interface KbqCleanerContext {
readonly control: KbqFormFieldControl<unknown>;
readonly keydownTarget: HTMLElement;
readonly clearByEscape: boolean;
/** Overrides the default `cleanerControl.ngControl?.reset()` behavior when the cleaner is activated. */
clear?(): void;
}

/** @docs-private */
export const KBQ_CLEANER_CONTEXT = new InjectionToken<KbqCleanerContext | null>('KbqCleanerContext');

/**
* Utility provider for `KBQ_CLEANER_CONTEXT`, built from a factory that resolves the current host's `KbqCleanerContext`.
* @docs-private
*/
export const kbqCleanerFactoryProvider = (factory: () => KbqCleanerContext): Provider => ({
provide: KBQ_CLEANER_CONTEXT,
useFactory: factory
});

/** @docs-private */
export function getKbqFormFieldYouCanNotUseCleanerInNumberInputError(): Error {
return Error(`You can't use kbq-cleaner with input that have type="number"`);
}

/**
* Element to be placed in end of the form field.
Expand All @@ -19,11 +58,26 @@ import { KbqIconButton } from '@koobiq/components/icon';
// The cleaner is an icon-only control: without a role and an accessible name it is announced as an
// unlabeled focusable graphic.
role: 'button',
'[attr.aria-label]': 'accessibleName()'
'[attr.aria-label]': 'accessibleName()',
'(click)': 'clear($event)',
'(keydown.enter)': 'clear($event)',
'(keydown.space)': 'clear($event)'
},
exportAs: 'kbqCleaner'
})
export class KbqCleaner extends KbqIconButton {
export class KbqCleaner extends KbqIconButton implements AfterContentInit {
private readonly context = inject(KBQ_CLEANER_CONTEXT, { optional: true });

/**
* Whether the cleaner can be displayed for the current control state.
* @docs-private
*/
get canShow(): boolean {
const control = this.context?.control;

return control ? !control.disabled && !control.empty : true;
}

private readonly a11yLocaleConfiguration = kbqInjectA11yLocaleConfiguration();

/** Accessible name of the cleaner. Defaults to the localized "Clear". */
Expand All @@ -39,4 +93,48 @@ export class KbqCleaner extends KbqIconButton {
this.color = KbqComponentColors.ContrastFade;
this.autoColor = true;
}

ngAfterContentInit(): void {
super.ngAfterContentInit();

if (this.context?.control?.controlType === 'input-number') {
throw getKbqFormFieldYouCanNotUseCleanerInNumberInputError();
}

const keydownTarget = this.context?.keydownTarget;

if (keydownTarget) {
fromEvent<KeyboardEvent>(keydownTarget, 'keydown')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event) => this.onKeyDown(event));
}
}

/**
* Clears the owning control in response to pointer or keyboard activation.
* @docs-private
*/
clear(event: Event): void {
if (!this.context || !this.canShow) return;

event.stopPropagation();
event.preventDefault();

if (this.context.clear) {
this.context.clear();
} else {
this.context.control.ngControl?.reset();
}

this.context.control.focus();
}

/**
* Clears the focused control when Escape handling is enabled.
*/
private onKeyDown(event: KeyboardEvent): void {
if (event.keyCode === ESCAPE && this.context?.clearByEscape && this.context.control.focused && this.canShow) {
this.clear(event);
}
}
}
Loading