Skip to content

Commit 18a7192

Browse files
committed
fix(cdk-experimental/listbox): initial listbox focus state
* The first option to receive focus in a listbox should be either the first focusable selected option or the first focusable option in the list if no focusable selected option exists.
1 parent 9f5b154 commit 18a7192

File tree

6 files changed

+126
-12
lines changed

6 files changed

+126
-12
lines changed

src/cdk-experimental/listbox/listbox.ts

+31-4
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@
77
*/
88

99
import {
10+
AfterViewInit,
1011
booleanAttribute,
1112
computed,
1213
contentChildren,
1314
Directive,
15+
effect,
1416
ElementRef,
1517
inject,
1618
input,
1719
model,
20+
signal,
1821
} from '@angular/core';
1922
import {ListboxPattern, OptionPattern} from '../ui-patterns';
2023
import {Directionality} from '@angular/cdk/bidi';
@@ -29,9 +32,9 @@ import {_IdGenerator} from '@angular/cdk/a11y';
2932
*
3033
* ```html
3134
* <ul cdkListbox>
32-
* <li cdkOption>Item 1</li>
33-
* <li cdkOption>Item 2</li>
34-
* <li cdkOption>Item 3</li>
35+
* <li [value]="1" cdkOption>Item 1</li>
36+
* <li [value]="2" cdkOption>Item 2</li>
37+
* <li [value]="3" cdkOption>Item 3</li>
3538
* </ul>
3639
* ```
3740
*/
@@ -49,9 +52,10 @@ import {_IdGenerator} from '@angular/cdk/a11y';
4952
'[attr.aria-activedescendant]': 'pattern.activedescendant()',
5053
'(keydown)': 'pattern.onKeydown($event)',
5154
'(pointerdown)': 'pattern.onPointerdown($event)',
55+
'(focusin)': 'onFocus()',
5256
},
5357
})
54-
export class CdkListbox<V> {
58+
export class CdkListbox<V> implements AfterViewInit {
5559
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
5660
private readonly _directionality = inject(Directionality);
5761

@@ -105,6 +109,28 @@ export class CdkListbox<V> {
105109
items: this.items,
106110
textDirection: this.textDirection,
107111
});
112+
113+
/** Whether the listbox has received focus yet. */
114+
private _touched = signal(false);
115+
116+
/** Whether the options in the listbox have been initialized. */
117+
private _isViewInitialized = signal(false);
118+
119+
constructor() {
120+
effect(() => {
121+
if (this._isViewInitialized() && !this._touched()) {
122+
this.pattern.setDefaultState();
123+
}
124+
});
125+
}
126+
127+
ngAfterViewInit() {
128+
this._isViewInitialized.set(true);
129+
}
130+
131+
onFocus() {
132+
this._touched.set(true);
133+
}
108134
}
109135

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

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

138165
// TODO(wagnermaciel): See if we want to change how we handle this since textContent is not

