diff --git a/src/cdk/listbox/listbox.spec.ts b/src/cdk/listbox/listbox.spec.ts index c9ac18836a01..3a75f90bdb2d 100644 --- a/src/cdk/listbox/listbox.spec.ts +++ b/src/cdk/listbox/listbox.spec.ts @@ -706,6 +706,38 @@ describe('CdkOption and CdkListbox', () => { expect(options[options.length - 1].isActive()).toBeTrue(); }); + + it('should focus the selected option when the listbox is focused', () => { + const {testComponent, fixture, listbox, listboxEl, options} = + setupComponent(ListboxWithOptions); + testComponent.selectedValue = 'peach'; + fixture.detectChanges(); + listbox.focus(); + fixture.detectChanges(); + + expect(options[3].isActive()).toBeTrue(); + + dispatchKeyboardEvent(listboxEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(options[2].isActive()).toBeTrue(); + }); + + it('should not move focus to the selected option while the user is navigating', () => { + const {testComponent, fixture, listbox, listboxEl, options} = + setupComponent(ListboxWithOptions); + listbox.focus(); + fixture.detectChanges(); + expect(options[0].isActive()).toBeTrue(); + + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + expect(options[1].isActive()).toBeTrue(); + + testComponent.selectedValue = 'peach'; + fixture.detectChanges(); + expect(options[1].isActive()).toBeTrue(); + }); }); describe('with roving tabindex', () => { @@ -909,6 +941,7 @@ describe('CdkOption and CdkListbox', () => { [cdkListboxOrientation]="orientation" [cdkListboxNavigationWrapDisabled]="!navigationWraps" [cdkListboxNavigatesDisabledOptions]="!navigationSkipsDisabled" + [cdkListboxValue]="selectedValue" (cdkListboxValueChange)="onSelectionChange($event)">
) { this.changedOption = event.option; diff --git a/src/cdk/listbox/listbox.ts b/src/cdk/listbox/listbox.ts index 38575e067d5e..197f8e34f8c7 100644 --- a/src/cdk/listbox/listbox.ts +++ b/src/cdk/listbox/listbox.ts @@ -146,9 +146,6 @@ export class CdkOption implements ListKeyManagerOption, Highlightab /** Emits when the option is clicked. */ readonly _clicked = new Subject(); - /** Whether the option is currently active. */ - private _active = false; - ngOnDestroy() { this.destroyed.next(); this.destroyed.complete(); @@ -161,7 +158,7 @@ export class CdkOption implements ListKeyManagerOption, Highlightab /** Whether this option is active. */ isActive() { - return this._active; + return this.listbox.isActive(this); } /** Toggle the selected state of this option. */ @@ -190,20 +187,16 @@ export class CdkOption implements ListKeyManagerOption, Highlightab } /** - * Set the option as active. + * No-op implemented as a part of `Highlightable`. * @docs-private */ - setActiveStyles() { - this._active = true; - } + setActiveStyles() {} /** - * Set the option as inactive. + * No-op implemented as a part of `Highlightable`. * @docs-private */ - setInactiveStyles() { - this._active = false; - } + setInactiveStyles() {} /** Handle focus events on the option. */ protected _handleFocus() { @@ -240,6 +233,7 @@ export class CdkOption implements ListKeyManagerOption, Highlightab '(focus)': '_handleFocus()', '(keydown)': '_handleKeydown($event)', '(focusout)': '_handleFocusOut($event)', + '(focusin)': '_handleFocusIn()', }, providers: [ { @@ -419,6 +413,9 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con /** A predicate that does not skip any options. */ private readonly _skipNonePredicate = () => false; + /** Whether the listbox currently has focus. */ + private _hasFocus = false; + ngAfterContentInit() { if (typeof ngDevMode === 'undefined' || ngDevMode) { this._verifyNoOptionValueCollisions(); @@ -526,6 +523,14 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con return this.isValueSelected(option.value); } + /** + * Get whether the given option is active. + * @param option The option to get the active state of + */ + isActive(option: CdkOption): boolean { + return !!(this.listKeyManager?.activeItem === option); + } + /** * Get whether the given value is selected. * @param value The value to get the selected state of @@ -653,7 +658,12 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con /** Called when the listbox receives focus. */ protected _handleFocus() { if (!this.useActiveDescendant) { - this.listKeyManager.setNextItemActive(); + if (this.selectionModel.selected.length > 0) { + this._setNextFocusToSelectedOption(); + } else { + this.listKeyManager.setNextItemActive(); + } + this._focusActiveOption(); } } @@ -759,6 +769,13 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con } } + /** Called when a focus moves into the listbox. */ + protected _handleFocusIn() { + // Note that we use a `focusin` handler for this instead of the existing `focus` handler, + // because focus won't land on the listbox if `useActiveDescendant` is enabled. + this._hasFocus = true; + } + /** * Called when the focus leaves an element in the listbox. * @param event The focusout event @@ -767,6 +784,8 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con const otherElement = event.relatedTarget as Element; if (this.element !== otherElement && !this.element.contains(otherElement)) { this._onTouched(); + this._hasFocus = false; + this._setNextFocusToSelectedOption(); } } @@ -800,6 +819,10 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con this.listKeyManager.withHorizontalOrientation(this._dir?.value || 'ltr'); } + if (this.selectionModel.selected.length) { + Promise.resolve().then(() => this._setNextFocusToSelectedOption()); + } + this.listKeyManager.change.subscribe(() => this._focusActiveOption()); } @@ -820,6 +843,20 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con this.selectionModel.clear(false); } this.selectionModel.setSelection(...this._coerceValue(value)); + + if (!this._hasFocus) { + this._setNextFocusToSelectedOption(); + } + } + + /** Sets the first selected option as first in the keyboard focus order. */ + private _setNextFocusToSelectedOption() { + // Null check the options since they only get defined after `ngAfterContentInit`. + const selected = this.options?.find(option => option.isSelected()); + + if (selected) { + this.listKeyManager.updateActiveItem(selected); + } } /** Update the internal value of the listbox based on the selection model. */ diff --git a/tools/public_api_guard/cdk/listbox.md b/tools/public_api_guard/cdk/listbox.md index b143c78cf4a0..556f66df3f73 100644 --- a/tools/public_api_guard/cdk/listbox.md +++ b/tools/public_api_guard/cdk/listbox.md @@ -34,10 +34,12 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con protected _getAriaActiveDescendant(): string | null | undefined; protected _getTabIndex(): number | null; protected _handleFocus(): void; + protected _handleFocusIn(): void; protected _handleFocusOut(event: FocusEvent): void; protected _handleKeydown(event: KeyboardEvent): void; get id(): string; set id(value: string); + isActive(option: CdkOption): boolean; isSelected(option: CdkOption): boolean; isValueSelected(value: T): boolean; protected listKeyManager: ActiveDescendantKeyManager>;