Skip to content
Open
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
196 changes: 196 additions & 0 deletions packages/components/select/select.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1891,6 +1891,75 @@ class SelectWithShowPreselectedValuesMultiple {
control = new UntypedFormControl(['unknown-value-1', 'pizza', 'unknown-value-2']);
}

@Component({
selector: 'select-with-search-and-preselected-value',
imports: [KbqSelectModule, KbqInputModule, ReactiveFormsModule],
template: `
<kbq-form-field>
<kbq-select [showPreselectedValues]="true" [(value)]="value">
<kbq-form-field kbqSelectSearch>
<input kbqInput type="text" [formControl]="searchCtrl" />
</kbq-form-field>

@for (option of options; track option) {
<kbq-option [value]="option">{{ option }}</kbq-option>
}
</kbq-select>
</kbq-form-field>
`
})
class SelectWithSearchAndPreselectedValue {
readonly select = viewChild.required(KbqSelect);

searchCtrl = new UntypedFormControl('');
options = ['One', 'Two', 'Three'];
/** Deliberately absent from `options`, as a value from an already unloaded page would be. */
value = 'Unknown';
}

const ASYNC_OPTIONS_PAGE_SIZE = 10;

@Component({
selector: 'select-with-async-options',
imports: [KbqSelectModule],
template: `
<kbq-form-field>
<kbq-select
[compareWith]="compareWith"
[showPreselectedValues]="true"
[value]="value"
[virtualOptionFactory]="virtualOptionFactory"
>
@for (option of options; track option.id) {
<kbq-option [value]="option">{{ option.name }}</kbq-option>
}
</kbq-select>
</kbq-form-field>
`
})
class SelectWithAsyncOptions {
readonly select = viewChild.required(KbqSelect);

/** Starts empty: options only arrive once a page has been loaded. */
options: CityOption[] = [];
value: CityOption = { id: 0, name: 'Option #0' };

compareWith = (a: CityOption | null, b: CityOption | null) => a?.id === b?.id;
virtualOptionFactory = (value: CityOption) => new KbqVirtualOption(value, false, value.name);

/** Appends a page of options, as an infinite-paging consumer would. */
loadPage(page: number): void {
this.options = [
...this.options,
...Array.from({ length: ASYNC_OPTIONS_PAGE_SIZE }).map((_, index) => {
const id = page * ASYNC_OPTIONS_PAGE_SIZE + index;

return { id, name: `Option #${id}` };
})
];
}
}

@Component({
selector: 'multi-select-with-trigger-values-limit',
imports: [KbqSelectModule, ReactiveFormsModule, KbqIconModule, KbqTagsModule],
Expand Down Expand Up @@ -6586,6 +6655,133 @@ describe('KbqSelect', () => {
});
});

describe('with asynchronously loaded options', () => {
let fixture: ComponentFixture<SelectWithAsyncOptions>;
let testInstance: SelectWithAsyncOptions;

/** Opens the panel and loads the first page, as an infinite-paging consumer would. */
function openPanelWithFirstPage() {
testInstance.select().open();
fixture.detectChanges();
flush();

testInstance.loadPage(0);
fixture.detectChanges();
flush();
fixture.detectChanges();
}

beforeEach(fakeAsync(() => {
configureKbqSelectTestingModule([SelectWithAsyncOptions]);
fixture = TestBed.createComponent(SelectWithAsyncOptions);
testInstance = fixture.componentInstance;
fixture.detectChanges();
flush();
fixture.detectChanges();
}));

it('should render the trigger label before any option is rendered', fakeAsync(() => {
const triggerText = fixture.debugElement.query(By.css('.kbq-select__matcher-text')).nativeElement
.textContent;

expect(testInstance.select().selectionModel.selected[0]).toBeInstanceOf(KbqVirtualOption);
expect(triggerText.trim()).toBe('Option #0');
}));

it('should replace the preselected virtual option with the matching one once it is rendered', fakeAsync(() => {
openPanelWithFirstPage();

const selected = testInstance.select().selectionModel.selected[0];

expect(selected).not.toBeInstanceOf(KbqVirtualOption);
expect(selected.value.id).toBe(0);
}));

it('should keep the active item when options are appended while the panel is open', fakeAsync(() => {
openPanelWithFirstPage();

const activeOption = testInstance.select().options.toArray()[7];

testInstance.select().keyManager.setActiveItem(activeOption);
fixture.detectChanges();

testInstance.loadPage(1);
fixture.detectChanges();
flush();
fixture.detectChanges();

expect(testInstance.select().keyManager.activeItem === activeOption).toBe(true);
expect(testInstance.select().keyManager.activeItem!.value.id).toBe(7);
expect(testInstance.select().keyManager.activeItemIndex).toBe(7);
}));

it('should activate the selected option when the options list is replaced', fakeAsync(() => {
openPanelWithFirstPage();

testInstance.select().keyManager.setActiveItem(testInstance.select().options.toArray()[7]);
fixture.detectChanges();

// The active option is gone from the new list, so the highlight has to move to the selected one.
testInstance.options = [
{ id: 100, name: 'Option #100' },
{ id: 0, name: 'Option #0' }
];
fixture.detectChanges();
flush();
fixture.detectChanges();

expect(testInstance.select().keyManager.activeItem!.value.id).toBe(0);
expect(testInstance.select().keyManager.activeItemIndex).toBe(1);
}));
});

