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