From 9a44351aee1d8f246d007b35e0587c4acd2e155a Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Thu, 27 Mar 2025 14:08:24 -0400 Subject: [PATCH 1/4] refactor(cdk-experimental/ui-patterns): track list selection by value * Switch to using values instead of ids for tracking selected items in a list --- src/cdk-experimental/listbox/listbox.ts | 14 ++-- .../behaviors/list-focus/list-focus.ts | 8 +- .../list-selection/list-selection.spec.ts | 75 ++++++++++--------- .../list-selection/list-selection.ts | 36 ++++----- .../ui-patterns/listbox/listbox.ts | 18 ++--- .../ui-patterns/listbox/option.ts | 26 ++++--- .../cdk-listbox/cdk-listbox-example.html | 6 +- 7 files changed, 95 insertions(+), 88 deletions(-) diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index c24b05876185..328e581511db 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -50,7 +50,7 @@ import {_IdGenerator} from '@angular/cdk/a11y'; '(pointerdown)': 'pattern.onPointerdown($event)', }, }) -export class CdkListbox { +export class CdkListbox { /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ private readonly _directionality = inject(Directionality); @@ -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([]); + values = model([]); /** The current index that has been navigated to. */ activeIndex = model(0); /** The Listbox UIPattern. */ - pattern: ListboxPattern = new ListboxPattern({ + pattern: ListboxPattern = new ListboxPattern({ ...this, items: this.items, textDirection: this.textDirection, @@ -116,7 +115,7 @@ export class CdkListbox { '[attr.aria-disabled]': 'pattern.disabled()', }, }) -export class CdkOption { +export class CdkOption { /** A reference to the option element. */ private readonly _elementRef = inject(ElementRef); @@ -130,6 +129,8 @@ export class CdkOption { /** A unique identifier for the option. */ protected id = computed(() => this._generatedId); + protected value = input.required(); + // 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. */ @@ -148,9 +149,10 @@ export class CdkOption { label = input(); /** The Option UIPattern. */ - pattern = new OptionPattern({ + pattern = new OptionPattern({ ...this, id: this.id, + value: this.value, listbox: this.listbox, element: this.element, searchTerm: this.searchTerm, diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts index 96514ce71b09..9ca1927bb1b0 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -34,11 +34,13 @@ export class ListFocus { } /** The id of the current active item. */ - getActiveDescendant(): string | undefined { + getActiveDescendant(): string | void { if (this.inputs.focusMode() === 'roving') { - return undefined; + return; + } + if (this.navigation.inputs.items().length) { + return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id(); } - return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id(); } /** The tabindex for the list. */ diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts index 86a955992a47..931d48878537 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts @@ -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 extends ListSelectionItem { disabled: WritableSignalLike; } - function getItems(length: number): SignalLike { + function getItems(values: V[]): SignalLike[]> { 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( + function getNavigation, V>( items: SignalLike, args: Partial> = {}, ): ListNavigation { @@ -42,15 +42,15 @@ describe('List Selection', () => { }); } - function getSelection( + function getSelection, V>( items: SignalLike, navigation: ListNavigation, - args: Partial> = {}, - ): ListSelection { + args: Partial> = {}, + ): ListSelection { return new ListSelection({ items, navigation, - selectedIds: signal([]), + values: signal([]), multiselectable: signal(true), selectionMode: signal('explicit'), ...args, @@ -59,16 +59,16 @@ 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.values()).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); @@ -76,11 +76,11 @@ describe('List Selection', () => { nav.next(); selection.select(); // [0, 1] - expect(selection.inputs.selectedIds()).toEqual(['0', '1']); + expect(selection.inputs.values()).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), @@ -90,42 +90,42 @@ describe('List Selection', () => { nav.next(); selection.select(); // [1] - expect(selection.inputs.selectedIds()).toEqual(['1']); + expect(selection.inputs.values()).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.values()).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.values()).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.values().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); @@ -133,61 +133,61 @@ describe('List Selection', () => { items()[0].disabled.set(true); selection.deselect(); // [0] - expect(selection.inputs.selectedIds()).toEqual(['0']); + expect(selection.inputs.values()).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.values()).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.values().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.values()).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.values()).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.values().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); @@ -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.values()).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), }); @@ -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.values()).toEqual([3, 1, 2]); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts index ce8280d0e453..e8e3802175df 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -11,38 +11,38 @@ import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; /** Represents an item in a collection, such as a listbox option, than can be selected. */ -export interface ListSelectionItem extends ListNavigationItem { - /** A unique identifier for the item. */ - id: SignalLike; +export interface ListSelectionItem extends ListNavigationItem { + /** The value of the item. */ + value: SignalLike; /** Whether an item is disabled. */ disabled: SignalLike; } /** Represents the required inputs for a collection that contains selectable items. */ -export interface ListSelectionInputs { +export interface ListSelectionInputs, V> { /** The items in the list. */ items: SignalLike; /** Whether multiple items in the list can be selected at once. */ multiselectable: SignalLike; - /** The ids of the current selected items. */ - selectedIds: WritableSignalLike; + /** The values of the current selected items. */ + values: WritableSignalLike; /** The selection strategy used by the list. */ selectionMode: SignalLike<'follow' | 'explicit'>; } /** Controls selection for a list of items. */ -export class ListSelection { - /** The id of the most recently selected item. */ - previousSelectedId = signal(undefined); +export class ListSelection, V> { + /** The value of the most recently selected item. */ + previousValue = signal(undefined); /** The navigation controller of the parent list. */ navigation: ListNavigation; - constructor(readonly inputs: ListSelectionInputs & {navigation: ListNavigation}) { + constructor(readonly inputs: ListSelectionInputs & {navigation: ListNavigation}) { this.navigation = inputs.navigation; } @@ -50,7 +50,7 @@ export class ListSelection { select(item?: T) { item = item ?? this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; - if (item.disabled() || this.inputs.selectedIds().includes(item.id())) { + if (item.disabled() || this.inputs.values().includes(item.value())) { return; } @@ -60,7 +60,7 @@ export class ListSelection { // TODO: Need to discuss when to drop this. this._anchor(); - this.inputs.selectedIds.update(ids => ids.concat(item.id())); + this.inputs.values.update(values => values.concat(item.value())); } /** Deselects the item at the current active index. */ @@ -68,20 +68,20 @@ export class ListSelection { item = item ?? this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; if (!item.disabled()) { - this.inputs.selectedIds.update(ids => ids.filter(id => id !== item.id())); + this.inputs.values.update(values => values.filter(value => value !== item.value())); } } /** Toggles the item at the current active index. */ toggle() { const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; - this.inputs.selectedIds().includes(item.id()) ? this.deselect() : this.select(); + this.inputs.values().includes(item.value()) ? this.deselect() : this.select(); } /** Toggles only the item at the current active index. */ toggleOne() { const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; - this.inputs.selectedIds().includes(item.id()) ? this.deselect() : this.selectOne(); + this.inputs.values().includes(item.value()) ? this.deselect() : this.selectOne(); } /** Selects all items in the list. */ @@ -106,8 +106,8 @@ export class ListSelection { /** Selects the items in the list starting at the last selected item. */ selectFromPrevSelectedItem() { - const prevSelectedId = this.inputs.items().findIndex(i => this.previousSelectedId() === i.id()); - this._selectFromIndex(prevSelectedId); + const previousValue = this.inputs.items().findIndex(i => this.previousValue() === i.value()); + this._selectFromIndex(previousValue); } /** Selects the items in the list starting at the last active item. */ @@ -138,6 +138,6 @@ export class ListSelection { /** Sets the anchor to the current active index. */ private _anchor() { const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; - this.previousSelectedId.set(item.id()); + this.previousValue.set(item.value()); } } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 09aeda3cf589..098aa5af2526 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -29,26 +29,26 @@ interface SelectOptions { } /** Represents the required inputs for a listbox. */ -export type ListboxInputs = ListNavigationInputs & - ListSelectionInputs & +export type ListboxInputs = ListNavigationInputs> & + ListSelectionInputs, V> & ListTypeaheadInputs & - ListFocusInputs & { + ListFocusInputs> & { disabled: SignalLike; }; /** Controls the state of a listbox. */ -export class ListboxPattern { +export class ListboxPattern { /** Controls navigation for the listbox. */ - navigation: ListNavigation; + navigation: ListNavigation>; /** Controls selection for the listbox. */ - selection: ListSelection; + selection: ListSelection, V>; /** Controls typeahead for the listbox. */ - typeahead: ListTypeahead; + typeahead: ListTypeahead>; /** Controls focus for the listbox. */ - focusManager: ListFocus; + focusManager: ListFocus>; /** Whether the list is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; @@ -159,7 +159,7 @@ export class ListboxPattern { return manager; }); - constructor(readonly inputs: ListboxInputs) { + constructor(readonly inputs: ListboxInputs) { this.disabled = inputs.disabled; this.orientation = inputs.orientation; this.multiselectable = inputs.multiselectable; diff --git a/src/cdk-experimental/ui-patterns/listbox/option.ts b/src/cdk-experimental/ui-patterns/listbox/option.ts index 913596d29e0d..ad244fa48e72 100644 --- a/src/cdk-experimental/ui-patterns/listbox/option.ts +++ b/src/cdk-experimental/ui-patterns/listbox/option.ts @@ -17,26 +17,29 @@ import {SignalLike} from '../behaviors/signal-like/signal-like'; * Represents the properties exposed by a listbox that need to be accessed by an option. * This exists to avoid circular dependency errors between the listbox and option. */ -interface ListboxPattern { - focusManager: ListFocus; - selection: ListSelection; - navigation: ListNavigation; +interface ListboxPattern { + focusManager: ListFocus>; + selection: ListSelection, V>; + navigation: ListNavigation>; } /** Represents the required inputs for an option in a listbox. */ -export interface OptionInputs +export interface OptionInputs extends ListNavigationItem, - ListSelectionItem, + ListSelectionItem, ListTypeaheadItem, ListFocusItem { - listbox: SignalLike; + listbox: SignalLike | undefined>; } /** Represents an option in a listbox. */ -export class OptionPattern { +export class OptionPattern { /** A unique identifier for the option. */ id: SignalLike; + /** The value of the option. */ + value: SignalLike; + /** The position of the option in the list. */ index = computed( () => @@ -46,7 +49,7 @@ export class OptionPattern { ); /** Whether the option is selected. */ - selected = computed(() => this.listbox()?.selection.inputs.selectedIds().includes(this.id())); + selected = computed(() => this.listbox()?.selection.inputs.values().includes(this.value())); /** Whether the option is disabled. */ disabled: SignalLike; @@ -55,7 +58,7 @@ export class OptionPattern { searchTerm: SignalLike; /** A reference to the parent listbox. */ - listbox: SignalLike; + listbox: SignalLike | undefined>; /** The tabindex of the option. */ tabindex = computed(() => this.listbox()?.focusManager.getItemTabindex(this)); @@ -63,8 +66,9 @@ export class OptionPattern { /** The html element that should receive focus. */ element: SignalLike; - constructor(args: OptionInputs) { + constructor(args: OptionInputs) { this.id = args.id; + this.value = args.value; this.listbox = args.listbox; this.element = args.element; this.disabled = args.disabled; diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html index 4bc60d28b3ec..a62ec10b26b4 100644 --- a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html @@ -44,10 +44,8 @@ @for (fruit of fruits; track fruit) { - @let checked = option.pattern.selected() ? 'checked' : 'unchecked'; - -
  • - +
  • + {{ fruit }}
  • } From 3906bb57beaec8a8506be8818aeeec6d36d1fc50 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Thu, 27 Mar 2025 14:31:15 -0400 Subject: [PATCH 2/4] fixup! refactor(cdk-experimental/ui-patterns): track list selection by value --- src/cdk-experimental/listbox/listbox.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index 328e581511db..b5970192a3e7 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -89,7 +89,7 @@ export class CdkListbox { /** Whether the listbox is disabled. */ disabled = input(false, {transform: booleanAttribute}); - /** The ids of the current selected items. */ + /** The values of the current selected items. */ values = model([]); /** The current index that has been navigated to. */ From 8cc9789aafaa5e9ba7a15472f737aed4cf3dbbbc Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 28 Mar 2025 08:56:15 -0400 Subject: [PATCH 3/4] fixup! refactor(cdk-experimental/ui-patterns): track list selection by value --- src/cdk-experimental/listbox/listbox.ts | 2 +- .../list-selection/list-selection.spec.ts | 30 +++++++++---------- .../list-selection/list-selection.ts | 14 ++++----- .../ui-patterns/listbox/option.ts | 2 +- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index b5970192a3e7..93e04eb19534 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -90,7 +90,7 @@ export class CdkListbox { disabled = input(false, {transform: booleanAttribute}); /** The values of the current selected items. */ - values = model([]); + value = model([]); /** The current index that has been navigated to. */ activeIndex = model(0); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts index 931d48878537..79c850a5a8af 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts @@ -50,7 +50,7 @@ describe('List Selection', () => { return new ListSelection({ items, navigation, - values: signal([]), + value: signal([]), multiselectable: signal(true), selectionMode: signal('explicit'), ...args, @@ -64,7 +64,7 @@ describe('List Selection', () => { const selection = getSelection(items, nav); selection.select(); // [0] - expect(selection.inputs.values()).toEqual([0]); + expect(selection.inputs.value()).toEqual([0]); }); it('should select multiple options', () => { @@ -76,7 +76,7 @@ describe('List Selection', () => { nav.next(); selection.select(); // [0, 1] - expect(selection.inputs.values()).toEqual([0, 1]); + expect(selection.inputs.value()).toEqual([0, 1]); }); it('should not select multiple options', () => { @@ -90,7 +90,7 @@ describe('List Selection', () => { nav.next(); selection.select(); // [1] - expect(selection.inputs.values()).toEqual([1]); + expect(selection.inputs.value()).toEqual([1]); }); it('should not select disabled items', () => { @@ -100,7 +100,7 @@ describe('List Selection', () => { items()[0].disabled.set(true); selection.select(); // [] - expect(selection.inputs.values()).toEqual([]); + expect(selection.inputs.value()).toEqual([]); }); it('should do nothing to already selected items', () => { @@ -111,7 +111,7 @@ describe('List Selection', () => { selection.select(); // [0] selection.select(); // [0] - expect(selection.inputs.values()).toEqual([0]); + expect(selection.inputs.value()).toEqual([0]); }); }); @@ -121,7 +121,7 @@ describe('List Selection', () => { const nav = getNavigation(items); const selection = getSelection(items, nav); selection.deselect(); // [] - expect(selection.inputs.values().length).toBe(0); + expect(selection.inputs.value().length).toBe(0); }); it('should not deselect disabled items', () => { @@ -133,7 +133,7 @@ describe('List Selection', () => { items()[0].disabled.set(true); selection.deselect(); // [0] - expect(selection.inputs.values()).toEqual([0]); + expect(selection.inputs.value()).toEqual([0]); }); }); @@ -144,7 +144,7 @@ describe('List Selection', () => { const selection = getSelection(items, nav); selection.toggle(); // [0] - expect(selection.inputs.values()).toEqual([0]); + expect(selection.inputs.value()).toEqual([0]); }); it('should deselect a selected item', () => { @@ -153,7 +153,7 @@ describe('List Selection', () => { const selection = getSelection(items, nav); selection.select(); // [0] selection.toggle(); // [] - expect(selection.inputs.values().length).toBe(0); + expect(selection.inputs.value().length).toBe(0); }); }); @@ -163,7 +163,7 @@ describe('List Selection', () => { const nav = getNavigation(items); const selection = getSelection(items, nav); selection.selectAll(); - expect(selection.inputs.values()).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', () => { @@ -171,7 +171,7 @@ describe('List Selection', () => { const nav = getNavigation(items); const selection = getSelection(items, nav); selection.selectAll(); - expect(selection.inputs.values()).toEqual([0, 1, 2, 3, 4]); + expect(selection.inputs.value()).toEqual([0, 1, 2, 3, 4]); }); }); @@ -181,7 +181,7 @@ describe('List Selection', () => { const nav = getNavigation(items); const selection = getSelection(items, nav); selection.deselectAll(); // [] - expect(selection.inputs.values().length).toBe(0); + expect(selection.inputs.value().length).toBe(0); }); }); @@ -196,7 +196,7 @@ describe('List Selection', () => { nav.next(); selection.selectFromPrevSelectedItem(); // [0, 1, 2] - expect(selection.inputs.values()).toEqual([0, 1, 2]); + expect(selection.inputs.value()).toEqual([0, 1, 2]); }); it('should select all items from an anchor at a higher index', () => { @@ -212,7 +212,7 @@ describe('List Selection', () => { selection.selectFromPrevSelectedItem(); // [3, 1, 2] // TODO(wagnermaciel): Order the values when inserting them. - expect(selection.inputs.values()).toEqual([3, 1, 2]); + expect(selection.inputs.value()).toEqual([3, 1, 2]); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts index e8e3802175df..754a70e92e60 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -27,8 +27,8 @@ export interface ListSelectionInputs, V> { /** Whether multiple items in the list can be selected at once. */ multiselectable: SignalLike; - /** The values of the current selected items. */ - values: WritableSignalLike; + /** The current value of the list selection. */ + value: WritableSignalLike; /** The selection strategy used by the list. */ selectionMode: SignalLike<'follow' | 'explicit'>; @@ -50,7 +50,7 @@ export class ListSelection, V> { select(item?: T) { item = item ?? this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; - if (item.disabled() || this.inputs.values().includes(item.value())) { + if (item.disabled() || this.inputs.value().includes(item.value())) { return; } @@ -60,7 +60,7 @@ export class ListSelection, V> { // TODO: Need to discuss when to drop this. this._anchor(); - this.inputs.values.update(values => values.concat(item.value())); + this.inputs.value.update(values => values.concat(item.value())); } /** Deselects the item at the current active index. */ @@ -68,20 +68,20 @@ export class ListSelection, V> { item = item ?? this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; if (!item.disabled()) { - this.inputs.values.update(values => values.filter(value => value !== item.value())); + this.inputs.value.update(values => values.filter(value => value !== item.value())); } } /** Toggles the item at the current active index. */ toggle() { const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; - this.inputs.values().includes(item.value()) ? this.deselect() : this.select(); + this.inputs.value().includes(item.value()) ? this.deselect() : this.select(); } /** Toggles only the item at the current active index. */ toggleOne() { const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; - this.inputs.values().includes(item.value()) ? this.deselect() : this.selectOne(); + this.inputs.value().includes(item.value()) ? this.deselect() : this.selectOne(); } /** Selects all items in the list. */ diff --git a/src/cdk-experimental/ui-patterns/listbox/option.ts b/src/cdk-experimental/ui-patterns/listbox/option.ts index ad244fa48e72..2bf7552e8ed9 100644 --- a/src/cdk-experimental/ui-patterns/listbox/option.ts +++ b/src/cdk-experimental/ui-patterns/listbox/option.ts @@ -49,7 +49,7 @@ export class OptionPattern { ); /** Whether the option is selected. */ - selected = computed(() => this.listbox()?.selection.inputs.values().includes(this.value())); + selected = computed(() => this.listbox()?.selection.inputs.value().includes(this.value())); /** Whether the option is disabled. */ disabled: SignalLike; From 7fb25b57d90e75949b6f2c0953b0bf4afd4bc1ba Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 28 Mar 2025 09:00:07 -0400 Subject: [PATCH 4/4] fixup! refactor(cdk-experimental/ui-patterns): track list selection by value --- .../ui-patterns/behaviors/list-focus/list-focus.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts index 9ca1927bb1b0..3600a4c9e2b4 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -34,13 +34,14 @@ export class ListFocus { } /** The id of the current active item. */ - getActiveDescendant(): string | void { + getActiveDescendant(): string | undefined { if (this.inputs.focusMode() === 'roving') { - return; + return undefined; } if (this.navigation.inputs.items().length) { return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id(); } + return undefined; } /** The tabindex for the list. */