diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts index 0c4fd44af81f..0e2efeb90b63 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts @@ -10,11 +10,21 @@ import {signal} from '@angular/core'; import {ListboxInputs, ListboxPattern} from './listbox'; import {OptionPattern} from './option'; import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {ModifierKeys} from '@angular/cdk/testing'; type TestInputs = ListboxInputs; type TestOption = OptionPattern; type TestListbox = ListboxPattern; +const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods); +const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); +const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods); +const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods); +const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods); +const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods); +const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods); +const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods); + describe('Listbox Pattern', () => { function getListbox(inputs: Partial & Pick) { return new ListboxPattern({ @@ -70,85 +80,287 @@ describe('Listbox Pattern', () => { ); } - describe('Navigation', () => { + describe('Keyboard Navigation', () => { it('should navigate next on ArrowDown', () => { const {listbox} = getDefaultPatterns(); - const event = createKeyboardEvent('keydown', 40, 'ArrowDown'); expect(listbox.inputs.activeIndex()).toBe(0); - listbox.onKeydown(event); + listbox.onKeydown(down()); expect(listbox.inputs.activeIndex()).toBe(1); }); it('should navigate prev on ArrowUp', () => { - const event = createKeyboardEvent('keydown', 38, 'ArrowUp'); - const {listbox} = getDefaultPatterns({ - activeIndex: signal(1), - }); + const {listbox} = getDefaultPatterns({activeIndex: signal(1)}); expect(listbox.inputs.activeIndex()).toBe(1); - listbox.onKeydown(event); + listbox.onKeydown(up()); expect(listbox.inputs.activeIndex()).toBe(0); }); it('should navigate next on ArrowRight (horizontal)', () => { - const event = createKeyboardEvent('keydown', 39, 'ArrowRight'); - const {listbox} = getDefaultPatterns({ - orientation: signal('horizontal'), - }); + const {listbox} = getDefaultPatterns({orientation: signal('horizontal')}); expect(listbox.inputs.activeIndex()).toBe(0); - listbox.onKeydown(event); + listbox.onKeydown(right()); expect(listbox.inputs.activeIndex()).toBe(1); }); it('should navigate prev on ArrowLeft (horizontal)', () => { - const event = createKeyboardEvent('keydown', 37, 'ArrowLeft'); const {listbox} = getDefaultPatterns({ activeIndex: signal(1), orientation: signal('horizontal'), }); expect(listbox.inputs.activeIndex()).toBe(1); - listbox.onKeydown(event); + listbox.onKeydown(left()); expect(listbox.inputs.activeIndex()).toBe(0); }); it('should navigate next on ArrowLeft (horizontal & rtl)', () => { - const event = createKeyboardEvent('keydown', 38, 'ArrowLeft'); const {listbox} = getDefaultPatterns({ textDirection: signal('rtl'), orientation: signal('horizontal'), }); expect(listbox.inputs.activeIndex()).toBe(0); - listbox.onKeydown(event); + listbox.onKeydown(left()); expect(listbox.inputs.activeIndex()).toBe(1); }); it('should navigate prev on ArrowRight (horizontal & rtl)', () => { - const event = createKeyboardEvent('keydown', 39, 'ArrowRight'); const {listbox} = getDefaultPatterns({ activeIndex: signal(1), textDirection: signal('rtl'), orientation: signal('horizontal'), }); expect(listbox.inputs.activeIndex()).toBe(1); - listbox.onKeydown(event); + listbox.onKeydown(right()); expect(listbox.inputs.activeIndex()).toBe(0); }); it('should navigate to the first option on Home', () => { - const event = createKeyboardEvent('keydown', 36, 'Home'); const {listbox} = getDefaultPatterns({ activeIndex: signal(8), }); expect(listbox.inputs.activeIndex()).toBe(8); - listbox.onKeydown(event); + listbox.onKeydown(home()); expect(listbox.inputs.activeIndex()).toBe(0); }); it('should navigate to the last option on End', () => { - const event = createKeyboardEvent('keydown', 35, 'End'); const {listbox} = getDefaultPatterns(); expect(listbox.inputs.activeIndex()).toBe(0); - listbox.onKeydown(event); + listbox.onKeydown(end()); expect(listbox.inputs.activeIndex()).toBe(8); }); }); + + describe('Keyboard Selection', () => { + describe('follows focus & single select', () => { + it('should select an option on navigation', () => { + const {listbox} = getDefaultPatterns({ + value: signal(['Apple']), + multiselectable: signal(false), + selectionMode: signal('follow'), + }); + + expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.value()).toEqual(['Apple']); + + listbox.onKeydown(down()); + expect(listbox.inputs.activeIndex()).toBe(1); + expect(listbox.inputs.value()).toEqual(['Apricot']); + + listbox.onKeydown(up()); + expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.value()).toEqual(['Apple']); + + listbox.onKeydown(end()); + expect(listbox.inputs.activeIndex()).toBe(8); + expect(listbox.inputs.value()).toEqual(['Cranberry']); + + listbox.onKeydown(home()); + expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); + }); + + describe('explicit focus & single select', () => { + let listbox: TestListbox; + + beforeEach(() => { + listbox = getDefaultPatterns({ + value: signal([]), + selectionMode: signal('explicit'), + multiselectable: signal(false), + }).listbox; + }); + + it('should select an option on Space', () => { + listbox.onKeydown(space()); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); + + it('should select an option on Enter', () => { + listbox.onKeydown(enter()); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); + + it('should only allow one selected option', () => { + listbox.onKeydown(enter()); + listbox.onKeydown(down()); + listbox.onKeydown(enter()); + expect(listbox.inputs.value()).toEqual(['Apricot']); + }); + }); + + describe('explicit focus & multi select', () => { + let listbox: TestListbox; + + beforeEach(() => { + listbox = getDefaultPatterns({ + value: signal([]), + selectionMode: signal('explicit'), + multiselectable: signal(true), + }).listbox; + }); + + it('should select an option on Space', () => { + listbox.onKeydown(space()); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); + + it('should select an option on Enter', () => { + listbox.onKeydown(enter()); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); + + it('should allow multiple selected options', () => { + listbox.onKeydown(enter()); + listbox.onKeydown(down()); + listbox.onKeydown(enter()); + expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']); + }); + + it('should toggle the selected state of the next option on Shift + ArrowDown', () => { + listbox.onKeydown(down({shift: true})); + listbox.onKeydown(down({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana']); + }); + + it('should toggle the selected state of the next option on Shift + ArrowUp', () => { + listbox.onKeydown(down()); + listbox.onKeydown(down()); + listbox.onKeydown(up({shift: true})); + listbox.onKeydown(up({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apricot', 'Apple']); + }); + + it('should select contiguous items from the most recently selected item to the focused item on Shift + Space (or Enter)', () => { + listbox.onKeydown(down()); + listbox.onKeydown(space()); // Apricot + listbox.onKeydown(down()); + listbox.onKeydown(down()); + listbox.onKeydown(space({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana', 'Blackberry']); + }); + + it('should select the focused option and all options up to the first option on Ctrl + Shift + Home', () => { + listbox.onKeydown(down()); + listbox.onKeydown(down()); + listbox.onKeydown(down()); + listbox.onKeydown(home({control: true, shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana', 'Blackberry']); + }); + + it('should select the focused option and all options down to the last option on Ctrl + Shift + End', () => { + listbox.onKeydown(down()); + listbox.onKeydown(down()); + listbox.onKeydown(down()); + listbox.onKeydown(down()); + listbox.onKeydown(down()); + listbox.onKeydown(end({control: true, shift: true})); + expect(listbox.inputs.value()).toEqual(['Cantaloupe', 'Cherry', 'Clementine', 'Cranberry']); + }); + }); + + describe('follows focus & multi select', () => { + let listbox: TestListbox; + + beforeEach(() => { + listbox = getDefaultPatterns({ + value: signal(['Apple']), + multiselectable: signal(true), + selectionMode: signal('follow'), + }).listbox; + }); + + it('should select an option on navigation', () => { + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onKeydown(down()); + expect(listbox.inputs.value()).toEqual(['Apricot']); + listbox.onKeydown(up()); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onKeydown(end()); + expect(listbox.inputs.value()).toEqual(['Cranberry']); + listbox.onKeydown(home()); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); + + it('should navigate without selecting an option if the Ctrl key is pressed', () => { + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onKeydown(down({control: true})); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onKeydown(up({control: true})); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onKeydown(end({control: true})); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onKeydown(home({control: true})); + }); + + it('should toggle an options selection state on Ctrl + Space', () => { + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down({control: true})); + listbox.onKeydown(space({control: true})); + expect(listbox.inputs.value()).toEqual(['Apple', 'Banana']); + }); + + it('should toggle the selected state of the next option on Shift + ArrowDown', () => { + listbox.onKeydown(down({shift: true})); + listbox.onKeydown(down({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana']); + }); + + it('should toggle the selected state of the next option on Shift + ArrowUp', () => { + listbox.onKeydown(down()); + listbox.onKeydown(down()); + listbox.onKeydown(up({shift: true})); + listbox.onKeydown(up({shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']); + }); + + it('should select contiguous items from the most recently selected item to the focused item on Shift + Space (or Enter)', () => { + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down()); // Blackberry + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down({control: true})); + listbox.onKeydown(space({shift: true})); + expect(listbox.inputs.value()).toEqual(['Blackberry', 'Blueberry', 'Cantaloupe']); + }); + + it('should select the focused option and all options up to the first option on Ctrl + Shift + Home', () => { + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down()); + listbox.onKeydown(home({control: true, shift: true})); + expect(listbox.inputs.value()).toEqual(['Blackberry', 'Apple', 'Apricot', 'Banana']); + }); + + it('should select the focused option and all options down to the last option on Ctrl + Shift + End', () => { + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down()); + listbox.onKeydown(end({control: true, shift: true})); + expect(listbox.inputs.value()).toEqual(['Cantaloupe', 'Cherry', 'Clementine', 'Cranberry']); + }); + }); + }); }); diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 098aa5af2526..1914ff91d48d 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -137,6 +137,8 @@ export class ListboxPattern { manager .on(Modifier.Ctrl, this.prevKey, () => this.prev()) .on(Modifier.Ctrl, this.nextKey, () => this.next()) + .on(Modifier.Ctrl, ' ', () => this._updateSelection({toggle: true})) + .on(Modifier.Ctrl, 'Enter', () => this._updateSelection({toggle: true})) .on(Modifier.Ctrl, 'Home', () => this.first()) // TODO: Not in spec but prob should be. .on(Modifier.Ctrl, 'End', () => this.last()); // TODO: Not in spec but prob should be. }