Skip to content

Commit 0a46b92

Browse files
authored
refactor(cdk-experimental/ui-patterns): track list selection by value (#30733)
* refactor(cdk-experimental/ui-patterns): track list selection by value * Switch to using values instead of ids for tracking selected items in a list * fixup! refactor(cdk-experimental/ui-patterns): track list selection by value * fixup! refactor(cdk-experimental/ui-patterns): track list selection by value * fixup! refactor(cdk-experimental/ui-patterns): track list selection by value
1 parent 4bf3591 commit 0a46b92

File tree

7 files changed

+95
-87
lines changed

7 files changed

+95
-87
lines changed

src/cdk-experimental/listbox/listbox.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import {_IdGenerator} from '@angular/cdk/a11y';
5050
'(pointerdown)': 'pattern.onPointerdown($event)',
5151
},
5252
})
53-
export class CdkListbox {
53+
export class CdkListbox<V> {
5454
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
5555
private readonly _directionality = inject(Directionality);
5656

@@ -89,15 +89,14 @@ export class CdkListbox {
8989
/** Whether the listbox is disabled. */
9090
disabled = input(false, {transform: booleanAttribute});
9191

92-
// TODO(wagnermaciel): Figure out how we want to expose control over the current listbox value.
93-
/** The ids of the current selected items. */
94-
selectedIds = model<string[]>([]);
92+
/** The values of the current selected items. */
93+
value = model<V[]>([]);
9594

9695
/** The current index that has been navigated to. */
9796
activeIndex = model<number>(0);
9897

9998
/** The Listbox UIPattern. */
100-
pattern: ListboxPattern = new ListboxPattern({
99+
pattern: ListboxPattern<V> = new ListboxPattern<V>({
101100
...this,
102101
items: this.items,
103102
textDirection: this.textDirection,
@@ -116,7 +115,7 @@ export class CdkListbox {
116115
'[attr.aria-disabled]': 'pattern.disabled()',
117116
},
118117
})
119-
export class CdkOption {
118+
export class CdkOption<V> {
120119
/** A reference to the option element. */
121120
private readonly _elementRef = inject(ElementRef);
122121

@@ -130,6 +129,8 @@ export class CdkOption {
130129
/** A unique identifier for the option. */
131130
protected id = computed(() => this._generatedId);
132131

132+
protected value = input.required<V>();
133+
133134
// TODO(wagnermaciel): See if we want to change how we handle this since textContent is not
134135
// reactive. See https://github.com/angular/components/pull/30495#discussion_r1961260216.
135136
/** The text used by the typeahead search. */
@@ -148,9 +149,10 @@ export class CdkOption {
148149
label = input<string>();
149150

150151
/** The Option UIPattern. */
151-
pattern = new OptionPattern({
152+
pattern = new OptionPattern<V>({
152153
...this,
153154
id: this.id,
155+
value: this.value,
154156
listbox: this.listbox,
155157
element: this.element,
156158
searchTerm: this.searchTerm,

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ export class ListFocus<T extends ListFocusItem> {
3838
if (this.inputs.focusMode() === 'roving') {
3939
return undefined;
4040
}
41-
return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id();
41+
if (this.navigation.inputs.items().length) {
42+
return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id();
43+
}
44+
return undefined;
4245
}
4346

4447
/** The tabindex for the list. */

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

Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,22 @@ import {ListSelectionItem, ListSelection, ListSelectionInputs} from './list-sele
1212
import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation';
1313

1414
describe('List Selection', () => {
15-
interface TestItem extends ListSelectionItem {
15+
interface TestItem<V> extends ListSelectionItem<V> {
1616
disabled: WritableSignalLike<boolean>;
1717
}
1818

19-
function getItems(length: number): SignalLike<TestItem[]> {
19+
function getItems<V>(values: V[]): SignalLike<TestItem<V>[]> {
2020
return signal(
21-
Array.from({length}).map((_, i) => ({
21+
values.map((value, i) => ({
2222
index: signal(i),
23-
id: signal(`${i}`),
23+
value: signal(value),
2424
disabled: signal(false),
2525
isAnchor: signal(false),
2626
})),
2727
);
2828
}
2929

30-
function getNavigation<T extends TestItem>(
30+
function getNavigation<T extends TestItem<V>, V>(
3131
items: SignalLike<T[]>,
3232
args: Partial<ListNavigationInputs<T>> = {},
3333
): ListNavigation<T> {
@@ -42,15 +42,15 @@ describe('List Selection', () => {
4242
});
4343
}
4444

45-
function getSelection<T extends TestItem>(
45+
function getSelection<T extends TestItem<V>, V>(
4646
items: SignalLike<T[]>,
4747
navigation: ListNavigation<T>,
48-
args: Partial<ListSelectionInputs<T>> = {},
49-
): ListSelection<T> {
48+
args: Partial<ListSelectionInputs<T, V>> = {},
49+
): ListSelection<T, V> {
5050
return new ListSelection({
5151
items,
5252
navigation,
53-
selectedIds: signal([]),
53+
value: signal<V[]>([]),
5454
multiselectable: signal(true),
5555
selectionMode: signal('explicit'),
5656
...args,
@@ -59,28 +59,28 @@ describe('List Selection', () => {
5959

6060
describe('#select', () => {
6161
it('should select an item', () => {
62-
const items = getItems(5);
62+
const items = getItems([0, 1, 2, 3, 4]);
6363
const nav = getNavigation(items);
6464
const selection = getSelection(items, nav);
6565

6666
selection.select(); // [0]
67-
expect(selection.inputs.selectedIds()).toEqual(['0']);
67+
expect(selection.inputs.value()).toEqual([0]);
6868
});
6969

7070
it('should select multiple options', () => {
71-
const items = getItems(5);
71+
const items = getItems([0, 1, 2, 3, 4]);
7272
const nav = getNavigation(items);
7373
const selection = getSelection(items, nav);
7474

7575
selection.select(); // [0]
7676
nav.next();
7777
selection.select(); // [0, 1]
7878

79-
expect(selection.inputs.selectedIds()).toEqual(['0', '1']);
79+
expect(selection.inputs.value()).toEqual([0, 1]);
8080
});
8181

8282
it('should not select multiple options', () => {
83-
const items = getItems(5);
83+
const items = getItems([0, 1, 2, 3, 4]);
8484
const nav = getNavigation(items);
8585
const selection = getSelection(items, nav, {
8686
multiselectable: signal(false),
@@ -90,104 +90,104 @@ describe('List Selection', () => {
9090
nav.next();
9191
selection.select(); // [1]
9292

93-
expect(selection.inputs.selectedIds()).toEqual(['1']);
93+
expect(selection.inputs.value()).toEqual([1]);
9494
});
9595

9696
it('should not select disabled items', () => {
97-
const items = getItems(5);
97+
const items = getItems([0, 1, 2, 3, 4]);
9898
const nav = getNavigation(items);
9999
const selection = getSelection(items, nav);
100100
items()[0].disabled.set(true);
101101

102102
selection.select(); // []
103-
expect(selection.inputs.selectedIds()).toEqual([]);
103+
expect(selection.inputs.value()).toEqual([]);
104104
});
105105

106106
it('should do nothing to already selected items', () => {
107-
const items = getItems(5);
107+
const items = getItems([0, 1, 2, 3, 4]);
108108
const nav = getNavigation(items);
109109
const selection = getSelection(items, nav);
110110

111111
selection.select(); // [0]
112112
selection.select(); // [0]
113113

114-
expect(selection.inputs.selectedIds()).toEqual(['0']);
114+
expect(selection.inputs.value()).toEqual([0]);
115115
});
116116
});
117117

118118
describe('#deselect', () => {
119119
it('should deselect an item', () => {
120-
const items = getItems(5);
120+
const items = getItems([0, 1, 2, 3, 4]);
121121
const nav = getNavigation(items);
122122
const selection = getSelection(items, nav);
123123
selection.deselect(); // []
124-
expect(selection.inputs.selectedIds().length).toBe(0);
124+
expect(selection.inputs.value().length).toBe(0);
125125
});
126126

127127
it('should not deselect disabled items', () => {
128-
const items = getItems(5);
128+
const items = getItems([0, 1, 2, 3, 4]);
129129
const nav = getNavigation(items);
130130
const selection = getSelection(items, nav);
131131

132132
selection.select(); // [0]
133133
items()[0].disabled.set(true);
134134
selection.deselect(); // [0]
135135

136-
expect(selection.inputs.selectedIds()).toEqual(['0']);
136+
expect(selection.inputs.value()).toEqual([0]);
137137
});
138138
});
139139

140140
describe('#toggle', () => {
141141
it('should select an unselected item', () => {
142-
const items = getItems(5);
142+
const items = getItems([0, 1, 2, 3, 4]);
143143
const nav = getNavigation(items);
144144
const selection = getSelection(items, nav);
145145

146146
selection.toggle(); // [0]
147-
expect(selection.inputs.selectedIds()).toEqual(['0']);
147+
expect(selection.inputs.value()).toEqual([0]);
148148
});
149149

150150
it('should deselect a selected item', () => {
151-
const items = getItems(5);
151+
const items = getItems([0, 1, 2, 3, 4]);
152152
const nav = getNavigation(items);
153153
const selection = getSelection(items, nav);
154154
selection.select(); // [0]
155155
selection.toggle(); // []
156-
expect(selection.inputs.selectedIds().length).toBe(0);
156+
expect(selection.inputs.value().length).toBe(0);
157157
});
158158
});
159159

160160
describe('#selectAll', () => {
161161
it('should select all items', () => {
162-
const items = getItems(5);
162+
const items = getItems([0, 1, 2, 3, 4]);
163163
const nav = getNavigation(items);
164164
const selection = getSelection(items, nav);
165165
selection.selectAll();
166-
expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']);
166+
expect(selection.inputs.value()).toEqual([0, 1, 2, 3, 4]);
167167
});
168168

169169
it('should do nothing if a list is not multiselectable', () => {
170-
const items = getItems(5);
170+
const items = getItems([0, 1, 2, 3, 4]);
171171
const nav = getNavigation(items);
172172
const selection = getSelection(items, nav);
173173
selection.selectAll();
174-
expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']);
174+
expect(selection.inputs.value()).toEqual([0, 1, 2, 3, 4]);
175175
});
176176
});
177177

178178
describe('#deselectAll', () => {
179179
it('should deselect all items', () => {
180-
const items = getItems(5);
180+
const items = getItems([0, 1, 2, 3, 4]);
181181
const nav = getNavigation(items);
182182
const selection = getSelection(items, nav);
183183
selection.deselectAll(); // []
184-
expect(selection.inputs.selectedIds().length).toBe(0);
184+
expect(selection.inputs.value().length).toBe(0);
185185
});
186186
});
187187

188188
describe('#selectFromAnchor', () => {
189189
it('should select all items from an anchor at a lower index', () => {
190-
const items = getItems(5);
190+
const items = getItems([0, 1, 2, 3, 4]);
191191
const nav = getNavigation(items);
192192
const selection = getSelection(items, nav);
193193

@@ -196,11 +196,11 @@ describe('List Selection', () => {
196196
nav.next();
197197
selection.selectFromPrevSelectedItem(); // [0, 1, 2]
198198

199-
expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2']);
199+
expect(selection.inputs.value()).toEqual([0, 1, 2]);
200200
});
201201

202202
it('should select all items from an anchor at a higher index', () => {
203-
const items = getItems(5);
203+
const items = getItems([0, 1, 2, 3, 4]);
204204
const nav = getNavigation(items, {
205205
activeIndex: signal(3),
206206
});
@@ -211,7 +211,8 @@ describe('List Selection', () => {
211211
nav.prev();
212212
selection.selectFromPrevSelectedItem(); // [3, 1, 2]
213213

214-
expect(selection.inputs.selectedIds()).toEqual(['3', '1', '2']);
214+
// TODO(wagnermaciel): Order the values when inserting them.
215+
expect(selection.inputs.value()).toEqual([3, 1, 2]);
215216
});
216217
});
217218
});

0 commit comments

Comments
 (0)