Skip to content

fix(cdk-experimental/listbox): initial listbox focus state #30764

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 23, 2025
Merged
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
35 changes: 31 additions & 4 deletions src/cdk-experimental/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@
*/

import {
AfterViewInit,
booleanAttribute,
computed,
contentChildren,
Directive,
effect,
ElementRef,
inject,
input,
model,
signal,
} from '@angular/core';
import {ListboxPattern, OptionPattern} from '../ui-patterns';
import {Directionality} from '@angular/cdk/bidi';
Expand All @@ -29,9 +32,9 @@ import {_IdGenerator} from '@angular/cdk/a11y';
*
* ```html
* <ul cdkListbox>
* <li cdkOption>Item 1</li>
* <li cdkOption>Item 2</li>
* <li cdkOption>Item 3</li>
* <li [value]="1" cdkOption>Item 1</li>
* <li [value]="2" cdkOption>Item 2</li>
* <li [value]="3" cdkOption>Item 3</li>
* </ul>
* ```
*/
Expand All @@ -49,9 +52,10 @@ import {_IdGenerator} from '@angular/cdk/a11y';
'[attr.aria-activedescendant]': 'pattern.activedescendant()',
'(keydown)': 'pattern.onKeydown($event)',
'(pointerdown)': 'pattern.onPointerdown($event)',
'(focusin)': 'onFocus()',
},
})
export class CdkListbox<V> {
export class CdkListbox<V> implements AfterViewInit {
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
private readonly _directionality = inject(Directionality);

Expand Down Expand Up @@ -105,6 +109,28 @@ export class CdkListbox<V> {
items: this.items,
textDirection: this.textDirection,
});

/** Whether the listbox has received focus yet. */
private _hasFocused = signal(false);

/** Whether the options in the listbox have been initialized. */
private _isViewInitialized = signal(false);

constructor() {
effect(() => {
if (this._isViewInitialized() && !this._hasFocused()) {
this.pattern.setDefaultState();
}
});
}

ngAfterViewInit() {
this._isViewInitialized.set(true);
}

onFocus() {
this._hasFocused.set(true);
}
}

