Skip to content

Commit 6bcf751

Browse files
committed
fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern
1 parent 0de6c06 commit 6bcf751

File tree

9 files changed

+57
-69
lines changed

9 files changed

+57
-69
lines changed

src/cdk-experimental/listbox/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ ng_module(
1010
),
1111
deps = [
1212
"//src/cdk-experimental/ui-patterns",
13+
"//src/cdk/a11y",
1314
"//src/cdk/bidi",
1415
],
1516
)

src/cdk-experimental/listbox/listbox.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import {ListboxPattern, OptionPattern} from '@angular/cdk-experimental/ui-patterns';
2020
import {Directionality} from '@angular/cdk/bidi';
2121
import {toSignal} from '@angular/core/rxjs-interop';
22+
import {_IdGenerator} from '@angular/cdk/a11y';
2223

2324
/**
2425
* A listbox container.
@@ -51,10 +52,10 @@ import {toSignal} from '@angular/core/rxjs-interop';
5152
})
5253
export class CdkListbox {
5354
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
54-
private _directionality = inject(Directionality);
55+
private readonly _directionality = inject(Directionality);
5556

5657
/** The CdkOptions nested inside of the CdkListbox. */
57-
private _cdkOptions = contentChildren(CdkOption, {descendants: true});
58+
private readonly _cdkOptions = contentChildren(CdkOption, {descendants: true});
5859

5960
/** A signal wrapper for directionality. */
6061
protected textDirection = toSignal(this._directionality.change, {
@@ -103,9 +104,6 @@ export class CdkListbox {
103104
});
104105
}
105106