describe('with a search field on a closed panel', () => {
let fixture: ComponentFixture<SelectWithSearchAndPreselectedValue>;
let testInstance: SelectWithSearchAndPreselectedValue;

beforeEach(fakeAsync(() => {
configureKbqSelectTestingModule([SelectWithSearchAndPreselectedValue]);
fixture = TestBed.createComponent(SelectWithSearchAndPreselectedValue);
testInstance = fixture.componentInstance;
fixture.detectChanges();
flush();
fixture.detectChanges();
}));

it('should not change the value when the search emits while the panel is closed', fakeAsync(() => {
expect(testInstance.select().panelOpen).toBe(false);

// `beforeOpened` consumers reset the search to reload options, and with no options
// the panel opens with a delay — so this lands while the panel is still closed.
testInstance.searchCtrl.setValue('');
fixture.detectChanges();
tick();
flush();
fixture.detectChanges();

const triggerText = fixture.debugElement.query(By.css('.kbq-select__matcher-text')).nativeElement
.textContent;

expect(testInstance.select().value).toBe('Unknown');
expect(triggerText.trim()).toBe('Unknown');
}));

it('should highlight the first option when the search emits while the panel is open', fakeAsync(() => {
testInstance.select().open();
fixture.detectChanges();
flush();

testInstance.searchCtrl.setValue('');
fixture.detectChanges();
tick();
flush();
fixture.detectChanges();

expect(testInstance.select().keyManager.activeItemIndex).toBe(0);
expect(testInstance.select().value).toBe('Unknown');
}));
});

describe('with triggerValuesLimit', () => {
let fixture: ComponentFixture<MultiSelectWithTriggerValuesLimit>;
let testInstance: MultiSelectWithTriggerValuesLimit;
Expand Down
23 changes: 17 additions & 6 deletions packages/components/select/select.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,11 +1021,11 @@ export class KbqSelect
?.changes.pipe(
takeUntilDestroyed(this.destroyRef),
delay(0),
filter(() => {
const activeItem = this.keyManager.activeItem as KbqOption | null;

return !activeItem || !this.options.toArray().includes(activeItem);
})
// Only while the panel is open. `beforeOpened` gives consumers a chance to (re)load
// options, and with no options the panel itself opens with a delay, so search changes
// can land while the panel is still closed. Moving the highlight then is read as
// keyboard navigation of a closed select and silently overwrites the value.
filter(() => this.panelOpen && this.isActiveItemStale())
)
.subscribe(() => this.keyManager.setFirstItemActive());
}
Expand Down Expand Up @@ -1716,6 +1716,13 @@ export class KbqSelect
});
}

/** Whether the key manager's active item is missing from the current options list. */
private isActiveItemStale(): boolean {
const activeItem = this.keyManager.activeItem as KbqOption | null;

return !activeItem || !this.options.toArray().includes(activeItem);
}
Comment on lines +1719 to +1724

