diff --git a/packages/components/select/select.component.spec.ts b/packages/components/select/select.component.spec.ts
index 4b3637d833..3eb1b0226c 100644
--- a/packages/components/select/select.component.spec.ts
+++ b/packages/components/select/select.component.spec.ts
@@ -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: `
+
+
+
+
+
+
+ @for (option of options; track option) {
+ {{ option }}
+ }
+
+
+ `
+})
+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: `
+
+
+ @for (option of options; track option.id) {
+ {{ option.name }}
+ }
+
+
+ `
+})
+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],
@@ -6586,6 +6655,133 @@ describe('KbqSelect', () => {
});
});
+ describe('with asynchronously loaded options', () => {
+ let fixture: ComponentFixture;
+ 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;
+ 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;
let testInstance: MultiSelectWithTriggerValuesLimit;
diff --git a/packages/components/select/select.component.ts b/packages/components/select/select.component.ts
index ec3ff0f389..1efdda7bb8 100644
--- a/packages/components/select/select.component.ts
+++ b/packages/components/select/select.component.ts
@@ -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());
}
@@ -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);
+ }
+
/**
* Sets the selected option based on a value.
* If no option can be found with the designated value, the select trigger is cleared.
@@ -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);
}
}
diff --git a/packages/docs-examples/components/select/select-loading/select-loading-example.ts b/packages/docs-examples/components/select/select-loading/select-loading-example.ts
index 4eaf7a09a1..94577602a1 100644
--- a/packages/docs-examples/components/select/select-loading/select-loading-example.ts
+++ b/packages/docs-examples/components/select/select-loading/select-loading-example.ts
@@ -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 {
@@ -125,7 +125,14 @@ export class SelectFacade {
-
+
@@ -157,7 +164,7 @@ export class SelectFacade {
}
@case ('success') {
@for (option of state.data; track option) {
-
+
@@ -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('');
diff --git a/packages/docs-examples/components/select/select-paging-error/select-paging-error-example.ts b/packages/docs-examples/components/select/select-paging-error/select-paging-error-example.ts
index c1263d127f..690be5db56 100644
--- a/packages/docs-examples/components/select/select-paging-error/select-paging-error-example.ts
+++ b/packages/docs-examples/components/select/select-paging-error/select-paging-error-example.ts
@@ -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';
@@ -210,8 +209,6 @@ export class SelectFacade {
selector: 'select-paging-error-example',
imports: [
KbqSelectModule,
- KbqButtonToggleModule,
- FormsModule,
KbqIconModule,
KbqInputModule,
ReactiveFormsModule,
@@ -225,7 +222,10 @@ export class SelectFacade {
+
@@ -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('');
diff --git a/packages/docs-examples/components/select/select-paging/select-paging-example.ts b/packages/docs-examples/components/select/select-paging/select-paging-example.ts
index ee71c26a1d..3a3c8032a6 100644
--- a/packages/docs-examples/components/select/select-paging/select-paging-example.ts
+++ b/packages/docs-examples/components/select/select-paging/select-paging-example.ts
@@ -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';
@@ -191,8 +190,6 @@ export class SelectFacade {
selector: 'select-paging-example',
imports: [
KbqSelectModule,
- KbqButtonToggleModule,
- FormsModule,
KbqIconModule,
KbqInputModule,
ReactiveFormsModule,
@@ -206,7 +203,10 @@ export class SelectFacade {
+
@@ -278,7 +278,10 @@ export class SelectPagingExample 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('');