106-
// TODO(wagnermaciel): Figure out how we want to generate IDs.
107-
let count = 0;
108-
109107
/** A selectable option in a CdkListbox. */
110108
@Directive({
111109
selector: '[cdkOption]',
@@ -120,14 +118,16 @@ let count = 0;
120118
})
121119
export class CdkOption {
122120
/** A reference to the option element. */
123-
private _elementRef = inject(ElementRef);
121+
private readonly _elementRef = inject(ElementRef);
124122

125123
/** The parent CdkListbox. */
126-
private _cdkListbox = inject(CdkListbox);
124+
private readonly _cdkListbox = inject(CdkListbox);
125+
126+
/** A unique identifier for the option. */
127+
private readonly _generatedId = inject(_IdGenerator).getId('cdk-option-');
127128

128-
// TODO(wagnermaciel): Figure out how we want to generate IDs.
129129
/** A unique identifier for the option. */
130-
protected id = computed(() => `${count++}`);
130+
protected id = computed(() => this._generatedId);
131131

132132
// TODO(wagnermaciel): See if we want to change how we handle this since textContent is not
133133
// reactive. See https://github.com/angular/components/pull/30495#discussion_r1961260216.

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {Signal, signal} from '@angular/core';
9+
import {computed, Signal, signal} from '@angular/core';
1010
import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation';
1111
import {ListFocus, ListFocusInputs, ListFocusItem} from './list-focus';
1212

@@ -58,7 +58,7 @@ describe('List Focus', () => {
5858
const items = getItems(5);
5959
const nav = getNavigation(items);
6060
const focus = getFocus(nav);
61-
const tabindex = focus.getListTabindex();
61+
const tabindex = computed(() => focus.getListTabindex());
6262
expect(tabindex()).toBe(-1);
6363
});
6464

@@ -75,7 +75,7 @@ describe('List Focus', () => {
7575
const focus = getFocus(nav);
7676

7777
items().forEach(i => {
78-
i.tabindex = focus.getItemTabindex(i);
78+
i.tabindex = computed(() => focus.getItemTabindex(i));
7979
});
8080

8181
expect(items()[0].tabindex()).toBe(0);
@@ -91,7 +91,7 @@ describe('List Focus', () => {
9191
const focus = getFocus(nav);
9292

9393
items().forEach(i => {
94-
i.tabindex = focus.getItemTabindex(i);
94+
i.tabindex = computed(() => focus.getItemTabindex(i));
9595
});
9696

9797
nav.next();
@@ -111,7 +111,7 @@ describe('List Focus', () => {
111111
const focus = getFocus(nav, {
112112
focusMode: signal('activedescendant'),
113113
});
114-
const tabindex = focus.getListTabindex();
114+
const tabindex = computed(() => focus.getListTabindex());
115115
expect(tabindex()).toBe(0);
116116
});
117117

@@ -132,7 +132,7 @@ describe('List Focus', () => {
132132
});
133133

134134
items().forEach(i => {
135-
i.tabindex = focus.getItemTabindex(i);
135+
i.tabindex = computed(() => focus.getItemTabindex(i));
136136
});
137137

138138
expect(items()[0].tabindex()).toBe(-1);

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

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed, Signal} from '@angular/core';
9+
import {Signal} from '@angular/core';
1010
import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation';
1111

1212
/** Represents an item in a collection, such as a listbox option, than may receive focus. */
@@ -29,32 +29,30 @@ export class ListFocus<T extends ListFocusItem> {
2929
/** The navigation controller of the parent list. */
3030
navigation: ListNavigation<ListFocusItem>;
3131

32+
constructor(readonly inputs: ListFocusInputs<T> & {navigation: ListNavigation<T>}) {
33+
this.navigation = inputs.navigation;
34+
}
35+
3236
/** The id of the current active item. */
33-
getActiveDescendant = computed<string | undefined>(() => {
37+
getActiveDescendant(): String | undefined {
3438
if (this.inputs.focusMode() === 'roving') {
3539
return undefined;
3640
}
3741
return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id();
38-
});
39-
40-
constructor(readonly inputs: ListFocusInputs<T> & {navigation: ListNavigation<T>}) {
41-
this.navigation = inputs.navigation;
4242
}
4343

44-
/** Returns a signal that keeps track of the tabindex for the list. */
45-
getListTabindex(): Signal<-1 | 0> {
46-
return computed(() => (this.inputs.focusMode() === 'activedescendant' ? 0 : -1));
44+
/** The tabindex for the list. */
45+
getListTabindex(): -1 | 0 {
46+
return this.inputs.focusMode() === 'activedescendant' ? 0 : -1;
4747
}
4848

49-
/** Returns a signal that keeps track of the tabindex for the given item. */
50-
getItemTabindex(item: T): Signal<-1 | 0> {
51-
return computed(() => {
52-
if (this.inputs.focusMode() === 'activedescendant') {
53-
return -1;
54-
}
55-
const index = this.navigation.inputs.items().indexOf(item);
56-
return this.navigation.inputs.activeIndex() === index ? 0 : -1;
57-
});
49+
/** Returns the tabindex for the given item. */
50+
getItemTabindex(item: T): -1 | 0 {
51+
if (this.inputs.focusMode() === 'activedescendant') {
52+
return -1;
53+
}
54+
const index = this.navigation.inputs.items().indexOf(item);
55+
return this.navigation.inputs.activeIndex() === index ? 0 : -1;
5856
}
5957

6058
/** Focuses the current active item. */

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

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -56,43 +56,30 @@ export class ListNavigation<T extends ListNavigationItem> {
5656
/** Navigates to the next item in the list. */
5757
next() {
5858
const items = this.inputs.items();
59+
const itemCount = items.length;
60+
const startIndex = this.inputs.activeIndex();
61+
const step = (i: number) => this._stepIndex(i, 1);
5962

60-
for (let i = this.inputs.activeIndex() + 1; i < items.length; i++) {
63+
for (let i = step(startIndex); i !== startIndex && i < itemCount; step(i)) {
6164
if (this.isFocusable(items[i])) {
6265
this.goto(items[i]);
6366
return;
6467
}
6568
}
66-
67-
if (this.inputs.wrap()) {
68-
for (let i = 0; i <= this.inputs.activeIndex(); i++) {
69-
if (this.isFocusable(items[i])) {
70-
this.goto(items[i]);
71-
return;
72-
}
73-
}
74-
}
7569
}
7670

7771
/** Navigates to the previous item in the list. */
7872
prev() {
7973
const items = this.inputs.items();
74+
const startIndex = this.inputs.activeIndex();
75+
const step = (i: number) => this._stepIndex(i, -1);
8076

81-
for (let i = this.inputs.activeIndex() - 1; i >= 0; i--) {
77+
for (let i = step(startIndex); i !== startIndex && i >= 0; step(i)) {
8278
if (this.isFocusable(items[i])) {
8379
this.goto(items[i]);
8480
return;
8581
}
8682
}
87-
88-
if (this.inputs.wrap()) {
89-
for (let i = items.length - 1; i >= this.inputs.activeIndex(); i--) {
90-
if (this.isFocusable(items[i])) {
91-
this.goto(items[i]);
92-
return;
93-
}
94-
}
95-
}
9683
}
9784

9885
/** Navigates to the first item in the list. */
@@ -119,4 +106,10 @@ export class ListNavigation<T extends ListNavigationItem> {
119106
isFocusable(item: T): boolean {
120107
return !item.disabled() || !this.inputs.skipDisabled();
121108
}
109+
110+
private _stepIndex(index: number, step: -1 | 1) {
111+
return this.inputs.wrap()
112+
? (index + step + this.inputs.items().length) % this.inputs.items().length
113+
: index + step;
114+
}
122115
}

src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ describe('List Selection', () => {
193193
selection.select(); // [0]
194194
nav.next();
195195
nav.next();
196-
selection.selectFromLastSelectedItem(); // [0, 1, 2]
196+
selection.selectFromPrevSelectedItem(); // [0, 1, 2]
197197

198198
expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2']);
199199
});
@@ -208,7 +208,7 @@ describe('List Selection', () => {
208208
selection.select(); // [3]
209209
nav.prev();
210210
nav.prev();
211-
selection.selectFromLastSelectedItem(); // [3, 1, 2]
211+
selection.selectFromPrevSelectedItem(); // [3, 1, 2]
212212

213213
expect(selection.inputs.selectedIds()).toEqual(['3', '1', '2']);
214214
});

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ export interface ListSelectionInputs<T extends ListSelectionItem> {
3535

3636
/** Controls selection for a list of items. */
3737
export class ListSelection<T extends ListSelectionItem> {
38-
/** The id of the last selected item. */
39-
lastSelectedId = signal<string | undefined>(undefined);
38+
/** The id of the most recently selected item. */
39+
previousSelectedId = signal<string | undefined>(undefined);
4040

4141
/** The navigation controller of the parent list. */
4242
navigation: ListNavigation<T>;
@@ -104,9 +104,9 @@ export class ListSelection<T extends ListSelectionItem> {
104104
}
105105

106106
/** Selects the items in the list starting at the last selected item. */
107-
selectFromLastSelectedItem() {
108-
const lastSelectedId = this.inputs.items().findIndex(i => this.lastSelectedId() === i.id());
109-
this._selectFromIndex(lastSelectedId);
107+
selectFromPrevSelectedItem() {
108+
const prevSelectedId = this.inputs.items().findIndex(i => this.previousSelectedId() === i.id());
109+
this._selectFromIndex(prevSelectedId);
110110
}
111111

112112
/** Selects the items in the list starting at the last active item. */
@@ -137,6 +137,6 @@ export class ListSelection<T extends ListSelectionItem> {
137137
/** Sets the anchor to the current active index. */
138138
private _anchor() {
139139
const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()];
140-
this.lastSelectedId.set(item.id());
140+
this.previousSelectedId.set(item.id());
141141
}
142142
}

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ export class ListboxPattern {
5656
disabled: Signal<boolean>;
5757

5858
/** The tabindex of the listbox. */
59-
tabindex: Signal<-1 | 0>;
59+
tabindex = computed(() => this.focusManager.getListTabindex());
6060

6161
/** The id of the current active item. */
62-
activedescendant: Signal<string | undefined>;
62+
activedescendant = computed(() => this.focusManager.getActiveDescendant());
6363

6464
/** Whether multiple items in the list can be selected at once. */
6565
multiselectable: Signal<boolean>;
@@ -167,9 +167,6 @@ export class ListboxPattern {
167167
this.selection = new ListSelection({...inputs, navigation: this.navigation});
168168
this.typeahead = new ListTypeahead({...inputs, navigation: this.navigation});
169169
this.focusManager = new ListFocus({...inputs, navigation: this.navigation});
170-
171-
this.tabindex = this.focusManager.getListTabindex();
172-
this.activedescendant = this.focusManager.getActiveDescendant;
173170
}
174171

175172
/** Handles keydown events for the listbox. */
@@ -249,7 +246,7 @@ export class ListboxPattern {
249246
this.selection.selectAll();
250247
}
251248
if (opts?.selectFromAnchor) {
252-
this.selection.selectFromLastSelectedItem();
249+
this.selection.selectFromPrevSelectedItem();
253250
}
254251
if (opts?.selectFromActive) {
255252
this.selection.selectFromActive();

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export class OptionPattern {
5757
listbox: Signal<ListboxPattern>;
5858

5959
/** The tabindex of the option. */
60-
tabindex: Signal<-1 | 0>;
60+
tabindex = computed(() => this.listbox().focusManager.getItemTabindex(this));
6161

6262
/** The html element that should receive focus. */
6363
element: Signal<HTMLElement>;
@@ -68,6 +68,5 @@ export class OptionPattern {
6868
this.element = args.element;
6969
this.disabled = args.disabled;
7070
this.searchTerm = args.searchTerm;
71-
this.tabindex = this.listbox().focusManager.getItemTabindex(this);
7271
}
7372
}

0 commit comments

Comments
 (0)