diff --git a/src/cdk-experimental/listbox/BUILD.bazel b/src/cdk-experimental/listbox/BUILD.bazel index 52498a8b7f3d..4e0184630536 100644 --- a/src/cdk-experimental/listbox/BUILD.bazel +++ b/src/cdk-experimental/listbox/BUILD.bazel @@ -9,8 +9,8 @@ ng_module( exclude = ["**/*.spec.ts"], ), deps = [ - "//src/cdk-experimental/combobox", "//src/cdk/a11y", + "//src/cdk/bidi", "//src/cdk/collections", "//src/cdk/keycodes", "@npm//@angular/forms", @@ -25,7 +25,6 @@ ng_test_library( ), deps = [ ":listbox", - "//src/cdk-experimental/combobox", "//src/cdk/keycodes", "//src/cdk/testing/private", "@npm//@angular/common", diff --git a/src/cdk-experimental/listbox/listbox.spec.ts b/src/cdk-experimental/listbox/listbox.spec.ts index 3f3221a7d478..9c30eb252cfd 100644 --- a/src/cdk-experimental/listbox/listbox.spec.ts +++ b/src/cdk-experimental/listbox/listbox.spec.ts @@ -3,7 +3,17 @@ import {Component, Type} from '@angular/core'; import {By} from '@angular/platform-browser'; import {CdkListbox, CdkListboxModule, CdkOption, ListboxValueChangeEvent} from './index'; import {dispatchKeyboardEvent, dispatchMouseEvent} from '../../cdk/testing/private'; -import {B, DOWN_ARROW, END, HOME, SPACE, UP_ARROW} from '@angular/cdk/keycodes'; +import { + A, + B, + DOWN_ARROW, + END, + HOME, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + UP_ARROW, +} from '@angular/cdk/keycodes'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {CommonModule} from '@angular/common'; @@ -132,6 +142,54 @@ describe('CdkOption and CdkListbox', () => { expect(fixture.componentInstance.changedOption?.id).toBe(options[0].id); }); + it('should select and deselect range on option SHIFT + click', async () => { + const {testComponent, fixture, listbox, optionEls} = await setupComponent(ListboxWithOptions); + testComponent.isMultiselectable = true; + fixture.detectChanges(); + + dispatchMouseEvent( + optionEls[1], + 'click', + undefined, + undefined, + undefined, + undefined, + undefined, + {shift: true}, + ); + fixture.detectChanges(); + + expect(listbox.value).toEqual(['orange']); + + dispatchMouseEvent( + optionEls[3], + 'click', + undefined, + undefined, + undefined, + undefined, + undefined, + {shift: true}, + ); + fixture.detectChanges(); + + expect(listbox.value).toEqual(['orange', 'banana', 'peach']); + + dispatchMouseEvent( + optionEls[2], + 'click', + undefined, + undefined, + undefined, + undefined, + undefined, + {shift: true}, + ); + fixture.detectChanges(); + + expect(listbox.value).toEqual(['orange']); + }); + it('should update on option activated via keyboard', async () => { const {fixture, listbox, listboxEl, options, optionEls} = await setupComponent( ListboxWithOptions, @@ -260,20 +318,19 @@ describe('CdkOption and CdkListbox', () => { expect(options[1].isSelected()).toBeFalse(); }); - // TODO(mmalerba): Fix this case. - // Currently banana gets booted because the option isn't loaded yet, - // but then when the option loads the value is already lost. - // it('should allow binding to listbox value', async () => { - // const {testComponent, fixture, listbox, options} = await setupComponent(ListboxWithBoundValue); - // expect(listbox.value).toEqual(['banana']); - // expect(options[2].isSelected()).toBeTrue(); - // - // testComponent.value = ['orange']; - // fixture.detectChanges(); - // - // expect(listbox.value).toEqual(['orange']); - // expect(options[1].isSelected()).toBeTrue(); - // }); + it('should allow binding to listbox value', async () => { + const {testComponent, fixture, listbox, options} = await setupComponent( + ListboxWithBoundValue, + ); + expect(listbox.value).toEqual(['banana']); + expect(options[2].isSelected()).toBeTrue(); + + testComponent.value = ['orange']; + fixture.detectChanges(); + + expect(listbox.value).toEqual(['orange']); + expect(options[1].isSelected()).toBeTrue(); + }); }); describe('disabled state', () => { @@ -387,6 +444,36 @@ describe('CdkOption and CdkListbox', () => { expect(options[2].isActive()).toBeTrue(); }); + + it('should not skip disabled options when navigating with arrow keys when skipping is turned off', async () => { + const {testComponent, fixture, listbox, listboxEl, options} = await setupComponent( + ListboxWithOptions, + ); + testComponent.navigationSkipsDisabled = false; + testComponent.isOrangeDisabled = true; + listbox.focus(); + fixture.detectChanges(); + + expect(options[0].isActive()).toBeTrue(); + + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(options[1].isActive()).toBeTrue(); + }); + + it('should not select disabled options with CONTROL + A', async () => { + const {testComponent, fixture, listbox, listboxEl} = await setupComponent(ListboxWithOptions); + testComponent.isMultiselectable = true; + testComponent.isOrangeDisabled = true; + fixture.detectChanges(); + + listbox.focus(); + dispatchKeyboardEvent(listboxEl, 'keydown', A, undefined, {control: true}); + fixture.detectChanges(); + + expect(listbox.value).toEqual(['apple', 'banana', 'peach']); + }); }); describe('compare with', () => { @@ -482,7 +569,138 @@ describe('CdkOption and CdkListbox', () => { expect(fixture.componentInstance.changedOption?.id).toBe(options[1].id); }); - // TODO(mmalerba): ensure all keys covered + it('should update active item on arrow key presses in horizontal mode', async () => { + const {testComponent, fixture, listbox, listboxEl, options} = await setupComponent( + ListboxWithOptions, + ); + testComponent.orientation = 'horizontal'; + fixture.detectChanges(); + + expect(listboxEl.getAttribute('aria-orientation')).toBe('horizontal'); + + listbox.focus(); + dispatchKeyboardEvent(listboxEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(options[1].isActive()).toBeTrue(); + + dispatchKeyboardEvent(listboxEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(options[0].isActive()).toBeTrue(); + }); + + it('should select and deselect all option with CONTROL + A', async () => { + const {testComponent, fixture, listbox, listboxEl} = await setupComponent(ListboxWithOptions); + testComponent.isMultiselectable = true; + fixture.detectChanges(); + + listbox.focus(); + dispatchKeyboardEvent(listboxEl, 'keydown', A, undefined, {control: true}); + fixture.detectChanges(); + + expect(listbox.value).toEqual(['apple', 'orange', 'banana', 'peach']); + + dispatchKeyboardEvent(listboxEl, 'keydown', A, undefined, {control: true}); + fixture.detectChanges(); + + expect(listbox.value).toEqual([]); + }); + + it('should select and deselect range with CONTROL + SPACE', async () => { + const {testComponent, fixture, listbox, listboxEl} = await setupComponent(ListboxWithOptions); + testComponent.isMultiselectable = true; + fixture.detectChanges(); + + listbox.focus(); + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(listboxEl, 'keydown', SPACE, undefined, {shift: true}); + fixture.detectChanges(); + + expect(listbox.value).toEqual(['orange']); + + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(listboxEl, 'keydown', SPACE, undefined, {shift: true}); + fixture.detectChanges(); + + expect(listbox.value).toEqual(['orange', 'banana', 'peach']); + + dispatchKeyboardEvent(listboxEl, 'keydown', UP_ARROW); + dispatchKeyboardEvent(listboxEl, 'keydown', SPACE, undefined, {shift: true}); + + expect(listbox.value).toEqual(['orange']); + }); + + it('should select and deselect range with CONTROL + SHIFT + HOME', async () => { + const {testComponent, fixture, listbox, listboxEl} = await setupComponent(ListboxWithOptions); + testComponent.isMultiselectable = true; + listbox.focus(); + fixture.detectChanges(); + + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(listboxEl, 'keydown', HOME, undefined, {control: true, shift: true}); + + expect(listbox.value).toEqual(['apple', 'orange', 'banana']); + + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(listboxEl, 'keydown', HOME, undefined, {control: true, shift: true}); + + expect(listbox.value).toEqual([]); + }); + + it('should select and deselect range with CONTROL + SHIFT + END', async () => { + const {testComponent, fixture, listbox, listboxEl} = await setupComponent(ListboxWithOptions); + testComponent.isMultiselectable = true; + listbox.focus(); + fixture.detectChanges(); + + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(listboxEl, 'keydown', END, undefined, {control: true, shift: true}); + + expect(listbox.value).toEqual(['orange', 'banana', 'peach']); + + dispatchKeyboardEvent(listboxEl, 'keydown', UP_ARROW); + dispatchKeyboardEvent(listboxEl, 'keydown', UP_ARROW); + dispatchKeyboardEvent(listboxEl, 'keydown', END, undefined, {control: true, shift: true}); + + expect(listbox.value).toEqual([]); + }); + + it('should wrap navigation when wrapping is enabled', async () => { + const {fixture, listbox, listboxEl, options} = await setupComponent(ListboxWithOptions); + listbox.focus(); + dispatchKeyboardEvent(listboxEl, 'keydown', END); + fixture.detectChanges(); + + expect(options[options.length - 1].isActive()).toBeTrue(); + + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(options[0].isActive()).toBeTrue(); + }); + + it('should not wrap navigation when wrapping is not enabled', async () => { + const {testComponent, fixture, listbox, listboxEl, options} = await setupComponent( + ListboxWithOptions, + ); + testComponent.navigationWraps = false; + fixture.detectChanges(); + + listbox.focus(); + dispatchKeyboardEvent(listboxEl, 'keydown', END); + fixture.detectChanges(); + + expect(options[options.length - 1].isActive()).toBeTrue(); + + dispatchKeyboardEvent(listboxEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(options[options.length - 1].isActive()).toBeTrue(); + }); }); describe('with roving tabindex', () => { @@ -639,15 +857,15 @@ describe('CdkOption and CdkListbox', () => { subscription.unsubscribe(); }); - it('should have FormControl error multiple values selected in single-select listbox', async () => { + it('should have FormControl error when multiple values selected in single-select listbox', async () => { const {testComponent, fixture} = await setupComponent(ListboxWithFormControl, [ ReactiveFormsModule, ]); testComponent.formControl.setValue(['orange', 'banana']); fixture.detectChanges(); - expect(testComponent.formControl.hasError('cdkListboxMultipleValues')).toBeTrue(); - expect(testComponent.formControl.hasError('cdkListboxInvalidValues')).toBeFalse(); + expect(testComponent.formControl.hasError('cdkListboxUnexpectedMultipleValues')).toBeTrue(); + expect(testComponent.formControl.hasError('cdkListboxUnexpectedOptionValues')).toBeFalse(); }); it('should have FormControl error when non-option value selected', async () => { @@ -658,9 +876,9 @@ describe('CdkOption and CdkListbox', () => { testComponent.formControl.setValue(['orange', 'dragonfruit', 'mango']); fixture.detectChanges(); - expect(testComponent.formControl.hasError('cdkListboxInvalidValues')).toBeTrue(); - expect(testComponent.formControl.hasError('cdkListboxMultipleValues')).toBeFalse(); - expect(testComponent.formControl.errors?.['cdkListboxInvalidValues']).toEqual({ + expect(testComponent.formControl.hasError('cdkListboxUnexpectedOptionValues')).toBeTrue(); + expect(testComponent.formControl.hasError('cdkListboxUnexpectedMultipleValues')).toBeFalse(); + expect(testComponent.formControl.errors?.['cdkListboxUnexpectedOptionValues']).toEqual({ 'values': ['dragonfruit', 'mango'], }); }); @@ -672,9 +890,9 @@ describe('CdkOption and CdkListbox', () => { testComponent.formControl.setValue(['dragonfruit', 'mango']); fixture.detectChanges(); - expect(testComponent.formControl.hasError('cdkListboxInvalidValues')).toBeTrue(); - expect(testComponent.formControl.hasError('cdkListboxMultipleValues')).toBeTrue(); - expect(testComponent.formControl.errors?.['cdkListboxInvalidValues']).toEqual({ + expect(testComponent.formControl.hasError('cdkListboxUnexpectedOptionValues')).toBeTrue(); + expect(testComponent.formControl.hasError('cdkListboxUnexpectedMultipleValues')).toBeTrue(); + expect(testComponent.formControl.errors?.['cdkListboxUnexpectedOptionValues']).toEqual({ 'values': ['dragonfruit', 'mango'], }); }); @@ -689,30 +907,37 @@ describe('CdkOption and CdkListbox', () => { [cdkListboxMultiple]="isMultiselectable" [cdkListboxDisabled]="isListboxDisabled" [cdkListboxUseActiveDescendant]="isActiveDescendant" + [cdkListboxOrientation]="orientation" + [cdkListboxKeyboardNavigationWraps]="navigationWraps" + [cdkListboxKeyboardNavigationSkipsDisabled]="navigationSkipsDisabled" (cdkListboxValueChange)="onSelectionChange($event)">
+ [cdkOptionDisabled]="isAppleDisabled" + [id]="appleId" + [tabindex]="appleTabindex"> Apple
-
Orange
+
Orange +
Banana
Peach
`, }) class ListboxWithOptions { - changedOption: CdkOption; + changedOption: CdkOption | null; isListboxDisabled = false; isAppleDisabled = false; isOrangeDisabled = false; isMultiselectable = false; isActiveDescendant = false; + navigationWraps = true; + navigationSkipsDisabled = true; listboxId: string; listboxTabindex: number; appleId: string; appleTabindex: number; + orientation: 'horizontal' | 'vertical' = 'vertical'; onSelectionChange(event: ListboxValueChangeEvent) { this.changedOption = event.option; @@ -755,20 +980,20 @@ class ListboxWithFormControl { }) class ListboxWithCustomTypeahead {} -// @Component({ -// template: ` -//
-//
Apple
-//
Orange
-//
Banana
-//
Peach
-//
-// `, -// }) -// class ListboxWithBoundValue { -// value = ['banana']; -// } +@Component({ + template: ` +
+
Apple
+
Orange
+
Banana
+
Peach
+
+ `, +}) +class ListboxWithBoundValue { + value = ['banana']; +} @Component({ template: ` diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index ca6c637a42e2..136494738290 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -21,11 +21,22 @@ import { QueryList, } from '@angular/core'; import {ActiveDescendantKeyManager, Highlightable, ListKeyManagerOption} from '@angular/cdk/a11y'; -import {DOWN_ARROW, ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, UP_ARROW} from '@angular/cdk/keycodes'; +import { + A, + DOWN_ARROW, + END, + ENTER, + hasModifierKey, + HOME, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + UP_ARROW, +} from '@angular/cdk/keycodes'; import {BooleanInput, coerceArray, coerceBooleanProperty} from '@angular/cdk/coercion'; import {SelectionModel} from '@angular/cdk/collections'; -import {BehaviorSubject, combineLatest, defer, merge, Observable, Subject} from 'rxjs'; -import {filter, mapTo, startWith, switchMap, take, takeUntil} from 'rxjs/operators'; +import {defer, merge, Observable, Subject} from 'rxjs'; +import {filter, map, startWith, switchMap, takeUntil} from 'rxjs/operators'; import { AbstractControl, ControlValueAccessor, @@ -37,14 +48,42 @@ import { Validators, } from '@angular/forms'; import {Directionality} from '@angular/cdk/bidi'; -import {CdkCombobox} from '@angular/cdk-experimental/combobox'; /** The next id to use for creating unique DOM IDs. */ let nextId = 0; -// TODO(mmalerba): -// - should listbox wrap be configurable? -// - should skipping disabled options be configurable? +/** + * An implementation of SelectionModel that internally always represents the selection as a + * multi-selection. This is necessary so that we can recover the full selection if the user + * switches the listbox from single-selection to multi-selection after initialization. + * + * This selection model may report multiple selected values, even if it is in single-selection + * mode. It is up to the user (CdkListbox) to check for invalid selections. + */ +class ListboxSelectionModel extends SelectionModel { + constructor( + public multiple = false, + initiallySelectedValues?: T[], + emitChanges = true, + compareWith?: (o1: T, o2: T) => boolean, + ) { + super(true, initiallySelectedValues, emitChanges, compareWith); + } + + override isMultipleSelection(): boolean { + return this.multiple; + } + + override select(...values: T[]) { + // The super class is always in multi-selection mode, so we need to override the behavior if + // this selection model actually belongs to a single-selection listbox. + if (this.multiple) { + return super.select(...values); + } else { + return super.setSelection(...values); + } + } +} /** A selectable option in a listbox. */ @Directive({ @@ -60,7 +99,7 @@ let nextId = 0; '[class.cdk-option-disabled]': 'disabled', '[class.cdk-option-active]': 'isActive()', '[class.cdk-option-selected]': 'isSelected()', - '(click)': '_clicked.next()', + '(click)': '_clicked.next($event)', '(focus)': '_handleFocus()', }, }) @@ -117,7 +156,7 @@ export class CdkOption implements ListKeyManagerOption, Highlightab protected destroyed = new Subject(); /** Emits when the option is clicked. */ - readonly _clicked = new Subject(); + readonly _clicked = new Subject(); /** Whether the option is currently active. */ private _active = false; @@ -129,7 +168,7 @@ export class CdkOption implements ListKeyManagerOption, Highlightab /** Whether this option is selected. */ isSelected() { - return this.listbox.isSelected(this.value); + return this.listbox.isSelected(this); } /** Whether this option is active. */ @@ -254,7 +293,7 @@ export class CdkListbox /** The value selected in the listbox, represented as an array of option values. */ @Input('cdkListboxValue') get value(): readonly T[] { - return this.selectionModel().selected; + return this._invalid ? [] : this.selectionModel.selected; } set value(value: readonly T[]) { this._setSelection(value); @@ -266,14 +305,12 @@ export class CdkListbox */ @Input('cdkListboxMultiple') get multiple(): boolean { - return this._multiple; + return this.selectionModel.multiple; } set multiple(value: BooleanInput) { - this._multiple = coerceBooleanProperty(value); - this._updateSelectionModel(); - this._onValidatorChange(); + this.selectionModel.multiple = coerceBooleanProperty(value); + this._updateInternalValue(); } - private _multiple: boolean = false; /** Whether the listbox is disabled. */ @Input('cdkListboxDisabled') @@ -296,18 +333,55 @@ export class CdkListbox private _useActiveDescendant: boolean = false; /** The orientation of the listbox. Only affects keyboard interaction, not visual layout. */ - @Input('cdkListboxOrientation') orientation: 'horizontal' | 'vertical' = 'vertical'; + @Input('cdkListboxOrientation') + get orientation() { + return this._orientation; + } + set orientation(value: 'horizontal' | 'vertical') { + this._orientation = value === 'horizontal' ? 'horizontal' : 'vertical'; + if (value === 'horizontal') { + this.listKeyManager?.withHorizontalOrientation(this._dir?.value || 'ltr'); + } else { + this.listKeyManager?.withVerticalOrientation(); + } + } + private _orientation: 'horizontal' | 'vertical' = 'vertical'; /** The function used to compare option values. */ @Input('cdkListboxCompareWith') get compareWith(): undefined | ((o1: T, o2: T) => boolean) { - return this._compareWith; + return this.selectionModel.compareWith; } set compareWith(fn: undefined | ((o1: T, o2: T) => boolean)) { - this._compareWith = fn; - this._updateSelectionModel(); + this.selectionModel.compareWith = fn; + } + + /** + * Whether the keyboard navigation should wrap when the user presses arrow down on the last item + * or arrow up on the first item. + */ + @Input('cdkListboxKeyboardNavigationWraps') + get keyboardNavigationWraps() { + return this._keyboardNavigationWraps; + } + set keyboardNavigationWraps(wrap: BooleanInput) { + this._keyboardNavigationWraps = coerceBooleanProperty(wrap); + this.listKeyManager?.withWrap(this._keyboardNavigationWraps); + } + private _keyboardNavigationWraps = true; + + /** Whether keyboard navigation should skip over disabled items. */ + @Input('cdkListboxKeyboardNavigationSkipsDisabled') + get keyboardNavigationSkipsDisabled() { + return this._keyboardNavigationSkipsDisabled; + } + set keyboardNavigationSkipsDisabled(skip: BooleanInput) { + this._keyboardNavigationSkipsDisabled = coerceBooleanProperty(skip); + this.listKeyManager?.skipPredicate( + this._keyboardNavigationSkipsDisabled ? this._skipDisabledPredicate : this._skipNonePredicate, + ); } - private _compareWith?: (o1: T, o2: T) => boolean; + private _keyboardNavigationSkipsDisabled = true; /** Emits when the selected value(s) in the listbox change. */ @Output('cdkListboxValueChange') readonly valueChange = new Subject>(); @@ -315,11 +389,8 @@ export class CdkListbox /** The child options in this listbox. */ @ContentChildren(CdkOption, {descendants: true}) protected options: QueryList>; - // TODO(mmalerba): Refactor SelectionModel so that its not necessary to create new instances /** The selection model used by the listbox. */ - protected selectionModelSubject = new BehaviorSubject( - new SelectionModel(this.multiple, [], true, this._compareWith), - ); + protected selectionModel = new ListboxSelectionModel(); /** The key manager that manages keyboard navigation for this listbox. */ protected listKeyManager: ActiveDescendantKeyManager>; @@ -333,6 +404,12 @@ export class CdkListbox /** The change detector for this listbox. */ protected readonly changeDetectorRef = inject(ChangeDetectorRef); + /** Whether the currently selected value in the selection model is invalid. */ + private _invalid = false; + + /** The last user-triggered option. */ + private _lastTriggered: CdkOption | null = null; + /** Callback called when the listbox has been touched */ private _onTouched = () => {}; @@ -346,15 +423,20 @@ export class CdkListbox private _optionClicked = defer(() => (this.options.changes as Observable[]>).pipe( startWith(this.options), - switchMap(options => merge(...options.map(option => option._clicked.pipe(mapTo(option))))), + switchMap(options => + merge(...options.map(option => option._clicked.pipe(map(event => ({option, event}))))), + ), ), ); /** The directionality of the page. */ private readonly _dir = inject(Directionality, InjectFlags.Optional); - // TODO(mmalerba): Should not depend on combobox - private readonly _combobox = inject(CdkCombobox, InjectFlags.Optional); + /** A predicate that skips disabled options. */ + private readonly _skipDisabledPredicate = (option: CdkOption) => option.disabled; + + /** A predicate that does not skip any options. */ + private readonly _skipNonePredicate = () => false; /** * Validator that produces an error if multiple values are selected in a single selection @@ -362,10 +444,10 @@ export class CdkListbox * @param control The control to validate * @return A validation error or null */ - private _validateMultipleValues: ValidatorFn = (control: AbstractControl) => { + private _validateUnexpectedMultipleValues: ValidatorFn = (control: AbstractControl) => { const controlValue = this._coerceValue(control.value); if (!this.multiple && controlValue.length > 1) { - return {'cdkListboxMultipleValues': true}; + return {'cdkListboxUnexpectedMultipleValues': true}; } return null; }; @@ -375,48 +457,47 @@ export class CdkListbox * @param control The control to validate * @return A validation error or null */ - private _validateInvalidValues: ValidatorFn = (control: AbstractControl) => { + private _validateUnexpectedOptionValues: ValidatorFn = (control: AbstractControl) => { const controlValue = this._coerceValue(control.value); - const invalidValues = this._getValuesWithValidity(controlValue, false); + const invalidValues = this._getInvalidOptionValues(controlValue); if (invalidValues.length) { - return {'cdkListboxInvalidValues': {'values': invalidValues}}; + return {'cdkListboxUnexpectedOptionValues': {'values': invalidValues}}; } return null; }; /** The combined set of validators for this listbox. */ private _validators = Validators.compose([ - this._validateMultipleValues, - this._validateInvalidValues, + this._validateUnexpectedMultipleValues, + this._validateUnexpectedOptionValues, ])!; constructor() { - this.selectionModelSubject - .pipe( - switchMap(selectionModel => selectionModel.changed), - takeUntil(this.destroyed), - ) - .subscribe(() => { - this._updateInternalValue(); - }); + // Update the internal value whenever the selection model's selection changes. + this.selectionModel.changed.pipe(startWith(null), takeUntil(this.destroyed)).subscribe(() => { + this._updateInternalValue(); + }); } ngAfterContentInit() { if (typeof ngDevMode === 'undefined' || ngDevMode) { this._verifyNoOptionValueCollisions(); } + this._initKeyManager(); - this._combobox?._registerContent(this.id, 'listbox'); - this.options.changes.pipe(takeUntil(this.destroyed)).subscribe(() => { + + // Update the internal value whenever the options change, as this may change the validity of + // the current selection + this.options.changes.pipe(startWith(this.options), takeUntil(this.destroyed)).subscribe(() => { this._updateInternalValue(); - this._onValidatorChange(); }); + this._optionClicked .pipe( - filter(option => !option.disabled), + filter(({option}) => !option.disabled), takeUntil(this.destroyed), ) - .subscribe(option => this._handleOptionClicked(option)); + .subscribe(({option, event}) => this._handleOptionClicked(option, event)); } ngOnDestroy() { @@ -438,7 +519,10 @@ export class CdkListbox * @param value The value to toggle */ toggleValue(value: T) { - this.selectionModel().toggle(value); + if (this._invalid) { + this.selectionModel.clear(false); + } + this.selectionModel.toggle(value); } /** @@ -454,7 +538,10 @@ export class CdkListbox * @param value The value to select */ selectValue(value: T) { - this.selectionModel().select(value); + if (this._invalid) { + this.selectionModel.clear(false); + } + this.selectionModel.select(value); } /** @@ -470,7 +557,10 @@ export class CdkListbox * @param value The value to deselect */ deselectValue(value: T) { - this.selectionModel().deselect(value); + if (this._invalid) { + this.selectionModel.clear(false); + } + this.selectionModel.deselect(value); } /** @@ -479,9 +569,12 @@ export class CdkListbox */ setAllSelected(isSelected: boolean) { if (!isSelected) { - this.selectionModel().clear(); + this.selectionModel.clear(); } else { - this.selectionModel().select(...this.options.toArray().map(option => option.value)); + if (this._invalid) { + this.selectionModel.clear(false); + } + this.selectionModel.select(...this.options.map(option => option.value)); } } @@ -489,8 +582,19 @@ export class CdkListbox * Get whether the given option is selected. * @param option The option to get the selected state of */ - isSelected(option: CdkOption | T) { - return this.selectionModel().isSelected(option instanceof CdkOption ? option.value : option); + isSelected(option: CdkOption) { + return this.isValueSelected(option.value); + } + + /** + * Get whether the given value is selected. + * @param value The value to get the selected state of + */ + isValueSelected(value: T) { + if (this._invalid) { + return false; + } + return this.selectionModel.isSelected(value); } /** @@ -551,11 +655,6 @@ export class CdkListbox this.element.focus(); } - /** The selection model used to track the listbox's value. */ - protected selectionModel() { - return this.selectionModelSubject.value; - } - /** * Triggers the given option in response to user interaction. * - In single selection mode: selects the option and deselects any other selected option. @@ -564,15 +663,10 @@ export class CdkListbox */ protected triggerOption(option: CdkOption | null) { if (option && !option.disabled) { - let changed = false; - this.selectionModel() - .changed.pipe(take(1), takeUntil(this.destroyed)) - .subscribe(() => (changed = true)); - if (this.multiple) { - this.toggle(option); - } else { - this.select(option); - } + this._lastTriggered = option; + const changed = this.multiple + ? this.selectionModel.toggle(option.value) + : this.selectionModel.select(option.value); if (changed) { this._onChange(this.value); this.valueChange.next({ @@ -584,6 +678,46 @@ export class CdkListbox } } + /** + * Trigger the given range of options in response to user interaction. + * Should only be called in multi-selection mode. + * @param trigger The option that was triggered + * @param from The start index of the options to toggle + * @param to The end index of the options to toggle + * @param on Whether to toggle the option range on + */ + protected triggerRange(trigger: CdkOption | null, from: number, to: number, on: boolean) { + if (this.disabled || (trigger && trigger.disabled)) { + return; + } + this._lastTriggered = trigger; + const isEqual = this.compareWith ?? Object.is; + const updateValues = [...this.options] + .slice(Math.max(0, Math.min(from, to)), Math.min(this.options.length, Math.max(from, to) + 1)) + .filter(option => !option.disabled) + .map(option => option.value); + const selected = [...this.value]; + for (const updateValue of updateValues) { + const selectedIndex = selected.findIndex(selectedValue => + isEqual(selectedValue, updateValue), + ); + if (on && selectedIndex === -1) { + selected.push(updateValue); + } else if (!on && selectedIndex !== -1) { + selected.splice(selectedIndex, 1); + } + } + let changed = this.selectionModel.setSelection(...selected); + if (changed) { + this._onChange(this.value); + this.valueChange.next({ + value: this.value, + listbox: this, + option: trigger, + }); + } + } + /** * Sets the given option as active. * @param option The option to make active @@ -608,21 +742,95 @@ export class CdkListbox const {keyCode} = event; const previousActiveIndex = this.listKeyManager.activeItemIndex; + const ctrlKeys = ['ctrlKey', 'metaKey'] as const; + + if (this.multiple && keyCode === A && hasModifierKey(event, ...ctrlKeys)) { + // Toggle all options off if they're all selected, otherwise toggle them all on. + this.triggerRange( + null, + 0, + this.options.length - 1, + this.options.length !== this.value.length, + ); + event.preventDefault(); + return; + } + + if ( + this.multiple && + (keyCode === SPACE || keyCode === ENTER) && + hasModifierKey(event, 'shiftKey') + ) { + if (this.listKeyManager.activeItem && this.listKeyManager.activeItemIndex != null) { + this.triggerRange( + this.listKeyManager.activeItem, + this._getLastTriggeredIndex() ?? this.listKeyManager.activeItemIndex, + this.listKeyManager.activeItemIndex, + !this.listKeyManager.activeItem.isSelected(), + ); + } + event.preventDefault(); + return; + } + + if ( + this.multiple && + keyCode === HOME && + hasModifierKey(event, ...ctrlKeys) && + hasModifierKey(event, 'shiftKey') + ) { + const trigger = this.listKeyManager.activeItem; + if (trigger) { + const from = this.listKeyManager.activeItemIndex!; + this.listKeyManager.setFirstItemActive(); + this.triggerRange( + trigger, + from, + this.listKeyManager.activeItemIndex!, + !trigger.isSelected(), + ); + } + event.preventDefault(); + return; + } + + if ( + this.multiple && + keyCode === END && + hasModifierKey(event, ...ctrlKeys) && + hasModifierKey(event, 'shiftKey') + ) { + const trigger = this.listKeyManager.activeItem; + if (trigger) { + const from = this.listKeyManager.activeItemIndex!; + this.listKeyManager.setLastItemActive(); + this.triggerRange( + trigger, + from, + this.listKeyManager.activeItemIndex!, + !trigger.isSelected(), + ); + } + event.preventDefault(); + return; + } if (keyCode === SPACE || keyCode === ENTER) { this.triggerOption(this.listKeyManager.activeItem); event.preventDefault(); - } else { - this.listKeyManager.onKeydown(event); + return; } - /** Will select an option if shift was pressed while navigating to the option */ - const isArrow = + const isNavKey = keyCode === UP_ARROW || keyCode === DOWN_ARROW || keyCode === LEFT_ARROW || - keyCode === RIGHT_ARROW; - if (isArrow && event.shiftKey && previousActiveIndex !== this.listKeyManager.activeItemIndex) { + keyCode === RIGHT_ARROW || + keyCode === HOME || + keyCode === END; + this.listKeyManager.onKeydown(event); + // Will select an option if shift was pressed while navigating to the option + if (isNavKey && event.shiftKey && previousActiveIndex !== this.listKeyManager.activeItemIndex) { this.triggerOption(this.listKeyManager.activeItem); } } @@ -654,10 +862,15 @@ export class CdkListbox /** Initialize the key manager. */ private _initKeyManager() { this.listKeyManager = new ActiveDescendantKeyManager(this.options) - .withWrap() + .withWrap(this._keyboardNavigationWraps) .withTypeAhead() .withHomeAndEnd() - .withAllowedModifierKeys(['shiftKey']); + .withAllowedModifierKeys(['shiftKey']) + .skipPredicate( + this._keyboardNavigationSkipsDisabled + ? this._skipDisabledPredicate + : this._skipNonePredicate, + ); if (this.orientation === 'vertical') { this.listKeyManager.withVerticalOrientation(); @@ -670,29 +883,6 @@ export class CdkListbox .subscribe(() => this._focusActiveOption()); } - // TODO(mmalerba): Should not depend on combobox. - private _updatePanelForSelection(option: CdkOption) { - if (this._combobox) { - if (!this.multiple) { - this._combobox.updateAndClose(option.isSelected() ? option.value : []); - } else { - this._combobox.updateAndClose(this.value); - } - } - } - - /** Update the selection mode when the 'multiple' property changes. */ - private _updateSelectionModel() { - this.selectionModelSubject.next( - new SelectionModel( - this.multiple, - !this.multiple && this.value.length > 1 ? [] : this.value.slice(), - true, - this._compareWith, - ), - ); - } - /** Focus the active option. */ private _focusActiveOption() { if (!this.useActiveDescendant) { @@ -706,29 +896,24 @@ export class CdkListbox * @param value The list of new selected values. */ private _setSelection(value: readonly T[]) { - const coercedValue = this._coerceValue(value); - this.selectionModel().setSelection( - ...(!this.multiple && coercedValue.length > 1 - ? [] - : this._getValuesWithValidity(coercedValue, true)), - ); + if (this._invalid) { + this.selectionModel.clear(false); + } + this.selectionModel.setSelection(...this._coerceValue(value)); } /** Update the internal value of the listbox based on the selection model. */ private _updateInternalValue() { const indexCache = new Map(); - // Check if we need to remove any values due to them becoming invalid - // (e.g. if the option was removed from the DOM.) - const selected = this.selectionModel().selected; - const validSelected = this._getValuesWithValidity(selected, true); - if (validSelected.length != selected.length) { - this.selectionModel().setSelection(...validSelected); - } - this.selectionModel().sort((a: T, b: T) => { + this.selectionModel.sort((a: T, b: T) => { const aIndex = this._getIndexForValue(indexCache, a); const bIndex = this._getIndexForValue(indexCache, b); return aIndex - bIndex; }); + const selected = this.selectionModel.selected; + this._invalid = + (!this.multiple && selected.length > 1) || !!this._getInvalidOptionValues(selected).length; + this._onValidatorChange(); this.changeDetectorRef.markForCheck(); } @@ -757,18 +942,23 @@ export class CdkListbox * Handle the user clicking an option. * @param option The option that was clicked. */ - private _handleOptionClicked(option: CdkOption) { + private _handleOptionClicked(option: CdkOption, event: MouseEvent) { this.listKeyManager.setActiveItem(option); - this.triggerOption(option); - this._updatePanelForSelection(option); + if (event.shiftKey && this.multiple) { + this.triggerRange( + option, + this._getLastTriggeredIndex() ?? this.listKeyManager.activeItemIndex!, + this.listKeyManager.activeItemIndex!, + !option.isSelected(), + ); + } else { + this.triggerOption(option); + } } /** Verifies that no two options represent the same value under the compareWith function. */ private _verifyNoOptionValueCollisions() { - combineLatest([ - this.selectionModelSubject, - this.options.changes.pipe(startWith(this.options)), - ]).subscribe(() => { + this.options.changes.pipe(startWith(this.options), takeUntil(this.destroyed)).subscribe(() => { const isEqual = this.compareWith ?? Object.is; for (let i = 0; i < this.options.length; i++) { const option = this.options.get(i)!; @@ -813,17 +1003,20 @@ export class CdkListbox } /** - * Get the sublist of values with the given validity. + * Get the sublist of values that do not represent valid option values in this listbox. * @param values The list of values - * @param valid Whether to get valid values - * @return The sublist of values with the requested validity + * @return The sublist of values that are not valid option values */ - private _getValuesWithValidity(values: readonly T[], valid: boolean) { + private _getInvalidOptionValues(values: readonly T[]) { const isEqual = this.compareWith || Object.is; const validValues = (this.options || []).map(option => option.value); - return values.filter( - value => valid === validValues.some(validValue => isEqual(value, validValue)), - ); + return values.filter(value => !validValues.some(validValue => isEqual(value, validValue))); + } + + /** Get the index of the last triggered option. */ + private _getLastTriggeredIndex() { + const index = this.options.toArray().indexOf(this._lastTriggered!); + return index === -1 ? null : index; } } @@ -836,5 +1029,5 @@ export interface ListboxValueChangeEvent { readonly listbox: CdkListbox; /** Reference to the option that was triggered. */ - readonly option: CdkOption; + readonly option: CdkOption | null; } diff --git a/src/cdk/collections/selection-model.ts b/src/cdk/collections/selection-model.ts index 0324b90f5ac3..c5ac4daff494 100644 --- a/src/cdk/collections/selection-model.ts +++ b/src/cdk/collections/selection-model.ts @@ -40,7 +40,7 @@ export class SelectionModel { private _multiple = false, initiallySelectedValues?: T[], private _emitChanges = true, - private _compareWith?: (o1: T, o2: T) => boolean, + public compareWith?: (o1: T, o2: T) => boolean, ) { if (initiallySelectedValues && initiallySelectedValues.length) { if (_multiple) { @@ -56,23 +56,39 @@ export class SelectionModel { /** * Selects a value or an array of values. + * @param values The values to select + * @return Whether the selection changed as a result of this call + * @breaking-change 16.0.0 make return type boolean */ - select(...values: T[]): void { + select(...values: T[]): boolean | void { this._verifyValueAssignment(values); values.forEach(value => this._markSelected(value)); + const changed = this._hasQueuedChanges(); this._emitChangeEvent(); + return changed; } /** * Deselects a value or an array of values. + * @param values The values to deselect + * @return Whether the selection changed as a result of this call + * @breaking-change 16.0.0 make return type boolean */ - deselect(...values: T[]): void { + deselect(...values: T[]): boolean | void { this._verifyValueAssignment(values); values.forEach(value => this._unmarkSelected(value)); + const changed = this._hasQueuedChanges(); this._emitChangeEvent(); + return changed; } - setSelection(...values: T[]): void { + /** + * Sets the selected values + * @param values The new selected values + * @return Whether the selection changed as a result of this call + * @breaking-change 16.0.0 make return type boolean + */ + setSelection(...values: T[]): boolean | void { this._verifyValueAssignment(values); const oldValues = this.selected; const newSelectedSet = new Set(values); @@ -80,31 +96,44 @@ export class SelectionModel { oldValues .filter(value => !newSelectedSet.has(value)) .forEach(value => this._unmarkSelected(value)); + const changed = this._hasQueuedChanges(); this._emitChangeEvent(); + return changed; } /** * Toggles a value between selected and deselected. + * @param value The value to toggle + * @return Whether the selection changed as a result of this call + * @breaking-change 16.0.0 make return type boolean */ - toggle(value: T): void { - this.isSelected(value) ? this.deselect(value) : this.select(value); + toggle(value: T): boolean | void { + return this.isSelected(value) ? this.deselect(value) : this.select(value); } /** * Clears all of the selected values. + * @param flushEvent Whether to flush the changes in an event. + * If false, the changes to the selection will be flushed along with the next event. + * @return Whether the selection changed as a result of this call + * @breaking-change 16.0.0 make return type boolean */ - clear(): void { + clear(flushEvent = true): boolean | void { this._unmarkAll(); - this._emitChangeEvent(); + const changed = this._hasQueuedChanges(); + if (flushEvent) { + this._emitChangeEvent(); + } + return changed; } /** * Determines whether a value is selected. */ isSelected(value: T): boolean { - if (this._compareWith) { + if (this.compareWith) { for (const otherValue of this._selection) { - if (this._compareWith(otherValue, value)) { + if (this.compareWith(otherValue, value)) { return true; } } @@ -204,6 +233,11 @@ export class SelectionModel { throw getMultipleValuesInSingleSelectionError(); } } + + /** Whether there are queued up change to be emitted. */ + private _hasQueuedChanges() { + return !!(this._deselectedToEmit.length || this._selectedToEmit.length); + } } /** diff --git a/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html index ece38d0bf626..5cfddeb741d8 100644 --- a/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html +++ b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html @@ -5,10 +5,11 @@

formControl

[cdkListboxMultiple]="multiSelectable" [cdkListboxUseActiveDescendant]="activeDescendant" [cdkListboxCompareWith]="compare" + [cdkListboxKeyboardNavigationSkipsDisabled]="skipDisabled" [formControl]="fruitControl">
  • Apple
  • Orange
  • -
  • Grapefruit
  • +
  • Grapefruit
  • Peach
  • Kiwi
  • @@ -30,10 +31,11 @@

    ngModel

    [cdkListboxUseActiveDescendant]="activeDescendant" [cdkListboxCompareWith]="compare" [cdkListboxDisabled]="fruitControl.disabled" + [cdkListboxKeyboardNavigationSkipsDisabled]="skipDisabled" [(ngModel)]="fruit">
  • Apple
  • Orange
  • -
  • Grapefruit
  • +
  • Grapefruit
  • Peach
  • @@ -83,3 +86,4 @@

    value binding

    }}
    + diff --git a/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts index 8ff808d681f1..f4da82563b38 100644 --- a/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts +++ b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts @@ -27,6 +27,7 @@ function dumbCompare(o1: string, o2: string) { export class CdkListboxDemo { multiSelectable = false; activeDescendant = true; + skipDisabled = true; compare?: (o1: string, o2: string) => boolean; fruitControl = new FormControl(); nativeFruitControl = new FormControl(); @@ -67,6 +68,10 @@ export class CdkListboxDemo { this.compare = this.compare ? undefined : dumbCompare; } + toggleSkipDisabled() { + this.skipDisabled = !this.skipDisabled; + } + onNativeFruitChange(event: Event) { this.nativeFruit = Array.from( (event.target as HTMLSelectElement).selectedOptions, diff --git a/tools/public_api_guard/cdk/collections.md b/tools/public_api_guard/cdk/collections.md index 6b422b4ced26..5fe8f1feec32 100644 --- a/tools/public_api_guard/cdk/collections.md +++ b/tools/public_api_guard/cdk/collections.md @@ -71,20 +71,21 @@ export interface SelectionChange { // @public export class SelectionModel { - constructor(_multiple?: boolean, initiallySelectedValues?: T[], _emitChanges?: boolean, _compareWith?: ((o1: T, o2: T) => boolean) | undefined); + constructor(_multiple?: boolean, initiallySelectedValues?: T[], _emitChanges?: boolean, compareWith?: ((o1: T, o2: T) => boolean) | undefined); readonly changed: Subject>; - clear(): void; - deselect(...values: T[]): void; + clear(flushEvent?: boolean): boolean | void; + // (undocumented) + compareWith?: ((o1: T, o2: T) => boolean) | undefined; + deselect(...values: T[]): boolean | void; hasValue(): boolean; isEmpty(): boolean; isMultipleSelection(): boolean; isSelected(value: T): boolean; - select(...values: T[]): void; + select(...values: T[]): boolean | void; get selected(): T[]; - // (undocumented) - setSelection(...values: T[]): void; + setSelection(...values: T[]): boolean | void; sort(predicate?: (a: T, b: T) => number): void; - toggle(value: T): void; + toggle(value: T): boolean | void; } // @public