Skip to content

Commit dac9e81

Browse files
committed
fix(cdk/listbox): set initial focus to selected option (#26174)
Fixes that the listbox was setting the initial focus on a deselected option when a selected one was available. **Note:** this fix is a bit more convoluted that it needs to be. E.g. ideally we would just focus the selected option when the listbox receives focus. We can't do that, because the listbox supports two different focus management modes: focus and `aria-activedescendant`. The former doesn't allow tabbing to the listbox. Fixes #25833. (cherry picked from commit f99af6d)
1 parent 3a01346 commit dac9e81

File tree

3 files changed

+86
-13
lines changed

3 files changed

+86
-13
lines changed

src/cdk/listbox/listbox.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,38 @@ describe('CdkOption and CdkListbox', () => {
706706

707707
expect(options[options.length - 1].isActive()).toBeTrue();
708708
});
709+
710+
it('should focus the selected option when the listbox is focused', () => {
711+
const {testComponent, fixture, listbox, listboxEl, options} =
712+
setupComponent(ListboxWithOptions);
713+
testComponent.selectedValue = 'peach';
714+
fixture.detectChanges();
715+
listbox.focus();
716+
fixture.detectChanges();
717+
718+
expect(options[3].isActive()).toBeTrue();
719+
720+
dispatchKeyboardEvent(listboxEl, 'keydown', UP_ARROW);
721+
fixture.detectChanges();
722+
723+
expect(options[2].isActive()).toBeTrue();
724+
});
725+
726+
it('should not move focus to the selected option while the user is navigating', () => {
727+
const {testComponent, fixture, listbox, listboxEl, options} =
728+
setupComponent(ListboxWithOptions);
729+
listbox.focus();
730+
fixture.detectChanges();
731+
expect(options[0].isActive()).toBeTrue();
732+
733+
dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW);
734+
fixture.detectChanges();
735+
expect(options[1].isActive()).toBeTrue();
736+
737+
testComponent.selectedValue = 'peach';
738+
fixture.detectChanges();
739+
expect(options[1].isActive()).toBeTrue();
740+
});
709741
});
710742

