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 f926fe6fc988..c51a65996d8d 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 @@ -115,29 +115,56 @@ export class ListSelection, V> { this._selectFromIndex(this.inputs.navigation.prevActiveIndex()); } - /** Selects the items in the list starting at the given index. */ - private _selectFromIndex(index: number) { - if (index === -1) { - return; - } - - const upper = Math.max(this.inputs.navigation.inputs.activeIndex(), index); - const lower = Math.min(this.inputs.navigation.inputs.activeIndex(), index); - - for (let i = lower; i <= upper; i++) { - this.select(this.inputs.items()[i]); - } - } - /** Sets the selection to only the current active item. */ selectOne() { this.deselectAll(); this.select(); } + /** Toggles the items in the list starting at the last selected item. */ + toggleFromPrevSelectedItem() { + const prevIndex = this.inputs.items().findIndex(i => this.previousValue() === i.value()); + const currIndex = this.inputs.navigation.inputs.activeIndex(); + const currValue = this.inputs.items()[currIndex].value(); + const items = this._getItemsFromIndex(prevIndex); + + const operation = this.inputs.value().includes(currValue) + ? this.deselect.bind(this) + : this.select.bind(this); + + for (const item of items) { + operation(item); + } + } + /** Sets the anchor to the current active index. */ private _anchor() { const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; this.previousValue.set(item.value()); } + + /** Selects the items in the list starting at the given index. */ + private _selectFromIndex(index: number) { + const items = this._getItemsFromIndex(index); + + for (const item of items) { + this.select(item); + } + } + + /** Returns all items from the given index to the current active index. */ + private _getItemsFromIndex(index: number) { + if (index === -1) { + return []; + } + + const upper = Math.max(this.inputs.navigation.inputs.activeIndex(), index); + const lower = Math.min(this.inputs.navigation.inputs.activeIndex(), index); + + const items = []; + for (let i = lower; i <= upper; i++) { + items.push(this.inputs.items()[i]); + } + return items; + } } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts index 0b70d65500ec..12701bc18606 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts @@ -46,13 +46,15 @@ describe('Listbox Pattern', () => { function getOptions(listbox: TestListbox, values: string[]): TestOption[] { return values.map((value, index) => { + const element = document.createElement('div'); + element.role = 'option'; return new OptionPattern({ value: signal(value), id: signal(`option-${index}`), disabled: signal(false), searchTerm: signal(value), listbox: signal(listbox), - element: signal({focus: () => {}} as HTMLElement), + element: signal(element), }); }); } @@ -439,4 +441,158 @@ describe('Listbox Pattern', () => { }); }); }); + + describe('Pointer Events', () => { + function click(options: WritableSignal, index: number, mods?: ModifierKeys) { + return { + target: options()[index].element(), + shiftKey: mods?.shift, + ctrlKey: mods?.control, + } as unknown as PointerEvent; + } + + describe('follows focus & single select', () => { + it('should select a single option on click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(false), + selectionMode: signal('follow'), + }); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); + }); + + describe('explicit focus & single select', () => { + it('should select an unselected option on click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(false), + selectionMode: signal('explicit'), + }); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); + + it('should deselect a selected option on click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(false), + value: signal(['Apple']), + selectionMode: signal('explicit'), + }); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual([]); + }); + }); + + describe('explicit focus & multi select', () => { + it('should select an unselected option on click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + selectionMode: signal('explicit'), + }); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); + + it('should deselect a selected option on click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + value: signal(['Apple']), + selectionMode: signal('explicit'), + }); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual([]); + }); + + it('should select options from anchor on shift + click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + selectionMode: signal('explicit'), + }); + listbox.onPointerdown(click(options, 2)); + listbox.onPointerdown(click(options, 5, {shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']); + }); + + it('should deselect options from anchor on shift + click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + selectionMode: signal('explicit'), + }); + listbox.onPointerdown(click(options, 2)); + listbox.onPointerdown(click(options, 5)); + listbox.onPointerdown(click(options, 2, {shift: true})); + expect(listbox.inputs.value()).toEqual([]); + }); + }); + + describe('follows focus & multi select', () => { + it('should select a single option on click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + selectionMode: signal('follow'), + }); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onPointerdown(click(options, 1)); + expect(listbox.inputs.value()).toEqual(['Apricot']); + listbox.onPointerdown(click(options, 2)); + expect(listbox.inputs.value()).toEqual(['Banana']); + }); + + it('should select an unselected option on ctrl + click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + selectionMode: signal('follow'), + }); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onPointerdown(click(options, 1, {control: true})); + expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']); + listbox.onPointerdown(click(options, 2, {control: true})); + expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana']); + }); + + it('should deselect a selected option on ctrl + click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + selectionMode: signal('follow'), + }); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onPointerdown(click(options, 0, {control: true})); + expect(listbox.inputs.value()).toEqual([]); + }); + + it('should select options from anchor on shift + click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + selectionMode: signal('follow'), + }); + listbox.onPointerdown(click(options, 2)); + listbox.onPointerdown(click(options, 5, {shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']); + }); + + it('should deselect options from anchor on shift + click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + selectionMode: signal('follow'), + }); + listbox.onPointerdown(click(options, 2)); + listbox.onPointerdown(click(options, 5, {control: true})); + listbox.onPointerdown(click(options, 2, {shift: true})); + expect(listbox.inputs.value()).toEqual([]); + }); + }); + + it('should only navigate when readonly', () => { + const {listbox, options} = getDefaultPatterns({readonly: signal(true)}); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual([]); + listbox.onPointerdown(click(options, 1)); + expect(listbox.inputs.value()).toEqual([]); + listbox.onPointerdown(click(options, 2)); + expect(listbox.inputs.value()).toEqual([]); + }); + }); }); diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index bba0f2444ba6..0c5bcaf3c593 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -26,6 +26,7 @@ interface SelectOptions { selectAll?: boolean; selectFromAnchor?: boolean; selectFromActive?: boolean; + toggleFromAnchor?: boolean; } /** Represents the required inputs for a listbox. */ @@ -167,13 +168,28 @@ export class ListboxPattern { return manager.on(e => this.goto(e)); } - if (this.inputs.multi()) { + if (!this.multi() && this.followFocus()) { + return manager.on(e => this.goto(e, {selectOne: true})); + } + + if (!this.multi() && !this.followFocus()) { + return manager.on(e => this.goto(e, {toggle: true})); + } + + if (this.multi() && this.followFocus()) { + return manager + .on(e => this.goto(e, {selectOne: true})) + .on(Modifier.Ctrl, e => this.goto(e, {toggle: true})) + .on(Modifier.Shift, e => this.goto(e, {toggleFromAnchor: true})); + } + + if (this.multi() && !this.followFocus()) { return manager .on(e => this.goto(e, {toggle: true})) - .on(Modifier.Shift, e => this.goto(e, {selectFromActive: true})); + .on(Modifier.Shift, e => this.goto(e, {toggleFromAnchor: true})); } - return manager.on(e => this.goto(e, {toggleOne: true})); + return manager; }); constructor(readonly inputs: ListboxInputs) { @@ -270,6 +286,9 @@ export class ListboxPattern { if (opts?.selectFromActive) { this.selection.selectFromActive(); } + if (opts?.toggleFromAnchor) { + this.selection.toggleFromPrevSelectedItem(); + } } private _getItem(e: PointerEvent) {