/**
* Sets the selected option based on a value.
* If no option can be found with the designated value, the select trigger is cleared.
Expand All @@ -1738,7 +1745,11 @@ export class KbqSelect

// Shift focus to the active item. Note that we shouldn't do this in multiple
// mode, because we don't know what option the user interacted with last.
if (correspondingOption && !this.withVirtualScroll) {
// While the panel is open and the user is already navigating a still-present
// active item, keep it as is: `options.changes` re-runs this method on every
// appended chunk (e.g. paging), and re-activating the selected option would
// focus it and scroll the list back to it.
if (correspondingOption && !this.withVirtualScroll && (!this.panelOpen || this.isActiveItemStale())) {
this.keyManager.setActiveItem(correspondingOption);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import { KbqButtonToggleModule } from '@koobiq/components/button-toggle';
import { KbqHighlightBackgroundPipe } from '@koobiq/components/core';
import { KbqHighlightBackgroundPipe, KbqVirtualOption } from '@koobiq/components/core';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqInputModule } from '@koobiq/components/input';
import {
Expand Down Expand Up @@ -125,7 +125,14 @@ export class SelectFacade {
</div>

<kbq-form-field>
<kbq-select [value]="selectedOption" (beforeOpened)="loadOptions()" (closed)="resetOptions()">
<kbq-select
[compareWith]="compareWith"
[showPreselectedValues]="true"
[value]="selectedOption"
[virtualOptionFactory]="virtualOptionFactory"
(beforeOpened)="loadOptions()"
(closed)="resetOptions()"
>
<kbq-form-field noBorders kbqSelectSearch>
<i kbq-icon="kbq-magnifying-glass_16" kbqPrefix></i>
<input kbqInput type="text" autocomplete="off" [formControl]="searchControl" />
Expand Down Expand Up @@ -157,7 +164,7 @@ export class SelectFacade {
}
@case ('success') {
@for (option of state.data; track option) {
<kbq-option [value]="option.id">
<kbq-option [value]="option">
<span
[innerHTML]="option.label | kbqHighlightBackground: searchControl.value"
></span>
Expand All @@ -184,7 +191,10 @@ export class SelectFacade {
export class SelectLoadingExample {
protected readonly facade = inject(SelectFacade);

protected selectedOption: Option = { id: 0, label: `Option 0` };
protected selectedOption: Option = { id: 0, label: `Option #0` };

protected readonly compareWith = (a: Option | null, b: Option | null) => a?.id === b?.id;
protected readonly virtualOptionFactory = (value: Option) => new KbqVirtualOption(value, false, value.label);

readonly state$ = this.facade.state$;
readonly searchControl = new FormControl('');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, NgZone, OnDestroy } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { KbqButtonModule } from '@koobiq/components/button';
import { KbqButtonToggleModule } from '@koobiq/components/button-toggle';
import { KbqHighlightBackgroundPipe } from '@koobiq/components/core';
import { KbqHighlightBackgroundPipe, KbqVirtualOption } from '@koobiq/components/core';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqInputModule } from '@koobiq/components/input';
import { KbqProgressSpinnerModule } from '@koobiq/components/progress-spinner';
Expand Down Expand Up @@ -210,8 +209,6 @@ export class SelectFacade {
selector: 'select-paging-error-example',
imports: [
KbqSelectModule,
KbqButtonToggleModule,
FormsModule,
KbqIconModule,
KbqInputModule,
ReactiveFormsModule,
Expand All @@ -225,7 +222,10 @@ export class SelectFacade {
<kbq-form-field>
<kbq-select
#select
[compareWith]="compareWith"
[showPreselectedValues]="true"
[value]="selectedOption"
[virtualOptionFactory]="virtualOptionFactory"
(beforeOpened)="reloadOptions()"
(opened)="onSelectOpened(select)"
(closed)="onSelectClosed()"
Expand Down Expand Up @@ -260,7 +260,7 @@ export class SelectFacade {
}
@case ('success') {
@for (option of state.data; track option.id) {
<kbq-option [value]="option.id">
<kbq-option [value]="option">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ошибка все равно воспроизводится: https://koobiq-next--prs-1847-hsycrq0f.web.app/ru/components/select/examples#%D0%BF%D0%BE%D1%81%D1%82%D1%80%D0%B0%D0%BD%D0%B8%D1%87%D0%BD%D0%B0%D1%8F-%D0%B7%D0%B0%D0%B3%D1%80%D1%83%D0%B7%D0%BA%D0%B0

Открыть, в поиске ввести 40
Выбрать опцию
Открыть выпадашку снова - Выбор вернется на элемент "#0"

<span
[innerHTML]="option.label | kbqHighlightBackground: searchControl.value"
></span>
Expand Down Expand Up @@ -314,7 +314,10 @@ export class SelectPagingErrorExample implements OnDestroy {
protected readonly facade = inject(SelectFacade);
private readonly ngZone = inject(NgZone);

protected selectedOption: Option = { id: 0, label: `Option 0` };
protected selectedOption: Option = { id: 0, label: `Option #0` };

protected readonly compareWith = (a: Option | null, b: Option | null) => a?.id === b?.id;
protected readonly virtualOptionFactory = (value: Option) => new KbqVirtualOption(value, false, value.label);

readonly searchControl = new FormControl('');

Expand Down
Loading