711743
describe('with roving tabindex', () => {
@@ -909,6 +941,7 @@ describe('CdkOption and CdkListbox', () => {
909941
[cdkListboxOrientation]="orientation"
910942
[cdkListboxNavigationWrapDisabled]="!navigationWraps"
911943
[cdkListboxNavigatesDisabledOptions]="!navigationSkipsDisabled"
944+
[cdkListboxValue]="selectedValue"
912945
(cdkListboxValueChange)="onSelectionChange($event)">
913946
<div cdkOption="apple"
914947
[cdkOptionDisabled]="isAppleDisabled"
@@ -937,6 +970,7 @@ class ListboxWithOptions {
937970
appleId: string;
938971
appleTabindex: number;
939972
orientation: 'horizontal' | 'vertical' = 'vertical';
973+
selectedValue: string;
940974

941975
onSelectionChange(event: ListboxValueChangeEvent<unknown>) {
942976
this.changedOption = event.option;

src/cdk/listbox/listbox.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,6 @@ export class CdkOption<T = unknown> implements ListKeyManagerOption, Highlightab
146146
/** Emits when the option is clicked. */
147147
readonly _clicked = new Subject<MouseEvent>();
148148

149-
/** Whether the option is currently active. */
150-
private _active = false;
151-
152149
ngOnDestroy() {
153150
this.destroyed.next();
154151
this.destroyed.complete();
@@ -161,7 +158,7 @@ export class CdkOption<T = unknown> implements ListKeyManagerOption, Highlightab
161158

162159
/** Whether this option is active. */
163160
isActive() {
164-
return this._active;
161+
return this.listbox.isActive(this);
165162
}
166163

167164
/** Toggle the selected state of this option. */
@@ -190,20 +187,16 @@ export class CdkOption<T = unknown> implements ListKeyManagerOption, Highlightab
190187
}
191188

192189
/**
193-
* Set the option as active.
190+
* No-op implemented as a part of `Highlightable`.
194191
* @docs-private
195192
*/
196-
setActiveStyles() {
197-
this._active = true;
198-
}
193+
setActiveStyles() {}
199194

200195
/**
201-
* Set the option as inactive.
196+
* No-op implemented as a part of `Highlightable`.
202197
* @docs-private
203198
*/
204-
setInactiveStyles() {
205-
this._active = false;
206-
}
199+
setInactiveStyles() {}
207200

208201
/** Handle focus events on the option. */
209202
protected _handleFocus() {
@@ -240,6 +233,7 @@ export class CdkOption<T = unknown> implements ListKeyManagerOption, Highlightab
240233
'(focus)': '_handleFocus()',
241234
'(keydown)': '_handleKeydown($event)',
242235
'(focusout)': '_handleFocusOut($event)',
236+
'(focusin)': '_handleFocusIn()',
243237
},
244238
providers: [
245239
{
@@ -419,6 +413,9 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
419413
/** A predicate that does not skip any options. */
420414
private readonly _skipNonePredicate = () => false;
421415

416+
/** Whether the listbox currently has focus. */
417+
private _hasFocus = false;
418+
422419
ngAfterContentInit() {
423420
if (typeof ngDevMode === 'undefined' || ngDevMode) {
424421
this._verifyNoOptionValueCollisions();
@@ -526,6 +523,14 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
526523
return this.isValueSelected(option.value);
527524
}
528525

526+
/**
527+
* Get whether the given option is active.
528+
* @param option The option to get the active state of
529+
*/
530+
isActive(option: CdkOption<T>): boolean {
531+
return !!(this.listKeyManager?.activeItem === option);
532+
}
533+
529534
/**
530535
* Get whether the given value is selected.
531536
* @param value The value to get the selected state of
@@ -653,7 +658,12 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
653658
/** Called when the listbox receives focus. */
654659
protected _handleFocus() {
655660
if (!this.useActiveDescendant) {
656-
this.listKeyManager.setNextItemActive();
661+
if (this.selectionModel.selected.length > 0) {
662+
this._setNextFocusToSelectedOption();
663+
} else {
664+
this.listKeyManager.setNextItemActive();
665+
}
666+
657667
this._focusActiveOption();
658668
}
659669
}
@@ -759,6 +769,13 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
759769
}
760770
}
761771

772+
/** Called when a focus moves into the listbox. */
773+
protected _handleFocusIn() {
774+
// Note that we use a `focusin` handler for this instead of the existing `focus` handler,
775+
// because focus won't land on the listbox if `useActiveDescendant` is enabled.
776+
this._hasFocus = true;
777+
}
778+
762779
/**
763780
* Called when the focus leaves an element in the listbox.
764781
* @param event The focusout event
@@ -767,6 +784,8 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
767784
const otherElement = event.relatedTarget as Element;
768785
if (this.element !== otherElement && !this.element.contains(otherElement)) {
769786
this._onTouched();
787+
this._hasFocus = false;
788+
this._setNextFocusToSelectedOption();
770789
}
771790
}
772791

@@ -800,6 +819,10 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
800819
this.listKeyManager.withHorizontalOrientation(this._dir?.value || 'ltr');
801820
}
802821

822+
if (this.selectionModel.selected.length) {
823+
Promise.resolve().then(() => this._setNextFocusToSelectedOption());
824+
}
825+
803826
this.listKeyManager.change.subscribe(() => this._focusActiveOption());
804827
}
805828

@@ -820,6 +843,20 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
820843
this.selectionModel.clear(false);
821844
}
822845
this.selectionModel.setSelection(...this._coerceValue(value));
846+
847+
if (!this._hasFocus) {
848+
this._setNextFocusToSelectedOption();
849+
}
850+
}
851+
852+
/** Sets the first selected option as first in the keyboard focus order. */
853+
private _setNextFocusToSelectedOption() {
854+
// Null check the options since they only get defined after `ngAfterContentInit`.
855+
const selected = this.options?.find(option => option.isSelected());
856+
857+
if (selected) {
858+
this.listKeyManager.updateActiveItem(selected);
859+
}
823860
}
824861

825862
/** Update the internal value of the listbox based on the selection model. */

tools/public_api_guard/cdk/listbox.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
3434
protected _getAriaActiveDescendant(): string | null | undefined;
3535
protected _getTabIndex(): number | null;
3636
protected _handleFocus(): void;
37+
protected _handleFocusIn(): void;
3738
protected _handleFocusOut(event: FocusEvent): void;
3839
protected _handleKeydown(event: KeyboardEvent): void;
3940
get id(): string;
4041
set id(value: string);
42+
isActive(option: CdkOption<T>): boolean;
4143
isSelected(option: CdkOption<T>): boolean;
4244
isValueSelected(value: T): boolean;
4345
protected listKeyManager: ActiveDescendantKeyManager<CdkOption<T>>;

0 commit comments

Comments
 (0)