Skip to content

refactor(cdk-experimental/ui-patterns): track list selection by value #30733

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 4 commits into from
Mar 31, 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
16 changes: 9 additions & 7 deletions src/cdk-experimental/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {_IdGenerator} from '@angular/cdk/a11y';
'(pointerdown)': 'pattern.onPointerdown($event)',
},
})
export class CdkListbox {
export class CdkListbox<V> {
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
private readonly _directionality = inject(Directionality);

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

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

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

/** The Listbox UIPattern. */
pattern: ListboxPattern = new ListboxPattern({
pattern: ListboxPattern<V> = new ListboxPattern<V>({
...this,
items: this.items,
textDirection: this.textDirection,
Expand All @@ -116,7 +115,7 @@ export class CdkListbox {
'[attr.aria-disabled]': 'pattern.disabled()',
},
})
export class CdkOption {
export class CdkOption<V> {
/** A reference to the option element. */
private readonly _elementRef = inject(ElementRef);

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

protected value = input.required<V>();

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

/** The Option UIPattern. */
pattern = new OptionPattern({
pattern = new OptionPattern<V>({
...this,
id: this.id,
value: this.value,
listbox: this.listbox,
element: this.element,
searchTerm: this.searchTerm,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ export class ListFocus<T extends ListFocusItem> {
if (this.inputs.focusMode() === 'roving') {
return undefined;
}
return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id();
if (this.navigation.inputs.items().length) {
return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id();
}
return undefined;
}

/** The tabindex for the list. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,22 @@ import {ListSelectionItem, ListSelection, ListSelectionInputs} from './list-sele
import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation';

describe('List Selection', () => {
interface TestItem extends ListSelectionItem {
interface TestItem<V> extends ListSelectionItem<V> {
disabled: WritableSignalLike<boolean>;
}

function getItems(length: number): SignalLike<TestItem[]> {
function getItems<V>(values: V[]): SignalLike<TestItem<V>[]> {
return signal(
Array.from({length}).map((_, i) => ({
values.map((value, i) => ({
index: signal(i),
id: signal(`${i}`),
value: signal(value),
disabled: signal(false),
isAnchor: signal(false),
})),
);
}

function getNavigation<T extends TestItem>(
function getNavigation<T extends TestItem<V>, V>(
items: SignalLike<T[]>,
args: Partial<ListNavigationInputs<T>> = {},
): ListNavigation<T> {
Expand All @@ -42,15 +42,15 @@ describe('List Selection', () => {
});
}

function getSelection<T extends TestItem>(
function getSelection<T extends TestItem<V>, V>(
items: SignalLike<T[]>,
navigation: ListNavigation<T>,
args: Partial<ListSelectionInputs<T>> = {},
): ListSelection<T> {
args: Partial<ListSelectionInputs<T, V>> = {},
): ListSelection<T, V> {
return new ListSelection({
items,
navigation,
selectedIds: signal([]),
value: signal<V[]>([]),
multiselectable: signal(true),
selectionMode: signal('explicit'),
...args,
Expand All @@ -59,28 +59,28 @@ describe('List Selection', () => {

describe('#select', () => {
it('should select an item', () => {
const items = getItems(5);
const items = getItems([0, 1, 2, 3, 4]);
const nav = getNavigation(items);
const selection = getSelection(items, nav);

selection.select(); // [0]
expect(selection.inputs.selectedIds()).toEqual(['0']);
expect(selection.inputs.value()).toEqual([0]);
});

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

selection.select(); // [0]
nav.next();
selection.select(); // [0, 1]

expect(selection.inputs.selectedIds()).toEqual(['0', '1']);
expect(selection.inputs.value()).toEqual([0, 1]);
});

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

expect(selection.inputs.selectedIds()).toEqual(['1']);
expect(selection.inputs.value()).toEqual([1]);
});

it('should not select disabled items', () => {
const items = getItems(5);
const items = getItems([0, 1, 2, 3, 4]);
const nav = getNavigation(items);
const selection = getSelection(items, nav);
items()[0].disabled.set(true);

selection.select(); // []
expect(selection.inputs.selectedIds()).toEqual([]);
expect(selection.inputs.value()).toEqual([]);
});

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

selection.select(); // [0]
selection.select(); // [0]

expect(selection.inputs.selectedIds()).toEqual(['0']);
expect(selection.inputs.value()).toEqual([0]);
});
});

describe('#deselect', () => {
it('should deselect an item', () => {
const items = getItems(5);
const items = getItems([0, 1, 2, 3, 4]);
const nav = getNavigation(items);
const selection = getSelection(items, nav);
selection.deselect(); // []
expect(selection.inputs.selectedIds().length).toBe(0);
expect(selection.inputs.value().length).toBe(0);
});

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

selection.select(); // [0]
items()[0].disabled.set(true);
selection.deselect(); // [0]

expect(selection.inputs.selectedIds()).toEqual(['0']);
expect(selection.inputs.value()).toEqual([0]);
});
});

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

selection.toggle(); // [0]
expect(selection.inputs.selectedIds()).toEqual(['0']);
expect(selection.inputs.value()).toEqual([0]);
});

it('should deselect a selected item', () => {
const items = getItems(5);
const items = getItems([0, 1, 2, 3, 4]);
const nav = getNavigation(items);
const selection = getSelection(items, nav);
selection.select(); // [0]
selection.toggle(); // []
expect(selection.inputs.selectedIds().length).toBe(0);
expect(selection.inputs.value().length).toBe(0);
});
});

describe('#selectAll', () => {
it('should select all items', () => {
const items = getItems(5);
const items = getItems([0, 1, 2, 3, 4]);
const nav = getNavigation(items);
const selection = getSelection(items, nav);
selection.selectAll();
expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']);
expect(selection.inputs.value()).toEqual([0, 1, 2, 3, 4]);
});

it('should do nothing if a list is not multiselectable', () => {
const items = getItems(5);
const items = getItems([0, 1, 2, 3, 4]);
const nav = getNavigation(items);
const selection = getSelection(items, nav);
selection.selectAll();
expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']);
expect(selection.inputs.value()).toEqual([0, 1, 2, 3, 4]);
});
});

describe('#deselectAll', () => {
it('should deselect all items', () => {
const items = getItems(5);
const items = getItems([0, 1, 2, 3, 4]);
const nav = getNavigation(items);
const selection = getSelection(items, nav);
selection.deselectAll(); // []
expect(selection.inputs.selectedIds().length).toBe(0);
expect(selection.inputs.value().length).toBe(0);
});
});

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

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

expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2']);
expect(selection.inputs.value()).toEqual([0, 1, 2]);
});

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

expect(selection.inputs.selectedIds()).toEqual(['3', '1', '2']);
// TODO(wagnermaciel): Order the values when inserting them.
expect(selection.inputs.value()).toEqual([3, 1, 2]);
});
});
});
Loading
Loading