Skip to content

Commit 85568f4

Browse files
committed
fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern
1 parent 722b81b commit 85568f4

File tree

13 files changed

+129
-102
lines changed

13 files changed

+129
-102
lines changed

src/cdk-experimental/listbox/listbox.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,14 @@ import {toSignal} from '@angular/core/rxjs-interop';
5151
})
5252
export class CdkListbox {
5353
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
54-
private _dir = inject(Directionality);
54+
private _directionality = inject(Directionality);
5555

5656
/** The CdkOptions nested inside of the CdkListbox. */
5757
private _cdkOptions = contentChildren(CdkOption, {descendants: true});
5858

5959
/** A signal wrapper for directionality. */
60-
protected directionality = toSignal(this._dir.change, {
61-
initialValue: this._dir.value,
60+
protected textDirection = toSignal(this._directionality.change, {
61+
initialValue: this._directionality.value,
6262
});
6363

6464
/** The Option UIPatterns of the child CdkOptions. */
@@ -88,6 +88,7 @@ export class CdkListbox {
8888
/** Whether the listbox is disabled. */
8989
disabled = input(false, {transform: booleanAttribute});
9090

91+
// TODO(wagnermaciel): Figure out how we want to expose control over the current listbox value.
9192
/** The ids of the current selected items. */
9293
selectedIds = model<string[]>([]);
9394

@@ -98,11 +99,11 @@ export class CdkListbox {
9899
pattern: ListboxPattern = new ListboxPattern({
99100
...this,
100101
items: this.items,
101-
directionality: this.directionality,
102+
textDirection: this.textDirection,
102103
});
103104
}
104105

105-
// TODO(wagnermaciel): Figure out how we actually want to do this.
106+
// TODO(wagnermaciel): Figure out how we want to generate IDs.
106107
let count = 0;
107108

108109
/** A selectable option in a CdkListbox. */
@@ -124,6 +125,7 @@ export class CdkOption {
124125
/** The parent CdkListbox. */
125126
private _cdkListbox = inject(CdkListbox);
126127

128+
// TODO(wagnermaciel): Figure out how we want to generate IDs.
127129
/** A unique identifier for the option. */
128130
protected id = computed(() => `${count++}`);
129131

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe('List Focus', () => {
3636
wrap: signal(false),
3737
activeIndex: signal(0),
3838
skipDisabled: signal(false),
39-
directionality: signal('ltr'),
39+
textDirection: signal('ltr'),
4040
orientation: signal('vertical'),
4141
...args,
4242
});
@@ -62,12 +62,11 @@ describe('List Focus', () => {
6262
expect(tabindex()).toBe(-1);
6363
});
6464

65-
it('should set the activedescendant to null', () => {
65+
it('should set the activedescendant to undefined', () => {
6666
const items = getItems(5);
6767
const nav = getNavigation(items);
6868
const focus = getFocus(nav);
69-
const activeId = focus.getActiveDescendant();
70-
expect(activeId()).toBeNull();
69+
expect(focus.getActiveDescendant()).toBeUndefined();
7170
});
7271

7372
it('should set the first items tabindex to 0', () => {
@@ -122,8 +121,7 @@ describe('List Focus', () => {
122121
const focus = getFocus(nav, {
123122
focusMode: signal('activedescendant'),
124123
});
125-
const activeId = focus.getActiveDescendant();
126-
expect(activeId()).toBe(items()[0].id());
124+
expect(focus.getActiveDescendant()).toBe(items()[0].id());
127125
});
128126

129127
it('should set the tabindex of all items to -1', () => {
@@ -150,10 +148,9 @@ describe('List Focus', () => {
150148
const focus = getFocus(nav, {
151149
focusMode: signal('activedescendant'),
152150
});
153-
const activeId = focus.getActiveDescendant();
154151

155152
nav.next();
156-
expect(activeId()).toBe(items()[1].id());
153+
expect(focus.getActiveDescendant()).toBe(items()[1].id());
157154
});
158155
});
159156
});

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

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import {computed, Signal} from '@angular/core';
1010
import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation';
1111

12-
/** The required properties for focus items. */
12+
/** Represents an item in a collection, such as a listbox option, than may receive focus. */
1313
export interface ListFocusItem extends ListNavigationItem {
1414
/** A unique identifier for the item. */
1515
id: Signal<string>;
@@ -18,7 +18,7 @@ export interface ListFocusItem extends ListNavigationItem {
1818
element: Signal<HTMLElement>;
1919
}
2020

21-
/** The required inputs for list focus. */
21+
/** Represents the required inputs for a collection that contains focusable items. */
2222
export interface ListFocusInputs<T extends ListFocusItem> {
2323
/** The focus strategy used by the list. */
2424
focusMode: Signal<'roving' | 'activedescendant'>;
@@ -29,20 +29,18 @@ export class ListFocus<T extends ListFocusItem> {
2929
/** The navigation controller of the parent list. */
3030
navigation: ListNavigation<ListFocusItem>;
3131

32+
/** The id of the current active item. */
33+
getActiveDescendant = computed<string | undefined>(() => {
34+
if (this.inputs.focusMode() === 'roving') {
35+
return undefined;
36+
}
37+
return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id();
38+
});
39+
3240
constructor(readonly inputs: ListFocusInputs<T> & {navigation: ListNavigation<T>}) {
3341
this.navigation = inputs.navigation;
3442
}
3543

36-
/** Returns the id of the current active item. */
37-
getActiveDescendant(): Signal<string | null> {
38-
return computed(() => {
39-
if (this.inputs.focusMode() === 'roving') {
40-
return null;
41-
}
42-
return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id();
43-
});
44-
}
45-
4644
/** Returns a signal that keeps track of the tabindex for the list. */
4745
getListTabindex(): Signal<-1 | 0> {
4846
return computed(() => (this.inputs.focusMode() === 'activedescendant' ? 0 : -1));

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe('List Navigation', () => {
3232
wrap: signal(false),
3333
activeIndex: signal(0),
3434
skipDisabled: signal(false),
35-
directionality: signal('ltr'),
35+
textDirection: signal('ltr'),
3636
orientation: signal('vertical'),
3737
...args,
3838
});

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

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88

99
import {signal, Signal, WritableSignal} from '@angular/core';
1010

11-
/** The required properties for navigation items. */
11+
/** Represents an item in a collection, such as a listbox option, than can be navigated to. */
1212
export interface ListNavigationItem {
1313
/** Whether an item is disabled. */
1414
disabled: Signal<boolean>;
1515
}
1616

17-
/** The required inputs for list navigation. */
17+
/** Represents the required inputs for a collection that has navigable items. */
1818
export interface ListNavigationInputs<T extends ListNavigationItem> {
1919
/** Whether focus should wrap when navigating. */
2020
wrap: Signal<boolean>;
@@ -32,7 +32,7 @@ export interface ListNavigationInputs<T extends ListNavigationItem> {
3232
orientation: Signal<'vertical' | 'horizontal'>;
3333

3434
/** The direction that text is read based on the users locale. */
35-
directionality: Signal<'rtl' | 'ltr'>;
35+
textDirection: Signal<'rtl' | 'ltr'>;
3636
}
3737

3838
/** Controls navigation for a list of items. */
@@ -56,26 +56,42 @@ 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 after = items.slice(this.inputs.activeIndex() + 1);
60-
const before = items.slice(0, this.inputs.activeIndex());
61-
const array = this.inputs.wrap() ? after.concat(before) : after;
62-
const item = array.find(i => this.isFocusable(i));
6359

64-
if (item) {
65-
this.goto(item);
60+
for (let i = this.inputs.activeIndex() + 1; i < items.length; i++) {
61+
if (this.isFocusable(items[i])) {
62+
this.goto(items[i]);
63+
return;
64+
}
65+
}
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+
}
6674
}
6775
}
6876

6977
/** Navigates to the previous item in the list. */
7078
prev() {
7179
const items = this.inputs.items();
72-
const after = items.slice(this.inputs.activeIndex() + 1).reverse();
73-
const before = items.slice(0, this.inputs.activeIndex()).reverse();
74-
const array = this.inputs.wrap() ? before.concat(after) : before;
75-
const item = array.find(i => this.isFocusable(i));
7680

77-
if (item) {
78-
this.goto(item);
81+
for (let i = this.inputs.activeIndex() - 1; i >= 0; i--) {
82+
if (this.isFocusable(items[i])) {
83+
this.goto(items[i]);
84+
return;
85+
}
86+
}
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+
}
7995
}
8096
}
8197

@@ -90,10 +106,12 @@ export class ListNavigation<T extends ListNavigationItem> {
90106

91107
/** Navigates to the last item in the list. */
92108
last() {
93-
const item = [...this.inputs.items()].reverse().find(i => this.isFocusable(i));
94-
95-
if (item) {
96-
this.goto(item);
109+
const items = this.inputs.items();
110+
for (let i = items.length - 1; i >= 0; i--) {
111+
if (this.isFocusable(items[i])) {
112+
this.goto(items[i]);
113+
return;
114+
}
97115
}
98116
}
99117

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('List Selection', () => {
3535
wrap: signal(false),
3636
activeIndex: signal(0),
3737
skipDisabled: signal(false),
38-
directionality: signal('ltr'),
38+
textDirection: signal('ltr'),
3939
orientation: signal('vertical'),
4040
...args,
4141
});
@@ -193,7 +193,7 @@ describe('List Selection', () => {
193193
selection.select(); // [0]
194194
nav.next();
195195
nav.next();
196-
selection.selectFromAnchor(); // [0, 1, 2]
196+
selection.selectFromLastSelectedItem(); // [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.selectFromAnchor(); // [3, 1, 2]
211+
selection.selectFromLastSelectedItem(); // [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: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import {signal, Signal, WritableSignal} from '@angular/core';
1010
import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation';
1111

12-
/** The required properties for selection items. */
12+
/** Represents an item in a collection, such as a listbox option, than can be selected. */
1313
export interface ListSelectionItem extends ListNavigationItem {
1414
/** A unique identifier for the item. */
1515
id: Signal<string>;
@@ -18,7 +18,7 @@ export interface ListSelectionItem extends ListNavigationItem {
1818
disabled: Signal<boolean>;
1919
}
2020

21-
/** The required inputs for list selection. */
21+
/** Represents the required inputs for a collection that contains selectable items. */
2222
export interface ListSelectionInputs<T extends ListSelectionItem> {
2323
/** The items in the list. */
2424
items: Signal<T[]>;
@@ -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 previous selected item. */
39-
anchorId = signal<string | null>(null);
38+
/** The id of the last selected item. */
39+
lastSelectedId = 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-
selectFromAnchor() {
108-
const anchorIndex = this.inputs.items().findIndex(i => this.anchorId() === i.id());
109-
this._selectFromIndex(anchorIndex);
107+
selectFromLastSelectedItem() {
108+
const lastSelectedId = this.inputs.items().findIndex(i => this.lastSelectedId() === i.id());
109+
this._selectFromIndex(lastSelectedId);
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.anchorId.set(item.id());
140+
this.lastSelectedId.set(item.id());
141141
}
142142
}

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('List Typeahead', () => {
3535
activeIndex,
3636
wrap: signal(false),
3737
skipDisabled: signal(false),
38-
directionality: signal('ltr'),
38+
textDirection: signal('ltr'),
3939
orientation: signal('vertical'),
4040
});
4141
const typeahead = new ListTypeahead({
@@ -62,7 +62,7 @@ describe('List Typeahead', () => {
6262
activeIndex,
6363
wrap: signal(false),
6464
skipDisabled: signal(false),
65-
directionality: signal('ltr'),
65+
textDirection: signal('ltr'),
6666
orientation: signal('vertical'),
6767
});
6868
const typeahead = new ListTypeahead({
@@ -87,7 +87,7 @@ describe('List Typeahead', () => {
8787
activeIndex,
8888
wrap: signal(false),
8989
skipDisabled: signal(true),
90-
directionality: signal('ltr'),
90+
textDirection: signal('ltr'),
9191
orientation: signal('vertical'),
9292
});
9393
const typeahead = new ListTypeahead({
@@ -108,7 +108,7 @@ describe('List Typeahead', () => {
108108
activeIndex,
109109
wrap: signal(false),
110110
skipDisabled: signal(false),
111-
directionality: signal('ltr'),
111+
textDirection: signal('ltr'),
112112
orientation: signal('vertical'),
113113
});
114114
const typeahead = new ListTypeahead({
@@ -129,7 +129,7 @@ describe('List Typeahead', () => {
129129
activeIndex,
130130
wrap: signal(false),
131131
skipDisabled: signal(false),
132-
directionality: signal('ltr'),
132+
textDirection: signal('ltr'),
133133
orientation: signal('vertical'),
134134
});
135135
const typeahead = new ListTypeahead({

0 commit comments

Comments
 (0)