diff --git a/src/cdk-experimental/listbox/BUILD.bazel b/src/cdk-experimental/listbox/BUILD.bazel index a2692a9c81ba..cb160fa0db09 100644 --- a/src/cdk-experimental/listbox/BUILD.bazel +++ b/src/cdk-experimental/listbox/BUILD.bazel @@ -11,6 +11,7 @@ ng_module( module_name = "@angular/cdk-experimental/listbox", deps = [ "//src/cdk/a11y", + "//src/cdk/collections", "//src/cdk/keycodes", ], ) diff --git a/src/cdk-experimental/listbox/listbox.spec.ts b/src/cdk-experimental/listbox/listbox.spec.ts index 9d64771a1633..a9a3e0e82c8b 100644 --- a/src/cdk-experimental/listbox/listbox.spec.ts +++ b/src/cdk-experimental/listbox/listbox.spec.ts @@ -308,21 +308,271 @@ describe('CdkOption', () => { expect(listboxInstance._listKeyManager.activeItem).toEqual(optionInstances[2]); expect(listboxInstance._listKeyManager.activeItemIndex).toBe(2); }); + + it('should update selected option on click event', () => { + let selectedOptions = optionInstances.filter(option => option.selected); + + expect(selectedOptions.length).toBe(0); + expect(optionElements[0].hasAttribute('aria-selected')).toBeFalse(); + expect(optionInstances[0].selected).toBeFalse(); + expect(fixture.componentInstance.changedOption).toBeUndefined(); + + dispatchMouseEvent(optionElements[0], 'click'); + fixture.detectChanges(); + + selectedOptions = optionInstances.filter(option => option.selected); + expect(selectedOptions.length).toBe(1); + expect(optionElements[0].getAttribute('aria-selected')).toBe('true'); + expect(optionInstances[0].selected).toBeTrue(); + expect(fixture.componentInstance.changedOption).toBeDefined(); + expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id); + }); + }); + + describe('with multiple selection', () => { + let fixture: ComponentFixture; + + let testComponent: ListboxMultiselect; + + let listbox: DebugElement; + let listboxInstance: CdkListbox; + + let options: DebugElement[]; + let optionInstances: CdkOption[]; + let optionElements: HTMLElement[]; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkListboxModule], + declarations: [ListboxMultiselect], + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ListboxMultiselect); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + listbox = fixture.debugElement.query(By.directive(CdkListbox)); + listboxInstance = listbox.injector.get(CdkListbox); + + options = fixture.debugElement.queryAll(By.directive(CdkOption)); + optionInstances = options.map(o => o.injector.get(CdkOption)); + optionElements = options.map(o => o.nativeElement); + })); + + it('should select all options using the select all method', () => { + let selectedOptions = optionInstances.filter(option => option.selected); + testComponent.isMultiselectable = true; + fixture.detectChanges(); + + expect(selectedOptions.length).toBe(0); + expect(optionElements[0].hasAttribute('aria-selected')).toBeFalse(); + expect(optionInstances[0].selected).toBeFalse(); + expect(fixture.componentInstance.changedOption).toBeUndefined(); + + listboxInstance.setAllSelected(true); + fixture.detectChanges(); + + selectedOptions = optionInstances.filter(option => option.selected); + expect(selectedOptions.length).toBe(4); + + for (const option of optionElements) { + expect(option.getAttribute('aria-selected')).toBe('true'); + } + + expect(fixture.componentInstance.changedOption).toBeDefined(); + expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[3].id); + }); + + it('should deselect previously selected when multiple is false', () => { + let selectedOptions = optionInstances.filter(option => option.selected); + + expect(selectedOptions.length).toBe(0); + expect(optionElements[0].hasAttribute('aria-selected')).toBeFalse(); + expect(optionInstances[0].selected).toBeFalse(); + expect(fixture.componentInstance.changedOption).toBeUndefined(); + + dispatchMouseEvent(optionElements[0], 'click'); + fixture.detectChanges(); + + selectedOptions = optionInstances.filter(option => option.selected); + expect(selectedOptions.length).toBe(1); + expect(optionElements[0].getAttribute('aria-selected')).toBe('true'); + expect(optionInstances[0].selected).toBeTrue(); + expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id); + + dispatchMouseEvent(optionElements[2], 'click'); + fixture.detectChanges(); + + selectedOptions = optionInstances.filter(option => option.selected); + expect(selectedOptions.length).toBe(1); + expect(optionElements[0].hasAttribute('aria-selected')).toBeFalse(); + expect(optionInstances[0].selected).toBeFalse(); + expect(optionElements[2].getAttribute('aria-selected')).toBe('true'); + expect(optionInstances[2].selected).toBeTrue(); + + /** Expect first option to be most recently changed because it was deselected. */ + expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id); + }); + + it('should allow multiple selection when multiple is true', () => { + let selectedOptions = optionInstances.filter(option => option.selected); + testComponent.isMultiselectable = true; + + expect(selectedOptions.length).toBe(0); + expect(fixture.componentInstance.changedOption).toBeUndefined(); + + dispatchMouseEvent(optionElements[0], 'click'); + fixture.detectChanges(); + + selectedOptions = optionInstances.filter(option => option.selected); + expect(selectedOptions.length).toBe(1); + expect(optionElements[0].getAttribute('aria-selected')).toBe('true'); + expect(optionInstances[0].selected).toBeTrue(); + expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id); + + dispatchMouseEvent(optionElements[2], 'click'); + fixture.detectChanges(); + + selectedOptions = optionInstances.filter(option => option.selected); + expect(selectedOptions.length).toBe(2); + expect(optionElements[0].getAttribute('aria-selected')).toBe('true'); + expect(optionInstances[0].selected).toBeTrue(); + expect(optionElements[2].getAttribute('aria-selected')).toBe('true'); + expect(optionInstances[2].selected).toBeTrue(); + expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[2].id); + }); + + it('should deselect all options when multiple switches to false', () => { + let selectedOptions = optionInstances.filter(option => option.selected); + testComponent.isMultiselectable = true; + + expect(selectedOptions.length).toBe(0); + expect(fixture.componentInstance.changedOption).toBeUndefined(); + + dispatchMouseEvent(optionElements[0], 'click'); + fixture.detectChanges(); + + selectedOptions = optionInstances.filter(option => option.selected); + expect(selectedOptions.length).toBe(1); + expect(optionElements[0].getAttribute('aria-selected')).toBe('true'); + expect(optionInstances[0].selected).toBeTrue(); + expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id); + + testComponent.isMultiselectable = false; + fixture.detectChanges(); + + selectedOptions = optionInstances.filter(option => option.selected); + expect(selectedOptions.length).toBe(0); + expect(optionElements[0].hasAttribute('aria-selected')).toBeFalse(); + expect(optionInstances[0].selected).toBeFalse(); + expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id); + }); }); + describe('with aria active descendant', () => { + let fixture: ComponentFixture; + + let testComponent: ListboxActiveDescendant; + + let listbox: DebugElement; + let listboxInstance: CdkListbox; + let listboxElement: HTMLElement; + + let options: DebugElement[]; + let optionInstances: CdkOption[]; + let optionElements: HTMLElement[]; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkListboxModule], + declarations: [ListboxActiveDescendant], + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ListboxActiveDescendant); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + listbox = fixture.debugElement.query(By.directive(CdkListbox)); + listboxInstance = listbox.injector.get(CdkListbox); + listboxElement = listbox.nativeElement; + + options = fixture.debugElement.queryAll(By.directive(CdkOption)); + optionInstances = options.map(o => o.injector.get(CdkOption)); + optionElements = options.map(o => o.nativeElement); + })); + + it('should update aria active descendant when enabled', () => { + expect(listboxElement.hasAttribute('aria-activedescendant')).toBeFalse(); + + listboxInstance.setActiveOption(optionInstances[0]); + fixture.detectChanges(); + + expect(listboxElement.hasAttribute('aria-activedescendant')).toBeTrue(); + expect(listboxElement.getAttribute('aria-activedescendant')).toBe(optionElements[0].id); + + listboxInstance.setActiveOption(optionInstances[2]); + fixture.detectChanges(); + + expect(listboxElement.hasAttribute('aria-activedescendant')).toBeTrue(); + expect(listboxElement.getAttribute('aria-activedescendant')).toBe(optionElements[2].id); + }); + + it('should update aria active descendant via arrow keys', () => { + expect(listboxElement.hasAttribute('aria-activedescendant')).toBeFalse(); + + dispatchKeyboardEvent(listboxElement, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(listboxElement.hasAttribute('aria-activedescendant')).toBeTrue(); + expect(listboxElement.getAttribute('aria-activedescendant')).toBe(optionElements[0].id); + + dispatchKeyboardEvent(listboxElement, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(listboxElement.hasAttribute('aria-activedescendant')).toBeTrue(); + expect(listboxElement.getAttribute('aria-activedescendant')).toBe(optionElements[1].id); + }); + + it('should place focus on options and not set active descendant', () => { + testComponent.isActiveDescendant = false; + fixture.detectChanges(); + + expect(listboxElement.hasAttribute('aria-activedescendant')).toBeFalse(); + + dispatchKeyboardEvent(listboxElement, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(listboxElement.hasAttribute('aria-activedescendant')).toBeFalse(); + expect(document.activeElement).toEqual(optionElements[0]); + dispatchKeyboardEvent(listboxElement, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(listboxElement.hasAttribute('aria-activedescendant')).toBeFalse(); + expect(document.activeElement).toEqual(optionElements[1]); + + }); + }); }); @Component({ template: `
+ [disabled]="isListboxDisabled" + (selectionChange)="onSelectionChange($event)">
- Purple
+ Purple +
- Solar
+ Solar +
Arc
Stasis
` @@ -337,3 +587,47 @@ class ListboxWithOptions { this.changedOption = event.option; } } + +@Component({ + template: ` +
+
Purple
+
Solar
+
Arc
+
Stasis
+
` +}) +class ListboxMultiselect { + changedOption: CdkOption; + isMultiselectable: boolean = false; + + onSelectionChange(event: ListboxSelectionChangeEvent) { + this.changedOption = event.option; + } +} + +@Component({ + template: ` +
+
Purple
+
Solar
+
Arc
+
Stasis
+
` +}) +class ListboxActiveDescendant { + changedOption: CdkOption; + isActiveDescendant: boolean = true; + focusedOption: string; + + onSelectionChange(event: ListboxSelectionChangeEvent) { + this.changedOption = event.option; + } + + onFocus(option: string) { + this.focusedOption = option; + } +} diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index 2c1e295d0fa2..577ec9739f56 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -12,12 +12,15 @@ import { Directive, ElementRef, EventEmitter, forwardRef, Inject, - Input, OnDestroy, Output, + Input, OnDestroy, OnInit, Output, QueryList } from '@angular/core'; import {ActiveDescendantKeyManager, Highlightable, ListKeyManagerOption} from '@angular/cdk/a11y'; import {END, ENTER, HOME, SPACE} from '@angular/cdk/keycodes'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; +import {SelectionChange, SelectionModel} from '@angular/cdk/collections'; +import {defer, merge, Observable, Subject} from 'rxjs'; +import {startWith, switchMap, takeUntil} from 'rxjs/operators'; let nextId = 0; @@ -35,7 +38,6 @@ let nextId = 0; '[attr.aria-disabled]': '_isInteractionDisabled()', '[class.cdk-option-disabled]': '_isInteractionDisabled()', '[class.cdk-option-active]': '_active' - } }) export class CdkOption implements ListKeyManagerOption, Highlightable { @@ -43,6 +45,9 @@ export class CdkOption implements ListKeyManagerOption, Highlightable { private _disabled: boolean = false; _active: boolean = false; + /** The id of the option, set to a uniqueid if the user does not provide one. */ + @Input() id = `cdk-option-${nextId++}`; + @Input() get selected(): boolean { return this._selected; @@ -53,9 +58,6 @@ export class CdkOption implements ListKeyManagerOption, Highlightable { } } - /** The id of the option, set to a uniqueid if the user does not provide one. */ - @Input() id = `cdk-option-${nextId++}`; - @Input() get disabled(): boolean { return this._disabled; @@ -64,15 +66,18 @@ export class CdkOption implements ListKeyManagerOption, Highlightable { this._disabled = coerceBooleanProperty(value); } - constructor(private _elementRef: ElementRef, - @Inject(forwardRef(() => CdkListbox)) public listbox: CdkListbox) { + @Output() readonly selectionChange: EventEmitter = + new EventEmitter(); + + constructor(private readonly _elementRef: ElementRef, + @Inject(forwardRef(() => CdkListbox)) readonly listbox: CdkListbox) { } /** Toggles the selected state, emits a change event through the injected listbox. */ toggle() { if (!this._isInteractionDisabled()) { this.selected = !this.selected; - this.listbox._emitChangeEvent(this); + this._emitSelectionChange(true); } } @@ -80,13 +85,35 @@ export class CdkOption implements ListKeyManagerOption, Highlightable { activate() { if (!this._isInteractionDisabled()) { this._active = true; - this.listbox.setActiveOption(this); } } /** Sets the active property false. */ deactivate() { - this._active = false; + if (!this._isInteractionDisabled()) { + this._active = false; + } + } + + /** Sets the selected property true if it was false. */ + select() { + if (!this.selected) { + this.selected = true; + this._emitSelectionChange(); + } + } + + /** Sets the selected property false if it was true. */ + deselect() { + if (this.selected) { + this.selected = false; + this._emitSelectionChange(); + } + } + + /** Applies focus to the option. */ + focus() { + this._elementRef.nativeElement.focus(); } /** Returns true if the option or listbox are disabled, and false otherwise. */ @@ -94,20 +121,42 @@ export class CdkOption implements ListKeyManagerOption, Highlightable { return (this.listbox.disabled || this._disabled); } + /** Emits a change event extending the Option Selection Change Event interface. */ + private _emitSelectionChange(isUserInput = false) { + this.selectionChange.emit({ + source: this, + isUserInput: isUserInput + }); + } + /** Returns the tab index which depends on the disabled property. */ _getTabIndex(): string | null { - return (this.listbox.disabled || this._disabled) ? null : '-1'; + return this._isInteractionDisabled() ? null : '-1'; } - getLabel(): string { - // TODO: improve to method to handle more complex combinations of elements and text - return this._elementRef.nativeElement.textContent; + /** Get the label for this element which is required by the FocusableOption interface. */ + getLabel() { + // we know that the current node is an element type + const clone = this._elementRef.nativeElement.cloneNode(true) as Element; + this._removeIcons(clone); + + return clone.textContent?.trim() || ''; + } + + /** Remove any child from the given element which can be identified as an icon. */ + private _removeIcons(element: Element) { + // TODO: make this a configurable function that can removed any desired type of node. + for (const icon of Array.from(element.querySelectorAll('mat-icon, .material-icons'))) { + icon.parentNode?.removeChild(icon); + } } + /** Sets the active property to true to enable the active css class. */ setActiveStyles() { this._active = true; } + /** Sets the active property to false to disable the active css class. */ setInactiveStyles() { this._active = false; } @@ -123,18 +172,50 @@ export class CdkOption implements ListKeyManagerOption, Highlightable { 'role': 'listbox', '(keydown)': '_keydown($event)', '[attr.aria-disabled]': '_disabled', + '[attr.aria-multiselectable]': '_multiple', + '[attr.aria-activedescendant]': '_getAriaActiveDescendant()' } }) -export class CdkListbox implements AfterContentInit, OnDestroy { +export class CdkListbox implements AfterContentInit, OnDestroy, OnInit { _listKeyManager: ActiveDescendantKeyManager; + _selectionModel: SelectionModel; + + readonly optionSelectionChanges: Observable = defer(() => { + const options = this._options; + + return options.changes.pipe( + startWith(options), + switchMap(() => merge(...options.map(option => option.selectionChange))) + ); + }) as Observable; + + private _disabled: boolean = false; + private _multiple: boolean = false; + private _useActiveDescendant: boolean = true; + private _activeOption: CdkOption; + + private readonly _destroyed = new Subject(); @ContentChildren(CdkOption, {descendants: true}) _options: QueryList; @Output() readonly selectionChange: EventEmitter = new EventEmitter(); + /** + * Whether the listbox allows multiple options to be selected. + * If `multiple` switches from `true` to `false`, all options are deselected. + */ + @Input() + get multiple(): boolean { + return this._multiple; + } + set multiple(value: boolean) { + this._updateSelectionOnMultiSelectionChange(value); + this._multiple = coerceBooleanProperty(value); + } + @Input() get disabled(): boolean { return this._disabled; @@ -143,13 +224,57 @@ export class CdkListbox implements AfterContentInit, OnDestroy { this._disabled = coerceBooleanProperty(value); } + /** Whether the listbox will use active descendant or will move focus onto the options. */ + @Input() + get useActiveDescendant(): boolean { + return this._useActiveDescendant; + } + set useActiveDescendant(shouldUseActiveDescendant: boolean) { + this._useActiveDescendant = coerceBooleanProperty(shouldUseActiveDescendant); + } + + ngOnInit() { + this._selectionModel = new SelectionModel(this.multiple); + } + ngAfterContentInit() { - this._listKeyManager = new ActiveDescendantKeyManager(this._options) - .withWrap().withVerticalOrientation().withTypeAhead(); + this._initKeyManager(); + this._initSelectionModel(); + + this.optionSelectionChanges.subscribe(event => { + this._emitChangeEvent(event.source); + this._updateSelectionModel(event.source); + this.setActiveOption(event.source); + }); } ngOnDestroy() { this._listKeyManager.change.complete(); + this._destroyed.next(); + this._destroyed.complete(); + } + + private _initKeyManager() { + this._listKeyManager = new ActiveDescendantKeyManager(this._options) + .withWrap().withVerticalOrientation().withTypeAhead(); + + this._listKeyManager.change.pipe(takeUntil(this._destroyed)).subscribe(() => { + this._updateActiveOption(); + }); + } + + private _initSelectionModel() { + this._selectionModel.changed.pipe(takeUntil(this._destroyed)) + .subscribe((event: SelectionChange) => { + + for (const option of event.added) { + option.selected = true; + } + + for (const option of event.removed) { + option.selected = false; + } + }); } _keydown(event: KeyboardEvent) { @@ -158,7 +283,7 @@ export class CdkListbox implements AfterContentInit, OnDestroy { } const manager = this._listKeyManager; - const keyCode = event.keyCode; + const {keyCode} = event; if (keyCode === HOME || keyCode === END) { event.preventDefault(); @@ -172,33 +297,89 @@ export class CdkListbox implements AfterContentInit, OnDestroy { } else { manager.onKeydown(event); } - } /** Emits a selection change event, called when an option has its selected state changed. */ _emitChangeEvent(option: CdkOption) { - this.selectionChange.emit(new ListboxSelectionChangeEvent(this, option)); + this.selectionChange.emit({ + source: this, + option: option + }); } + /** Updates the selection model after a toggle. */ + _updateSelectionModel(option: CdkOption) { + if (!this.multiple && this._selectionModel.selected.length !== 0) { + const previouslySelected = this._selectionModel.selected[0]; + this.deselect(previouslySelected); + } + + option.selected ? this._selectionModel.select(option) : + this._selectionModel.deselect(option); + } + + /** Toggles the selected state of the active option if not disabled. */ private _toggleActiveOption() { const activeOption = this._listKeyManager.activeItem; if (activeOption && !activeOption.disabled) { activeOption.toggle(); - this._emitChangeEvent(activeOption); + } + } + + /** Returns the id of the active option if active descendant is being used. */ + _getAriaActiveDescendant(): string | null | undefined { + return this._useActiveDescendant ? this._listKeyManager?.activeItem?.id : null; + } + + /** Updates the activeOption and the active and focus properties of the option. */ + private _updateActiveOption() { + if (!this._listKeyManager.activeItem) { + return; + } + + this._activeOption?.deactivate(); + this._activeOption = this._listKeyManager.activeItem; + this._activeOption.activate(); + + if (!this.useActiveDescendant) { + this._activeOption.focus(); + } + } + + /** Updates selection states of options when the 'multiple' property changes. */ + private _updateSelectionOnMultiSelectionChange(value: boolean) { + if (this.multiple && !value) { + // Deselect all options instead of arbitrarily keeping one of the selected options. + this.setAllSelected(false); + } else if (!this.multiple && value) { + this._selectionModel = new SelectionModel(value, this._selectionModel.selected); } } /** Selects the given option if the option and listbox aren't disabled. */ select(option: CdkOption) { if (!this.disabled && !option.disabled) { - option.selected = true; + option.select(); } } /** Deselects the given option if the option and listbox aren't disabled. */ deselect(option: CdkOption) { if (!this.disabled && !option.disabled) { - option.selected = false; + option.deselect(); + } + } + + /** Sets the selected state of all options to be the given value. */ + setAllSelected(isSelected: boolean) { + for (const option of this._options.toArray()) { + const wasSelected = option.selected; + isSelected ? this.select(option) : this.deselect(option); + + if (wasSelected !== isSelected) { + this._emitChangeEvent(option); + this._updateSelectionModel(option); + } } } @@ -208,13 +389,24 @@ export class CdkListbox implements AfterContentInit, OnDestroy { } static ngAcceptInputType_disabled: BooleanInput; + static ngAcceptInputType_multiple: BooleanInput; + static ngAcceptInputType_useActiveDescendant: BooleanInput; } /** Change event that is being fired whenever the selected state of an option changes. */ -export class ListboxSelectionChangeEvent { - constructor( - /** Reference to the listbox that emitted the event. */ - public source: CdkListbox, - /** Reference to the option that has been changed. */ - public option: CdkOption) {} +export interface ListboxSelectionChangeEvent { + /** Reference to the listbox that emitted the event. */ + readonly source: CdkListbox; + + /** Reference to the option that has been changed. */ + readonly option: CdkOption; +} + +/** Event object emitted by MatOption when selected or deselected. */ +export interface OptionSelectionChangeEvent { + /** Reference to the option that emitted the event. */ + source: CdkOption; + + /** Whether the change in the option's value was a result of a user action. */ + isUserInput: boolean; }