/** A selectable option in a CdkListbox. */
Expand Down Expand Up @@ -133,6 +159,7 @@ export class CdkOption<V> {
/** A unique identifier for the option. */
protected id = computed(() => this._generatedId);

/** The value of the option. */
protected value = input.required<V>();

// TODO(wagnermaciel): See if we want to change how we handle this since textContent is not
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class ListFocus<T extends ListFocusItem> {

/** Returns the tabindex for the given item. */
getItemTabindex(item: T): -1 | 0 {
if (this.inputs.disabled()) {
if (this.isListDisabled()) {
return -1;
}
if (this.inputs.focusMode() === 'activedescendant') {
Expand Down
60 changes: 53 additions & 7 deletions src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('Listbox Pattern', () => {
const options = signal<TestOption[]>([]);
const listbox = getListbox({...inputs, items: options});
options.set(getOptions(listbox, values));
return {listbox, options};
return {listbox, options: options()};
}

function getDefaultPatterns(inputs: Partial<TestInputs> = {}) {
Expand Down Expand Up @@ -266,7 +266,7 @@ describe('Listbox Pattern', () => {
multi: signal(true),
});
listbox = patterns.listbox;
options = patterns.options();
options = patterns.options;
});

it('should select an option on Space', () => {
Expand Down Expand Up @@ -428,7 +428,7 @@ describe('Listbox Pattern', () => {
selectionMode: signal('follow'),
});
listbox = patterns.listbox;
options = patterns.options();
options = patterns.options;
});

it('should select an option on navigation', () => {
Expand Down Expand Up @@ -563,9 +563,9 @@ describe('Listbox Pattern', () => {
});

describe('Pointer Events', () => {
function click(options: WritableSignal<TestOption[]>, index: number, mods?: ModifierKeys) {
function click(options: TestOption[], index: number, mods?: ModifierKeys) {
return {
target: options()[index].element(),
target: options[index].element(),
shiftKey: mods?.shift,
ctrlKey: mods?.control,
} as unknown as PointerEvent;
Expand Down Expand Up @@ -716,7 +716,7 @@ describe('Listbox Pattern', () => {
skipDisabled: signal(false),
selectionMode: signal('follow'),
});
options()[2].disabled.set(true);
options[2].disabled.set(true);
listbox.onPointerdown(click(options, 0));
expect(listbox.inputs.value()).toEqual(['Apple']);

Expand All @@ -732,7 +732,7 @@ describe('Listbox Pattern', () => {
skipDisabled: signal(true),
selectionMode: signal('follow'),
});
options()[2].disabled.set(true);
options[2].disabled.set(true);
listbox.onPointerdown(click(options, 0));
expect(listbox.inputs.value()).toEqual(['Apple']);
listbox.onKeydown(down({control: true}));
Expand Down Expand Up @@ -785,4 +785,50 @@ describe('Listbox Pattern', () => {
expect(listbox.inputs.value()).toEqual(['Apple', 'Banana', 'Blackberry', 'Blueberry']);
});
});

describe('#setDefaultState', () => {
it('should set the active index to the first option', () => {
const {listbox} = getDefaultPatterns();
listbox.setDefaultState();
expect(listbox.inputs.activeIndex()).toBe(0);
});

it('should set the active index to the first focusable option', () => {
const {listbox, options} = getDefaultPatterns({
skipDisabled: signal(true),
});
options[0].disabled.set(true);
listbox.setDefaultState();
expect(listbox.inputs.activeIndex()).toBe(1);
});

it('should set the active index to the first selected option', () => {
const {listbox} = getDefaultPatterns({
value: signal(['Banana']),
skipDisabled: signal(true),
});
listbox.setDefaultState();
expect(listbox.inputs.activeIndex()).toBe(2);
});

it('should set the active index to the first focusable selected option', () => {
const {listbox, options} = getDefaultPatterns({
value: signal(['Banana', 'Blackberry']),
skipDisabled: signal(true),
});
options[2].disabled.set(true);
listbox.setDefaultState();
expect(listbox.inputs.activeIndex()).toBe(3);
});

it('should set the active index to the first option if no selected option is focusable', () => {
const {listbox, options} = getDefaultPatterns({
value: signal(['Banana']),
skipDisabled: signal(true),
});
options[2].disabled.set(true);
listbox.setDefaultState();
expect(listbox.inputs.activeIndex()).toBe(0);
});
});
});
30 changes: 30 additions & 0 deletions src/cdk-experimental/ui-patterns/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,36 @@ export class ListboxPattern<V> {
this._navigate(opts, () => this.typeahead.search(char));
}

/**
* Sets the listbox to it's default initial state.
*
* Sets the active index of the listbox to the first focusable selected
* item if one exists. Otherwise, sets focus to the first focusable item.
*
* This method should be called once the listbox and it's options are properly initialized,
* meaning the ListboxPattern and OptionPatterns should have references to each other before this
* is called.
*/
setDefaultState() {
let firstItem: OptionPattern<V> | null = null;

for (const item of this.inputs.items()) {
if (this.focusManager.isFocusable(item)) {
if (!firstItem) {
firstItem = item;
}
if (item.selected()) {
this.inputs.activeIndex.set(item.index());
return;
}
}
}

if (firstItem) {
this.inputs.activeIndex.set(firstItem.index());
}
}

/**
* Safely performs a navigation operation.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@
<mat-checkbox [formControl]="readonly">Readonly</mat-checkbox>
<mat-checkbox [formControl]="skipDisabled">Skip Disabled</mat-checkbox>

<mat-form-field subscriptSizing="dynamic" appearance="outline">
<mat-label>Selection</mat-label>
<mat-select [(value)]="selection" multiple>
@for (fruit of fruits; track fruit) {
<mat-option [value]="fruit">{{fruit}}</mat-option>
}
</mat-select>
</mat-form-field>

<mat-form-field subscriptSizing="dynamic" appearance="outline">
<mat-label>Disabled Options</mat-label>
<mat-select [(value)]="disabledOptions" multiple>
Expand Down Expand Up @@ -42,6 +51,7 @@
<!-- #docregion listbox -->
<ul
cdkListbox
[value]="selection"
[wrap]="wrap.value"
[multi]="multi.value"
[readonly]="readonly.value"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class CdkListboxExample {
focusMode: 'roving' | 'activedescendant' = 'roving';
selectionMode: 'explicit' | 'follow' = 'explicit';

selection: string[] = ['Banana', 'Blackberry'];
disabledOptions: string[] = ['Banana', 'Cantaloupe'];

wrap = new FormControl(true, {nonNullable: true});
Expand Down
Loading