src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export class ListFocus<T extends ListFocusItem> {
7575

7676
/** Returns the tabindex for the given item. */
7777
getItemTabindex(item: T): -1 | 0 {
78-
if (this.inputs.disabled()) {
78+
if (this.isListDisabled()) {
7979
return -1;
8080
}
8181
if (this.inputs.focusMode() === 'activedescendant') {

src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts

+53-7
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ describe('Listbox Pattern', () => {
6767
const options = signal<TestOption[]>([]);
6868
const listbox = getListbox({...inputs, items: options});
6969
options.set(getOptions(listbox, values));
70-
return {listbox, options};
70+
return {listbox, options: options()};
7171
}
7272

7373
function getDefaultPatterns(inputs: Partial<TestInputs> = {}) {
@@ -266,7 +266,7 @@ describe('Listbox Pattern', () => {
266266
multi: signal(true),
267267
});
268268
listbox = patterns.listbox;
269-
options = patterns.options();
269+
options = patterns.options;
270270
});
271271

272272
it('should select an option on Space', () => {
@@ -428,7 +428,7 @@ describe('Listbox Pattern', () => {
428428
selectionMode: signal('follow'),
429429
});
430430
listbox = patterns.listbox;
431-
options = patterns.options();
431+
options = patterns.options;
432432
});
433433

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

565565
describe('Pointer Events', () => {
566-
function click(options: WritableSignal<TestOption[]>, index: number, mods?: ModifierKeys) {
566+
function click(options: TestOption[], index: number, mods?: ModifierKeys) {
567567
return {
568-
target: options()[index].element(),
568+
target: options[index].element(),
569569
shiftKey: mods?.shift,
570570
ctrlKey: mods?.control,
571571
} as unknown as PointerEvent;
@@ -716,7 +716,7 @@ describe('Listbox Pattern', () => {
716716
skipDisabled: signal(false),
717717
selectionMode: signal('follow'),
718718
});
719-
options()[2].disabled.set(true);
719+
options[2].disabled.set(true);
720720
listbox.onPointerdown(click(options, 0));
721721
expect(listbox.inputs.value()).toEqual(['Apple']);
722722

@@ -732,7 +732,7 @@ describe('Listbox Pattern', () => {
732732
skipDisabled: signal(true),
733733
selectionMode: signal('follow'),
734734
});
735-
options()[2].disabled.set(true);
735+
options[2].disabled.set(true);
736736
listbox.onPointerdown(click(options, 0));
737737
expect(listbox.inputs.value()).toEqual(['Apple']);
738738
listbox.onKeydown(down({control: true}));
@@ -785,4 +785,50 @@ describe('Listbox Pattern', () => {
785785
expect(listbox.inputs.value()).toEqual(['Apple', 'Banana', 'Blackberry', 'Blueberry']);
786786
});
787787
});
788+
789+
describe('#setDefaultState', () => {
790+
it('should set the active index to the first option', () => {
791+
const {listbox} = getDefaultPatterns();
792+
listbox.setDefaultState();
793+
expect(listbox.inputs.activeIndex()).toBe(0);
794+
});
795+
796+
it('should set the active index to the first focusable option', () => {
797+
const {listbox, options} = getDefaultPatterns({
798+
skipDisabled: signal(true),
799+
});
800+
options[0].disabled.set(true);
801+
listbox.setDefaultState();
802+
expect(listbox.inputs.activeIndex()).toBe(1);
803+
});
804+
805+
it('should set the active index to the first selected option', () => {
806+
const {listbox} = getDefaultPatterns({
807+
value: signal(['Banana']),
808+
skipDisabled: signal(true),
809+
});
810+
listbox.setDefaultState();
811+
expect(listbox.inputs.activeIndex()).toBe(2);
812+
});
813+
814+
it('should set the active index to the first focusable selected option', () => {
815+
const {listbox, options} = getDefaultPatterns({
816+
value: signal(['Banana', 'Blackberry']),
817+
skipDisabled: signal(true),
818+
});
819+
options[2].disabled.set(true);
820+
listbox.setDefaultState();
821+
expect(listbox.inputs.activeIndex()).toBe(3);
822+
});
823+
824+
it('should set the active index to the first option if no selected option is focusable', () => {
825+
const {listbox, options} = getDefaultPatterns({
826+
value: signal(['Banana']),
827+
skipDisabled: signal(true),
828+
});
829+
options[2].disabled.set(true);
830+
listbox.setDefaultState();
831+
expect(listbox.inputs.activeIndex()).toBe(0);
832+
});
833+
});
788834
});

src/cdk-experimental/ui-patterns/listbox/listbox.ts

+30
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,36 @@ export class ListboxPattern<V> {
281281
this._navigate(opts, () => this.typeahead.search(char));
282282
}
283283

284+
/**
285+
* Sets the listbox to it's default initial state.
286+
*
287+
* Sets the active index of the listbox to the first focusable selected
288+
* item if one exists. Otherwise, sets focus to the first focusable item.
289+
*
290+
* This method should be called once the listbox and it's options are properly initialized,
291+
* meaning the ListboxPattern and OptionPatterns should have references to each other before this
292+
* is called.
293+
*/
294+
setDefaultState() {
295+
let firstItem: OptionPattern<V> | null = null;
296+
297+
for (const item of this.inputs.items()) {
298+
if (this.focusManager.isFocusable(item)) {
299+
if (!firstItem) {
300+
firstItem = item;
301+
}
302+
if (item.selected()) {
303+
this.inputs.activeIndex.set(item.index());
304+
return;
305+
}
306+
}
307+
}
308+
309+
if (firstItem) {
310+
this.inputs.activeIndex.set(firstItem.index());
311+
}
312+
}
313+
284314
/**
285315
* Safely performs a navigation operation.
286316
*

src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html

+10
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
<mat-checkbox [formControl]="readonly">Readonly</mat-checkbox>
66
<mat-checkbox [formControl]="skipDisabled">Skip Disabled</mat-checkbox>
77

8+
<mat-form-field subscriptSizing="dynamic" appearance="outline">
9+
<mat-label>Selection</mat-label>
10+
<mat-select [(value)]="selection" multiple>
11+
@for (fruit of fruits; track fruit) {
12+
<mat-option [value]="fruit">{{fruit}}</mat-option>
13+
}
14+
</mat-select>
15+
</mat-form-field>
16+
817
<mat-form-field subscriptSizing="dynamic" appearance="outline">
918
<mat-label>Disabled Options</mat-label>
1019
<mat-select [(value)]="disabledOptions" multiple>
@@ -42,6 +51,7 @@
4251
<!-- #docregion listbox -->
4352
<ul
4453
cdkListbox
54+
[value]="selection"
4555
[wrap]="wrap.value"
4656
[multi]="multi.value"
4757
[readonly]="readonly.value"

src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export class CdkListboxExample {
2727
focusMode: 'roving' | 'activedescendant' = 'roving';
2828
selectionMode: 'explicit' | 'follow' = 'explicit';
2929

30+
selection: string[] = ['Banana', 'Blackberry'];
3031
disabledOptions: string[] = ['Banana', 'Cantaloupe'];
3132

3233
wrap = new FormControl(true, {nonNullable: true});

0 commit comments

Comments
 (0)