From 99083fb5feaade4be5ec814f020e069a1a493eea Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 5 Aug 2026 14:06:31 +0300 Subject: [PATCH 1/2] fix(select): keep the active option when options are appended --- .../select/select.component.spec.ts | 123 ++++++++++++++++++ .../components/select/select.component.ts | 19 ++- .../select-loading/select-loading-example.ts | 18 ++- .../select-paging-error-example.ts | 17 ++- .../select-paging/select-paging-example.ts | 17 ++- 5 files changed, 170 insertions(+), 24 deletions(-) diff --git a/packages/components/select/select.component.spec.ts b/packages/components/select/select.component.spec.ts index 4b3637d833..dd0791acf7 100644 --- a/packages/components/select/select.component.spec.ts +++ b/packages/components/select/select.component.spec.ts @@ -1891,6 +1891,49 @@ class SelectWithShowPreselectedValuesMultiple { control = new UntypedFormControl(['unknown-value-1', 'pizza', 'unknown-value-2']); } +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 +6629,86 @@ 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 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..535826427f 100644 --- a/packages/components/select/select.component.ts +++ b/packages/components/select/select.component.ts @@ -1021,11 +1021,7 @@ 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); - }) + filter(() => this.isActiveItemStale()) ) .subscribe(() => this.keyManager.setFirstItemActive()); } @@ -1716,6 +1712,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 +1741,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(''); From a2f3870898e1a69b47e34115643039f8dc7cc217 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 5 Aug 2026 16:35:59 +0300 Subject: [PATCH 2/2] fix(select): don't select the first option when search emits on a closed panel --- .../select/select.component.spec.ts | 73 +++++++++++++++++++ .../components/select/select.component.ts | 6 +- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/components/select/select.component.spec.ts b/packages/components/select/select.component.spec.ts index dd0791acf7..3eb1b0226c 100644 --- a/packages/components/select/select.component.spec.ts +++ b/packages/components/select/select.component.spec.ts @@ -1891,6 +1891,32 @@ 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({ @@ -6709,6 +6735,53 @@ describe('KbqSelect', () => { })); }); + 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 535826427f..1efdda7bb8 100644 --- a/packages/components/select/select.component.ts +++ b/packages/components/select/select.component.ts @@ -1021,7 +1021,11 @@ export class KbqSelect ?.changes.pipe( takeUntilDestroyed(this.destroyRef), delay(0), - filter(() => this.isActiveItemStale()) + // 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()); }