diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4170974ada74..9ea4895af446 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -122,6 +122,7 @@ /src/material-experimental/mdc-typography/** @mmalerba /src/material-experimental/menubar/** @jelbourn @andy9775 /src/material-experimental/popover-edit/** @kseamon @andrewseguin +/src/material-experimental/selection/** @yifange @jelbourn # CDK experimental package /src/cdk-experimental/* @jelbourn @@ -132,6 +133,7 @@ /src/cdk-experimental/popover-edit/** @kseamon @andrewseguin /src/cdk-experimental/scrolling/** @mmalerba /src/cdk-experimental/listbox/** @nielsr98 @jelbourn +/src/cdk-experimental/selection/** @yifange @jelbourn # Docs examples & guides /guides/** @jelbourn @@ -215,6 +217,7 @@ /src/dev-app/typography/** @crisbeto /src/dev-app/virtual-scroll/** @mmalerba /src/dev-app/youtube-player/** @nathantate +/src/dev-app/selection/** @yifange @jelbourn # E2E app /src/e2e-app/* @jelbourn diff --git a/src/cdk-experimental/config.bzl b/src/cdk-experimental/config.bzl index ca3ffb5f404f..e69d1970a744 100644 --- a/src/cdk-experimental/config.bzl +++ b/src/cdk-experimental/config.bzl @@ -7,6 +7,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [ "listbox", "popover-edit", "scrolling", + "selection", ] # List of all entry-point targets of the Angular cdk-experimental package. diff --git a/src/cdk-experimental/selection/BUILD.bazel b/src/cdk-experimental/selection/BUILD.bazel new file mode 100644 index 000000000000..69d04d366acb --- /dev/null +++ b/src/cdk-experimental/selection/BUILD.bazel @@ -0,0 +1,38 @@ +load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "selection", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + module_name = "@angular/cdk-experimental/selection", + deps = [ + "//src/cdk/coercion", + "//src/cdk/collections", + "//src/cdk/table", + "@npm//@angular/core", + "@npm//@angular/forms", + "@npm//rxjs", + ], +) + +ng_test_library( + name = "unit_test_sources", + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":selection", + "//src/cdk/table", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/selection/index.ts b/src/cdk-experimental/selection/index.ts new file mode 100644 index 000000000000..e1fc5bfc0361 --- /dev/null +++ b/src/cdk-experimental/selection/index.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; + diff --git a/src/cdk-experimental/selection/public-api.ts b/src/cdk-experimental/selection/public-api.ts new file mode 100644 index 000000000000..423a022570cd --- /dev/null +++ b/src/cdk-experimental/selection/public-api.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './selection'; +export * from './select-all'; +export * from './selection-toggle'; +export * from './selection-column'; +export * from './row-selection'; +export * from './selection-set'; +export * from './selection-module'; diff --git a/src/cdk-experimental/selection/row-selection.ts b/src/cdk-experimental/selection/row-selection.ts new file mode 100644 index 000000000000..005b1fb2d82e --- /dev/null +++ b/src/cdk-experimental/selection/row-selection.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {coerceNumberProperty, NumberInput} from '@angular/cdk/coercion'; +import {Directive, Input} from '@angular/core'; + +import {CdkSelection} from './selection'; + +/** + * Applies `cdk-selected` class and `aria-selected` to an element. + * + * Must be used within a parent `CdkSelection` directive. + * Must be provided with the value. The index is required if `trackBy` is used on the `CdkSelection` + * directive. + */ +@Directive({ + selector: '[cdkRowSelection]', + host: { + '[class.cdk-selected]': '_selection.isSelected(this.value, this.index)', + '[attr.aria-selected]': '_selection.isSelected(this.value, this.index)', + }, +}) +export class CdkRowSelection { + @Input('cdkRowSelectionValue') value: T; + + @Input('cdkRowSelectionIndex') + get index(): number|undefined { return this._index; } + set index(index: number|undefined) { this._index = coerceNumberProperty(index); } + private _index?: number; + + constructor(readonly _selection: CdkSelection) {} + + static ngAcceptInputType_index: NumberInput; +} diff --git a/src/cdk-experimental/selection/select-all.ts b/src/cdk-experimental/selection/select-all.ts new file mode 100644 index 000000000000..000587cdc1fa --- /dev/null +++ b/src/cdk-experimental/selection/select-all.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, Inject, isDevMode, OnDestroy, OnInit, Optional, Self} from '@angular/core'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {Observable, of as observableOf, Subject} from 'rxjs'; +import {switchMap, takeUntil} from 'rxjs/operators'; + +import {CdkSelection} from './selection'; + +/** + * Makes the element a select-all toggle. + * + * Must be used within a parent `CdkSelection` directive. It toggles the selection states + * of all the selection toggles connected with the `CdkSelection` directive. + * If the element implements `ControlValueAccessor`, e.g. `MatCheckbox`, the directive + * automatically connects it with the select-all state provided by the `CdkSelection` directive. If + * not, use `checked$` to get the checked state, `indeterminate$` to get the indeterminate state, + * and `toggle()` to change the selection state. + */ +@Directive({ + selector: '[cdkSelectAll]', + exportAs: 'cdkSelectAll', +}) +export class CdkSelectAll implements OnDestroy, OnInit { + /** + * The checked state of the toggle. + * Resolves to `true` if all the values are selected, `false` if no value is selected. + */ + readonly checked: Observable = this._selection.change.pipe( + switchMap(() => observableOf(this._selection.isAllSelected())), + ); + + /** + * The indeterminate state of the toggle. + * Resolves to `true` if part (not all) of the values are selected, `false` if all values or no + * value at all are selected. + */ + readonly indeterminate: Observable = this._selection.change.pipe( + switchMap(() => observableOf(this._selection.isPartialSelected())), + ); + + /** + * Toggles the select-all state. + * @param event The click event if the toggle is triggered by a (mouse or keyboard) click. If + * using with a native ``, the parameter is required for the + * indeterminate state to work properly. + */ + toggle(event?: MouseEvent) { + // This is needed when applying the directive on a native + // checkbox. The default behavior needs to be prevented in order to support the indeterminate + // state. The timeout is also needed so the checkbox can show the latest state. + if (event) { + event.preventDefault(); + } + + setTimeout(() => { + this._selection.toggleSelectAll(); + }); + } + + private readonly _destroyed = new Subject(); + + constructor( + @Optional() @Inject(CdkSelection) private readonly _selection: CdkSelection, + @Optional() @Self() @Inject(NG_VALUE_ACCESSOR) private readonly _controlValueAccessor: + ControlValueAccessor[]) {} + + ngOnInit() { + this._assertValidParentSelection(); + this._configureControlValueAccessor(); + } + + private _configureControlValueAccessor() { + if (this._controlValueAccessor && this._controlValueAccessor.length) { + this._controlValueAccessor[0].registerOnChange((e: unknown) => { + if (e === true || e === false) { + this.toggle(); + } + }); + this.checked.pipe(takeUntil(this._destroyed)).subscribe((state) => { + this._controlValueAccessor[0].writeValue(state); + }); + } + } + + private _assertValidParentSelection() { + if (!this._selection && isDevMode()) { + throw Error('CdkSelectAll: missing CdkSelection in the parent'); + } + + if (!this._selection.multiple && isDevMode()) { + throw Error('CdkSelectAll: CdkSelection must have cdkSelectionMultiple set to true'); + } + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } +} diff --git a/src/cdk-experimental/selection/selection-column.ts b/src/cdk-experimental/selection/selection-column.ts new file mode 100644 index 000000000000..0e5a0bbd8426 --- /dev/null +++ b/src/cdk-experimental/selection/selection-column.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CdkCellDef, CdkColumnDef, CdkHeaderCellDef, CdkTable} from '@angular/cdk/table'; +import { + Component, + Input, + isDevMode, + OnDestroy, + OnInit, + Optional, + ViewChild, + ChangeDetectionStrategy, + ViewEncapsulation, + Inject, +} from '@angular/core'; + +import {CdkSelection} from './selection'; + +/** + * Column that adds row selecting checkboxes and a select-all checkbox if `cdkSelectionMultiple` is + * `true`. + * + * Must be used within a parent `CdkSelection` directive. + */ +@Component({ + selector: 'cdk-selection-column', + template: ` + + + + + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class CdkSelectionColumn implements OnInit, OnDestroy { + /** Column name that should be used to reference this column. */ + @Input('cdkSelectionColumnName') + get name(): string { + return this._name; + } + set name(name: string) { + this._name = name; + + this._syncColumnDefName(); + } + private _name: string; + + @ViewChild(CdkColumnDef, {static: true}) private readonly _columnDef: CdkColumnDef; + @ViewChild(CdkCellDef, {static: true}) private readonly _cell: CdkCellDef; + @ViewChild(CdkHeaderCellDef, {static: true}) private readonly _headerCell: CdkHeaderCellDef; + + constructor( + @Optional() @Inject(CdkTable) private _table: CdkTable, + @Optional() @Inject(CdkSelection) readonly selection: CdkSelection, + ) {} + + ngOnInit() { + if (!this.selection && isDevMode()) { + throw Error('CdkSelectionColumn: missing CdkSelection in the parent'); + } + + this._syncColumnDefName(); + + if (this._table) { + this._columnDef.cell = this._cell; + this._columnDef.headerCell = this._headerCell; + this._table.addColumnDef(this._columnDef); + } else { + if (isDevMode()) { + throw Error('CdkSelectionColumn: missing parent table'); + } + } + } + + ngOnDestroy() { + if (this._table) { + this._table.removeColumnDef(this._columnDef); + } + } + + private _syncColumnDefName() { + if (this._columnDef) { + this._columnDef.name = this._name; + } + } +} diff --git a/src/cdk-experimental/selection/selection-module.ts b/src/cdk-experimental/selection/selection-module.ts new file mode 100644 index 000000000000..63ba7286b28d --- /dev/null +++ b/src/cdk-experimental/selection/selection-module.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CdkTableModule} from '@angular/cdk/table'; +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; + +import {CdkRowSelection} from './row-selection'; +import {CdkSelectAll} from './select-all'; +import {CdkSelection} from './selection'; +import {CdkSelectionColumn} from './selection-column'; +import {CdkSelectionToggle} from './selection-toggle'; + +@NgModule({ + imports: [ + CommonModule, + CdkTableModule, + ], + exports: [ + CdkSelection, + CdkSelectionToggle, + CdkSelectAll, + CdkSelectionColumn, + CdkRowSelection, + ], + declarations: [ + CdkSelection, + CdkSelectionToggle, + CdkSelectAll, + CdkSelectionColumn, + CdkRowSelection, + ], +}) +export class CdkSelectionModule { +} diff --git a/src/cdk-experimental/selection/selection-set.ts b/src/cdk-experimental/selection/selection-set.ts new file mode 100644 index 000000000000..59ab89d5f1ad --- /dev/null +++ b/src/cdk-experimental/selection/selection-set.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {isDevMode, TrackByFunction} from '@angular/core'; +import {Subject} from 'rxjs'; + +/** + * Maintains a set of selected values. One or more values can be added to or removed from the + * selection. + */ +interface TrackBySelection { + isSelected(value: SelectableWithIndex): boolean; + select(...values: SelectableWithIndex[]): void; + deselect(...values: SelectableWithIndex[]): void; + changed: Subject>; +} + +/** + * A selectable value with an optional index. The index is required when the selection is used with + * `trackBy`. + */ +export interface SelectableWithIndex { + value: T; + index?: number; +} + +/** + * Represents the change in the selection set. + */ +export interface SelectionChange { + before: SelectableWithIndex[]; + after: SelectableWithIndex[]; +} + +/** + * Maintains a set of selected items. Support selecting and deselecting items, and checking if a + * value is selected. + * When constructed with a `trackByFn`, all the items will be identified by applying the `trackByFn` + * on them. Because `trackByFn` requires the index of the item to be passed in, the `index` field is + * expected to be set when calling `isSelected`, `select` and `deselect`. + */ +export class SelectionSet implements TrackBySelection { + private _selectionMap = new Map>, SelectableWithIndex>(); + changed = new Subject>(); + + constructor(private _multiple = false, private _trackByFn?: TrackByFunction) {} + + isSelected(value: SelectableWithIndex): boolean { + return this._selectionMap.has(this._getTrackedByValue(value)); + } + + select(...selects: SelectableWithIndex[]) { + if (!this._multiple && selects.length > 1 && isDevMode()) { + throw Error('SelectionSet: not multiple selection'); + } + + const before = this._getCurrentSelection(); + + if (!this._multiple) { + this._selectionMap.clear(); + } + + const toSelect: SelectableWithIndex[] = []; + for (const select of selects) { + if (this.isSelected(select)) { + continue; + } + + toSelect.push(select); + this._markSelected(this._getTrackedByValue(select), select); + } + + const after = this._getCurrentSelection(); + + this.changed.next({before, after}); + } + + deselect(...selects: SelectableWithIndex[]) { + if (!this._multiple && selects.length > 1 && isDevMode()) { + throw Error('SelectionSet: not multiple selection'); + } + + const before = this._getCurrentSelection(); + const toDeselect: SelectableWithIndex[] = []; + + for (const select of selects) { + if (!this.isSelected(select)) { + continue; + } + + toDeselect.push(select); + this._markDeselected(this._getTrackedByValue(select)); + } + + const after = this._getCurrentSelection(); + this.changed.next({before, after}); + } + + private _markSelected(key: T|ReturnType>, toSelect: SelectableWithIndex) { + this._selectionMap.set(key, toSelect); + } + + private _markDeselected(key: T|ReturnType>) { + this._selectionMap.delete(key); + } + + private _getTrackedByValue(select: SelectableWithIndex) { + if (!this._trackByFn) { + return select.value; + } + + if (select.index == null && isDevMode()) { + throw Error('SelectionSet: index required when trackByFn is used.'); + } + + return this._trackByFn(select.index!, select.value); + } + + private _getCurrentSelection(): SelectableWithIndex[] { + return Array.from(this._selectionMap.values()); + } +} diff --git a/src/cdk-experimental/selection/selection-toggle.ts b/src/cdk-experimental/selection/selection-toggle.ts new file mode 100644 index 000000000000..0d2220af0dea --- /dev/null +++ b/src/cdk-experimental/selection/selection-toggle.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {coerceNumberProperty, NumberInput} from '@angular/cdk/coercion'; +import { + Directive, + Inject, + Input, + isDevMode, + OnDestroy, + OnInit, + Optional, + Self +} from '@angular/core'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {Observable, of as observableOf, Subject} from 'rxjs'; +import {distinctUntilChanged, switchMap, takeUntil} from 'rxjs/operators'; + +import {CdkSelection} from './selection'; + +/** + * Makes the element a selection toggle. + * + * Must be used within a parent `CdkSelection` directive. + * Must be provided with the value. If `trackBy` is used on `CdkSelection`, the index of the value + * is required. If the element implements `ControlValueAccessor`, e.g. `MatCheckbox`, the directive + * automatically connects it with the selection state provided by the `CdkSelection` directive. If + * not, use `checked$` to get the checked state of the value, and `toggle()` to change the selection + * state. + */ +@Directive({ + selector: '[cdkSelectionToggle]', + exportAs: 'cdkSelectionToggle', +}) +export class CdkSelectionToggle implements OnDestroy, OnInit { + /** The value that is associated with the toggle */ + @Input('cdkSelectionToggleValue') value: T; + + /** The index of the value in the list. Required when used with `trackBy` */ + @Input('cdkSelectionToggleIndex') + get index(): number|undefined { return this._index; } + set index(index: number|undefined) { this._index = coerceNumberProperty(index); } + private _index?: number; + + /** The checked state of the selection toggle */ + readonly checked: Observable = this._selection.change.pipe( + switchMap(() => observableOf(this._isSelected())), + distinctUntilChanged(), + ); + + /** Toggles the selection */ + toggle() { + this._selection.toggleSelection(this.value, this.index); + } + + private _destroyed = new Subject(); + + constructor( + @Optional() @Inject(CdkSelection) private _selection: CdkSelection, + @Optional() @Self() @Inject(NG_VALUE_ACCESSOR) private _controlValueAccessors: + ControlValueAccessor[], + ) {} + + ngOnInit() { + this._assertValidParentSelection(); + this._configureControlValueAccessor(); + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + + private _assertValidParentSelection() { + if (!this._selection && isDevMode()) { + throw Error('CdkSelectAll: missing CdkSelection in the parent'); + } + } + + private _configureControlValueAccessor() { + if (this._controlValueAccessors && this._controlValueAccessors.length) { + this._controlValueAccessors[0].registerOnChange((e: unknown) => { + if (typeof e === 'boolean') { + this.toggle(); + } + }); + + this.checked.pipe(takeUntil(this._destroyed)).subscribe((state) => { + this._controlValueAccessors[0].writeValue(state); + }); + } + } + + private _isSelected(): boolean { + return this._selection.isSelected(this.value, this.index); + } + + static ngAcceptInputType_index: NumberInput; +} diff --git a/src/cdk-experimental/selection/selection.spec.ts b/src/cdk-experimental/selection/selection.spec.ts new file mode 100644 index 000000000000..2008cb443f38 --- /dev/null +++ b/src/cdk-experimental/selection/selection.spec.ts @@ -0,0 +1,691 @@ +import {CdkTableModule} from '@angular/cdk/table'; +import { + ChangeDetectorRef, + Component, + ElementRef, + ViewChild, +} from '@angular/core'; +import { + async, + ComponentFixture, + fakeAsync, + flush, + TestBed, +} from '@angular/core/testing'; + +import {CdkSelection} from './selection'; +import {CdkSelectionModule} from './selection-module'; +import {SelectionChange} from './selection-set'; + +describe('CdkSelection', () => { + let fixture: ComponentFixture; + let component: ListWithMultiSelection; + + beforeEach(async(() => { + TestBed + .configureTestingModule({ + imports: [CdkSelectionModule], + declarations: [ + ListWithMultiSelection, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ListWithMultiSelection); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('cdkSelection', () => { + it('should allow toggling selection', () => { + expect(component.cdkSelection.isSelected('apple', 0)).toBeFalsy(); + + component.cdkSelection.toggleSelection('apple', 0); + + expect(component.cdkSelection.isSelected('apple', 0)).toBeTruthy(); + }); + + it('should allow selecting all', () => { + expect(component.cdkSelection.isAllSelected()).toBeFalsy(); + + component.cdkSelection.toggleSelectAll(); + + expect(component.cdkSelection.isAllSelected()).toBeTruthy(); + }); + + it('should detect partial selection', () => { + expect(component.cdkSelection.isPartialSelected()).toBeFalsy(); + + component.cdkSelection.toggleSelectAll(); + + expect(component.cdkSelection.isPartialSelected()).toBeFalsy(); + + component.cdkSelection.toggleSelectAll(); + component.cdkSelection.toggleSelection('apple', 0); + + expect(component.cdkSelection.isPartialSelected()).toBeTruthy(); + }); + + it('should clear selection when partial selected and toggling select-all', () => { + component.cdkSelection.toggleSelection('apple', 0); + component.cdkSelection.toggleSelectAll(); + + expect(component.cdkSelection.isPartialSelected()).toBeFalsy(); + expect(component.cdkSelection.isAllSelected()).toBeFalsy(); + }); + }); + + describe('cdkSelectAll', () => { + it('should select all items when not all selected', fakeAsync(() => { + expect(component.cdkSelection.isAllSelected()).toBeFalsy(); + expect(component.getSelectAll().textContent.trim()).toBe('unchecked'); + + component.clickSelectAll(); + + expect(component.cdkSelection.isAllSelected()).toBeTruthy(); + expect(component.getSelectAll().textContent.trim()).toBe('checked'); + })); + + it('should de-select all items when all selected', fakeAsync(() => { + // Select all items. + component.clickSelectAll(); + + expect(component.cdkSelection.isAllSelected()).toBeTruthy(); + expect(component.getSelectAll().textContent.trim()).toBe('checked'); + + component.clickSelectAll(); + + expect(component.cdkSelection.isAllSelected()).toBeFalsy(); + expect(component.getSelectAll().textContent.trim()).toBe('unchecked'); + })); + + it('should de-select all items when partially selected', fakeAsync(() => { + // make the 1st item selected. + component.clickSelectionToggle(0); + + expect(component.cdkSelection.isPartialSelected()).toBeTruthy(); + expect(component.getSelectAll().textContent.trim()).toBe('indeterminate'); + + component.clickSelectAll(); + + expect(component.cdkSelection.isAllSelected()).toBeFalsy(); + expect(component.cdkSelection.isPartialSelected()).toBeFalsy(); + expect(component.getSelectAll().textContent.trim()).toBe('unchecked'); + })); + + it('should respond to selection toggle clicks', fakeAsync(() => { + // Start with no selection. + expect(component.cdkSelection.isAllSelected()).toBeFalsy(); + expect(component.getSelectAll().textContent.trim()).toBe('unchecked'); + + // Select the 1st item. + component.clickSelectionToggle(0); + + // Partially selected. + expect(component.cdkSelection.isAllSelected()).toBeFalsy(); + expect(component.cdkSelection.isPartialSelected()).toBeTruthy(); + expect(component.getSelectAll().textContent.trim()).toBe('indeterminate'); + + // Select the all the other items. + component.clickSelectionToggle(1); + component.clickSelectionToggle(2); + component.clickSelectionToggle(3); + + // Select-all shows all selected. + expect(component.cdkSelection.isAllSelected()).toBeTruthy(); + expect(component.cdkSelection.isPartialSelected()).toBeFalsy(); + expect(component.getSelectAll().textContent.trim()).toBe('checked'); + })); + + it('should emit the correct selection change events', fakeAsync(() => { + component.clickSelectAll(); + + expect(component.selectionChange!.before).toEqual([]); + expect(component.selectionChange!.after).toEqual([ + {value: 'apple', index: 0}, + {value: 'banana', index: 1}, + {value: 'cherry', index: 2}, + {value: 'durian', index: 3}, + ]); + + component.clickSelectAll(); + + expect(component.selectionChange!.before).toEqual([ + {value: 'apple', index: 0}, + {value: 'banana', index: 1}, + {value: 'cherry', index: 2}, + {value: 'durian', index: 3}, + ]); + expect(component.selectionChange!.after).toEqual([]); + })); + }); + + describe('cdkSelectionToggle', () => { + it('should respond to select-all toggle click', fakeAsync(() => { + // All items not unchecked. + expect(component.getSelectionToggle(0).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(1).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(2).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(3).textContent.trim()).toBe('unchecked'); + + component.clickSelectAll(); + + // Everything selected. + expect(component.getSelectionToggle(0).textContent.trim()).toBe('checked'); + expect(component.getSelectionToggle(1).textContent.trim()).toBe('checked'); + expect(component.getSelectionToggle(2).textContent.trim()).toBe('checked'); + expect(component.getSelectionToggle(3).textContent.trim()).toBe('checked'); + + component.clickSelectAll(); + + // Everything unselected. + expect(component.getSelectionToggle(0).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(1).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(2).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(3).textContent.trim()).toBe('unchecked'); + })); + + it('should select unselected item when clicked', fakeAsync(() => { + expect(component.cdkSelection.isSelected('apple', 0)).toBeFalsy(); + expect(component.getSelectionToggle(0).textContent.trim()).toBe('unchecked'); + + component.clickSelectionToggle(0); + + expect(component.cdkSelection.isSelected('apple', 0)).toBeTruthy(); + expect(component.getSelectionToggle(0).textContent.trim()).toBe('checked'); + + // And all the others are not affected. + expect(component.cdkSelection.isSelected('banana', 1)).toBeFalsy(); + expect(component.cdkSelection.isSelected('cherry', 2)).toBeFalsy(); + expect(component.cdkSelection.isSelected('durian', 3)).toBeFalsy(); + expect(component.getSelectionToggle(1).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(2).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(3).textContent.trim()).toBe('unchecked'); + })); + + it('should de-selected selected item when clicked', fakeAsync(() => { + // Make all items selected. + component.clickSelectAll(); + + component.clickSelectionToggle(1); + + expect(component.cdkSelection.isSelected('banana', 1)).toBeFalsy(); + expect(component.getSelectionToggle(1).textContent.trim()).toBe('unchecked'); + + // And all the others are not affected. + expect(component.cdkSelection.isSelected('apple', 0)).toBeTruthy(); + expect(component.cdkSelection.isSelected('cherry', 2)).toBeTruthy(); + expect(component.cdkSelection.isSelected('durian', 3)).toBeTruthy(); + expect(component.getSelectionToggle(0).textContent.trim()).toBe('checked'); + expect(component.getSelectionToggle(2).textContent.trim()).toBe('checked'); + expect(component.getSelectionToggle(3).textContent.trim()).toBe('checked'); + })); + + it('should emit the correct selection change events', fakeAsync(() => { + component.clickSelectionToggle(1); + + expect(component.selectionChange!.before).toEqual([]); + expect(component.selectionChange!.after).toEqual([ + {value: 'banana', index: 1}, + ]); + + component.clickSelectionToggle(2); + + expect(component.selectionChange!.before).toEqual([ + {value: 'banana', index: 1}, + ]); + expect(component.selectionChange!.after).toEqual([ + {value: 'banana', index: 1}, + {value: 'cherry', index: 2}, + ]); + + component.clickSelectionToggle(2); + + expect(component.selectionChange!.before).toEqual([ + {value: 'banana', index: 1}, + {value: 'cherry', index: 2}, + ]); + expect(component.selectionChange!.after).toEqual([ + {value: 'banana', index: 1}, + ]); + })); + }); +}); + +describe('CdkSelection with multiple = false', () => { + let fixture: ComponentFixture; + let component: ListWithSingleSelection; + + beforeEach(async(() => { + TestBed + .configureTestingModule({ + imports: [CdkSelectionModule], + declarations: [ + ListWithSingleSelection, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ListWithSingleSelection); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should uncheck the previous selection when selecting new item', fakeAsync(() => { + // Everything start as unchecked. + expect(component.getSelectionToggle(0).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(1).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(2).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(3).textContent.trim()).toBe('unchecked'); + + component.clickSelectionToggle(0); + + expect(component.getSelectionToggle(0).textContent.trim()).toBe('checked'); + expect(component.getSelectionToggle(1).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(2).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(3).textContent.trim()).toBe('unchecked'); + + component.clickSelectionToggle(1); + + // Should uncheck the previous selection while selecting the new value. + expect(component.getSelectionToggle(0).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(1).textContent.trim()).toBe('checked'); + expect(component.getSelectionToggle(2).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(3).textContent.trim()).toBe('unchecked'); + + component.clickSelectionToggle(1); + + // Selecting a selected value should still uncheck it. + expect(component.getSelectionToggle(0).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(1).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(2).textContent.trim()).toBe('unchecked'); + expect(component.getSelectionToggle(3).textContent.trim()).toBe('unchecked'); + })); + + it('should emit the correct selection change events', fakeAsync(() => { + component.clickSelectionToggle(1); + + expect(component.selectionChange!.before).toEqual([]); + expect(component.selectionChange!.after).toEqual([ + {value: 'banana', index: 1}, + ]); + + component.clickSelectionToggle(2); + + expect(component.selectionChange!.before).toEqual([ + {value: 'banana', index: 1}, + ]); + expect(component.selectionChange!.after).toEqual([ + {value: 'cherry', index: 2}, + ]); + + component.clickSelectionToggle(2); + + expect(component.selectionChange!.before).toEqual([ + {value: 'cherry', index: 2}, + ]); + expect(component.selectionChange!.after).toEqual([]); + })); +}); + +describe('cdkSelectionColumn', () => { + let fixture: ComponentFixture; + let component: MultiSelectTableWithSelectionColumn; + + beforeEach(async(() => { + TestBed + .configureTestingModule({ + imports: [ + CdkSelectionModule, + CdkTableModule, + ], + declarations: [ + MultiSelectTableWithSelectionColumn, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MultiSelectTableWithSelectionColumn); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should show check boxes', () => { + const checkboxes = + component.elementRef.nativeElement.querySelectorAll('input[type="checkbox"]'); + // Select-all toggle + each toggle per row. + expect(checkboxes.length).toBe(5); + }); + + it('should allow select all', fakeAsync(() => { + expect(component.getSelectAll().checked).toBe(false); + expect(component.getSelectionToggle(0).checked).toBe(false); + expect(component.getSelectionToggle(1).checked).toBe(false); + expect(component.getSelectionToggle(2).checked).toBe(false); + expect(component.getSelectionToggle(3).checked).toBe(false); + + component.clickSelectAll(); + + expect(component.getSelectAll().checked).toBe(true); + expect(component.getSelectionToggle(0).checked).toBe(true); + expect(component.getSelectionToggle(1).checked).toBe(true); + expect(component.getSelectionToggle(2).checked).toBe(true); + expect(component.getSelectionToggle(3).checked).toBe(true); + })); + + it('should allow toggle rows', fakeAsync(() => { + expect(component.getSelectAll().checked).toBe(false); + expect(component.getSelectAll().indeterminate).toBe(false); + expect(component.getSelectionToggle(0).checked).toBe(false); + + component.clickSelectionToggle(0); + + expect(component.getSelectAll().checked).toBe(false); + expect(component.getSelectAll().indeterminate).toBe(true); + expect(component.getSelectionToggle(0).checked).toBe(true); + + component.clickSelectionToggle(1); + component.clickSelectionToggle(2); + component.clickSelectionToggle(3); + + expect(component.getSelectAll().checked).toBe(true); + expect(component.getSelectAll().indeterminate).toBe(false); + expect(component.getSelectionToggle(1).checked).toBe(true); + expect(component.getSelectionToggle(2).checked).toBe(true); + expect(component.getSelectionToggle(3).checked).toBe(true); + })); + + describe('cdkRowSelection', () => { + it('should set .cdk-selected on selected rows', fakeAsync(() => { + expect(component.getRow(0).classList.contains('cdk-selected')).toBeFalsy(); + expect(component.getRow(1).classList.contains('cdk-selected')).toBeFalsy(); + expect(component.getRow(2).classList.contains('cdk-selected')).toBeFalsy(); + expect(component.getRow(3).classList.contains('cdk-selected')).toBeFalsy(); + + component.clickSelectionToggle(0); + + expect(component.getRow(0).classList.contains('cdk-selected')).toBeTruthy(); + + component.clickSelectionToggle(0); + + expect(component.getRow(0).classList.contains('cdk-selected')).toBeFalsy(); + })); + + it('should set aria-selected on selected rows', fakeAsync(() => { + expect(component.getRow(0).getAttribute('aria-selected')).toBe('false'); + expect(component.getRow(1).getAttribute('aria-selected')).toBe('false'); + expect(component.getRow(2).getAttribute('aria-selected')).toBe('false'); + expect(component.getRow(3).getAttribute('aria-selected')).toBe('false'); + + component.clickSelectionToggle(0); + + expect(component.getRow(0).getAttribute('aria-selected')).toBe('true'); + + component.clickSelectionToggle(0); + + expect(component.getRow(0).getAttribute('aria-selected')).toBe('false'); + })); + }); +}); + +describe('cdkSelectionColumn with multiple = false', () => { + let fixture: ComponentFixture; + let component: SingleSelectTableWithSelectionColumn; + + beforeEach(async(() => { + TestBed + .configureTestingModule({ + imports: [ + CdkSelectionModule, + CdkTableModule, + ], + declarations: [ + SingleSelectTableWithSelectionColumn, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SingleSelectTableWithSelectionColumn); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should not show select all', () => { + expect(component.elementRef.nativeElement.querySelector('input[cdkselectall]')).toBe(null); + }); + + it('should allow selecting one single row', fakeAsync(() => { + expect(component.getSelectionToggle(0).checked).toBe(false); + expect(component.getSelectionToggle(1).checked).toBe(false); + expect(component.getSelectionToggle(2).checked).toBe(false); + expect(component.getSelectionToggle(3).checked).toBe(false); + + component.clickSelectionToggle(0); + + expect(component.getSelectionToggle(0).checked).toBe(true); + + component.clickSelectionToggle(1); + + expect(component.getSelectionToggle(0).checked).toBe(false); + expect(component.getSelectionToggle(1).checked).toBe(true); + + component.clickSelectionToggle(1); + expect(component.getSelectionToggle(1).checked).toBe(false); + })); +}); + +@Component({ + template: ` +
    + +
  • + + {{item}} +
  • +
` +}) +class ListWithMultiSelection { + @ViewChild(CdkSelection) cdkSelection: CdkSelection; + + data = ['apple', 'banana', 'cherry', 'durian']; + + selectionChange?: SelectionChange; + + constructor(private readonly _elementRef: ElementRef, private readonly _cdr: ChangeDetectorRef) {} + + selectAllState(indeterminateState: boolean|null, checkedState: boolean|null): string { + if (indeterminateState) { + return 'indeterminate'; + } else if (checkedState) { + return 'checked'; + } else { + return 'unchecked'; + } + } + + clickSelectAll() { + this.getSelectAll().click(); + flush(); + this._cdr.detectChanges(); + } + + clickSelectionToggle(index: number) { + const toggle = this.getSelectionToggle(index); + if (!toggle) { + return; + } + + toggle.click(); + flush(); + this._cdr.detectChanges(); + } + + getSelectAll() { + return this._elementRef.nativeElement.querySelector('[cdkselectall]'); + } + + getSelectionToggle(index: number) { + return this._elementRef.nativeElement.querySelectorAll('[cdkselectiontoggle]')[index]; + } +} + +@Component({ + template: ` +
    +
  • + + {{item}} +
  • +
` +}) +class ListWithSingleSelection { + @ViewChild(CdkSelection) cdkSelection: CdkSelection; + + data = ['apple', 'banana', 'cherry', 'durian']; + selectionChange?: SelectionChange; + + clickSelectionToggle(index: number) { + const toggle = this.getSelectionToggle(index); + if (!toggle) { + return; + } + + toggle.click(); + flush(); + this._cdr.detectChanges(); + } + + constructor(private readonly _elementRef: ElementRef, private readonly _cdr: ChangeDetectorRef) {} + + getSelectionToggle(index: number) { + return this._elementRef.nativeElement.querySelectorAll('[cdkselectiontoggle]')[index]; + } +} + +@Component({ + template: ` + + + + + + + + + +
Name{{element}}
+ ` +}) +class MultiSelectTableWithSelectionColumn { + @ViewChild(CdkSelection) cdkSelection: CdkSelection; + + columns = ['select', 'name']; + data = ['apple', 'banana', 'cherry', 'durian']; + + selectAllState(indeterminateState: boolean|null, checkedState: boolean|null): string { + if (indeterminateState) { + return 'indeterminate'; + } else if (checkedState) { + return 'checked'; + } else { + return 'unchecked'; + } + } + + clickSelectAll() { + this.getSelectAll().click(); + flush(); + this._cdr.detectChanges(); + } + + clickSelectionToggle(index: number) { + const toggle = this.getSelectionToggle(index); + if (!toggle) { + return; + } + + toggle.click(); + flush(); + this._cdr.detectChanges(); + } + + constructor(readonly elementRef: ElementRef, private readonly _cdr: ChangeDetectorRef) {} + + getSelectAll(): HTMLInputElement { + return this.elementRef.nativeElement.querySelector('input[cdkselectall]'); + } + + getSelectionToggle(index: number): HTMLInputElement { + return this.elementRef.nativeElement.querySelectorAll('input[cdkselectiontoggle]')[index]; + } + + getRow(index: number): HTMLElement { + return this.elementRef.nativeElement.querySelectorAll('tr[cdkrowselection]')[index]; + } +} + +@Component({ + template: ` + + + + + + + + + +
Name{{element}}
+ ` +}) +class SingleSelectTableWithSelectionColumn { + @ViewChild(CdkSelection) cdkSelection: CdkSelection; + + columns = ['select', 'name']; + data = ['apple', 'banana', 'cherry', 'durian']; + + clickSelectionToggle(index: number) { + const toggle = this.getSelectionToggle(index); + if (!toggle) { + return; + } + + toggle.click(); + flush(); + this._cdr.detectChanges(); + } + + constructor(readonly elementRef: ElementRef, private readonly _cdr: ChangeDetectorRef) {} + + getSelectionToggle(index: number): HTMLInputElement { + return this.elementRef.nativeElement.querySelectorAll('input[cdkselectiontoggle]')[index]; + } + + getRow(index: number): HTMLElement { + return this.elementRef.nativeElement.querySelectorAll('tr[cdkrowselection]')[index]; + } +} diff --git a/src/cdk-experimental/selection/selection.ts b/src/cdk-experimental/selection/selection.ts new file mode 100644 index 000000000000..77734ab2d684 --- /dev/null +++ b/src/cdk-experimental/selection/selection.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; +import {CollectionViewer, DataSource, isDataSource, ListRange} from '@angular/cdk/collections'; +import { + AfterContentChecked, + Directive, + EventEmitter, + Input, + isDevMode, + OnDestroy, + OnInit, + Output, + TrackByFunction +} from '@angular/core'; +import {Observable, of as observableOf, Subject, Subscription} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; + +import {SelectableWithIndex, SelectionChange, SelectionSet} from './selection-set'; + +/** + * Manages the selection states of the items and provides methods to check and update the selection + * states. + * It must be applied to the parent element if `cdkSelectionToggle`, `cdkSelectAll`, + * `cdkRowSelection` and `cdkSelectionColumn` are applied. + */ +@Directive({ + selector: '[cdkSelection]', + exportAs: 'cdkSelection', +}) +export class CdkSelection implements OnInit, AfterContentChecked, CollectionViewer, OnDestroy { + viewChange: Observable; + + @Input() + get dataSource(): TableDataSource { + return this._dataSource; + } + set dataSource(dataSource: TableDataSource) { + if (this._dataSource !== dataSource) { + this._switchDataSource(dataSource); + } + } + private _dataSource: TableDataSource; + + @Input('trackBy') trackByFn: TrackByFunction; + + /** Whether to support multiple selection */ + @Input('cdkSelectionMultiple') + get multiple(): boolean { + return this._multiple; + } + set multiple(multiple: boolean) { + this._multiple = coerceBooleanProperty(multiple); + } + private _multiple: boolean; + + /** Emits when selection changes. */ + @Output('cdkSelectionChange') change = new EventEmitter>(); + + /** Latest data provided by the data source. */ + private _data: T[]|readonly T[]; + + /** Subscription that listens for the data provided by the data source. */ + private _renderChangeSubscription: Subscription|null; + + private _destroyed = new Subject(); + + private _selection: SelectionSet; + + private _switchDataSource(dataSource: TableDataSource) { + this._data = []; + + // TODO: Move this logic to a shared function in `cdk/collections`. + if (isDataSource(this._dataSource)) { + this._dataSource.disconnect(this); + } + + if (this._renderChangeSubscription) { + this._renderChangeSubscription.unsubscribe(); + this._renderChangeSubscription = null; + } + + this._dataSource = dataSource; + } + + private _observeRenderChanges() { + if (!this._dataSource) { + return; + } + + let dataStream: Observable>|undefined; + + if (isDataSource(this._dataSource)) { + dataStream = this._dataSource.connect(this); + } else if (this._dataSource instanceof Observable) { + dataStream = this._dataSource; + } else if (Array.isArray(this._dataSource)) { + dataStream = observableOf(this._dataSource); + } + + if (dataStream == null && isDevMode()) { + throw Error('Unknown data source'); + } + + this._renderChangeSubscription = + dataStream!.pipe(takeUntil(this._destroyed)).subscribe((data) => { + this._data = data || []; + }); + } + + ngOnInit() { + this._selection = new SelectionSet(this._multiple, this.trackByFn); + this._selection.changed.pipe(takeUntil(this._destroyed)).subscribe((change) => { + this._updateSelectAllState(); + this.change.emit(change); + }); + } + + ngAfterContentChecked() { + if (this._dataSource && !this._renderChangeSubscription) { + this._observeRenderChanges(); + } + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + + if (isDataSource(this._dataSource)) { + this._dataSource.disconnect(this); + } + } + + /** Toggles selection for a given value. `index` is required if `trackBy` is used. */ + toggleSelection(value: T, index?: number) { + if (this.trackByFn && index == null && isDevMode()) { + throw Error('CdkSelection: index required when trackBy is used'); + } + + if (this.isSelected(value, index)) { + this._selection.deselect({value, index}); + } else { + this._selection.select({value, index}); + } + } + + /** + * Toggles select-all. If no value is selected, select all values. If all values or some of the + * values are selected, de-select all values. + */ + toggleSelectAll() { + if (!this._multiple && isDevMode()) { + throw Error('CdkSelection: multiple selection not enabled'); + } + + if (this.selectAllState === 'none') { + this._selectAll(); + } else { + this._clearAll(); + } + } + + /** Checks whether a value is selected. `index` is required if `trackBy` is used. */ + isSelected(value: T, index?: number) { + if (this.trackByFn && index == null && isDevMode()) { + throw Error('CdkSelection: index required when trackBy is used'); + } + + return this._selection.isSelected({value, index}); + } + + /** Checks whether all values are selected. */ + isAllSelected() { + return this._data.every((value, index) => this._selection.isSelected({value, index})); + } + + /** Checks whether partially selected. */ + isPartialSelected() { + return !this.isAllSelected() && + this._data.some((value, index) => this._selection.isSelected({value, index})); + } + + private _selectAll() { + const toSelect: SelectableWithIndex[] = []; + this._data.forEach((value, index) => { + toSelect.push({value, index}); + }); + + this._selection.select(...toSelect); + } + + private _clearAll() { + const toDeselect: SelectableWithIndex[] = []; + this._data.forEach((value, index) => { + toDeselect.push({value, index}); + }); + + this._selection.deselect(...toDeselect); + } + + private _updateSelectAllState() { + if (this.isAllSelected()) { + this.selectAllState = 'all'; + } else if (this.isPartialSelected()) { + this.selectAllState = 'partial'; + } else { + this.selectAllState = 'none'; + } + } + + selectAllState: SelectAllState = 'none'; + + static ngAcceptInputType_multiple: BooleanInput; +} + +type SelectAllState = 'all'|'none'|'partial'; +type TableDataSource = DataSource|Observable|T[]>|ReadonlyArray|T[]; diff --git a/src/components-examples/BUILD.bazel b/src/components-examples/BUILD.bazel index ca0de2c1c25f..d980eb93b8ea 100644 --- a/src/components-examples/BUILD.bazel +++ b/src/components-examples/BUILD.bazel @@ -47,6 +47,7 @@ ALL_EXAMPLES = [ "//src/components-examples/material-experimental/popover-edit", "//src/components-examples/material-experimental/mdc-card", "//src/components-examples/material-experimental/mdc-form-field", + "//src/components-examples/material-experimental/selection", "//src/components-examples/cdk/tree", "//src/components-examples/cdk/text-field", "//src/components-examples/cdk/table", @@ -59,6 +60,7 @@ ALL_EXAMPLES = [ "//src/components-examples/cdk/a11y", "//src/components-examples/cdk/overlay", "//src/components-examples/cdk-experimental/popover-edit", + "//src/components-examples/cdk-experimental/selection", ] ng_module( diff --git a/src/components-examples/cdk-experimental/selection/BUILD.bazel b/src/components-examples/cdk-experimental/selection/BUILD.bazel new file mode 100644 index 000000000000..b2c9bfc97298 --- /dev/null +++ b/src/components-examples/cdk-experimental/selection/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "selection", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + module_name = "@angular/components-examples/cdk-experimental/selection", + deps = [ + "//src/cdk-experimental/selection", + "//src/cdk/collections", + "//src/cdk/table", + "//src/material/checkbox", + "@npm//@angular/forms", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/cdk-experimental/selection/cdk-selection-column/cdk-selection-column-example.css b/src/components-examples/cdk-experimental/selection/cdk-selection-column/cdk-selection-column-example.css new file mode 100644 index 000000000000..99b8307a1da4 --- /dev/null +++ b/src/components-examples/cdk-experimental/selection/cdk-selection-column/cdk-selection-column-example.css @@ -0,0 +1,7 @@ +.example-table { + border-collapse: collapse; +} + +tr.cdk-selected { + background-color: yellow; +} diff --git a/src/components-examples/cdk-experimental/selection/cdk-selection-column/cdk-selection-column-example.html b/src/components-examples/cdk-experimental/selection/cdk-selection-column/cdk-selection-column-example.html new file mode 100644 index 000000000000..ad8231d5c0cf --- /dev/null +++ b/src/components-examples/cdk-experimental/selection/cdk-selection-column/cdk-selection-column-example.html @@ -0,0 +1,30 @@ +Selected: {{selected}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} Name {{element.name}} Weight {{element.weight}} Symbol {{element.symbol}}
diff --git a/src/components-examples/cdk-experimental/selection/cdk-selection-column/cdk-selection-column-example.ts b/src/components-examples/cdk-experimental/selection/cdk-selection-column/cdk-selection-column-example.ts new file mode 100644 index 000000000000..6c5d8357d607 --- /dev/null +++ b/src/components-examples/cdk-experimental/selection/cdk-selection-column/cdk-selection-column-example.ts @@ -0,0 +1,58 @@ +import {SelectionChange} from '@angular/cdk-experimental/selection'; +import {Component, OnDestroy} from '@angular/core'; +import {ReplaySubject} from 'rxjs'; + +/** + * @title CDK Selection Column on a CDK table. + */ +@Component({ + selector: 'cdk-selection-column-example', + templateUrl: 'cdk-selection-column-example.html', + styleUrls: ['cdk-selection-column-example.css'], +}) +export class CdkSelectionColumnExample implements OnDestroy { + private readonly _destroyed = new ReplaySubject(1); + + displayedColumns: string[] = ['select', 'position', 'name', 'weight', 'symbol']; + dataSource = ELEMENT_DATA; + selected: string[] = []; + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + + selectionChanged(event: SelectionChange) { + this.selected = event.after.map((select) => select.value.name); + } +} + +interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; +} + +const ELEMENT_DATA: PeriodicElement[] = [ + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, + {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, + {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, + {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, + {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, + {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, + {position: 11, name: 'Sodium', weight: 22.9897, symbol: 'Na'}, + {position: 12, name: 'Magnesium', weight: 24.305, symbol: 'Mg'}, + {position: 13, name: 'Aluminum', weight: 26.9815, symbol: 'Al'}, + {position: 14, name: 'Silicon', weight: 28.0855, symbol: 'Si'}, + {position: 15, name: 'Phosphorus', weight: 30.9738, symbol: 'P'}, + {position: 16, name: 'Sulfur', weight: 32.065, symbol: 'S'}, + {position: 17, name: 'Chlorine', weight: 35.453, symbol: 'Cl'}, + {position: 18, name: 'Argon', weight: 39.948, symbol: 'Ar'}, + {position: 19, name: 'Potassium', weight: 39.0983, symbol: 'K'}, + {position: 20, name: 'Calcium', weight: 40.078, symbol: 'Ca'}, +]; diff --git a/src/components-examples/cdk-experimental/selection/cdk-selection-list/cdk-selection-list-example.html b/src/components-examples/cdk-experimental/selection/cdk-selection-list/cdk-selection-list-example.html new file mode 100644 index 000000000000..8877ac3e1b25 --- /dev/null +++ b/src/components-examples/cdk-experimental/selection/cdk-selection-list/cdk-selection-list-example.html @@ -0,0 +1,45 @@ +

native input

+Selected: {{selected1}} +
    + +
  • + + {{item}} +
  • +
+ +

mat-checkbox

+Selected: {{selected2}} +
    + +
  • + + {{item}} +
  • +
+ +

Single select with mat-checkbox

+Selected: {{selected3}} +
    +
  • + + {{item}} +
  • +
+ +

with trackBy

+Selected: {{selected4}} +
    + +
  • + + {{item}} +
  • +
+ + + diff --git a/src/components-examples/cdk-experimental/selection/cdk-selection-list/cdk-selection-list-example.ts b/src/components-examples/cdk-experimental/selection/cdk-selection-list/cdk-selection-list-example.ts new file mode 100644 index 000000000000..5b0f3f65fe09 --- /dev/null +++ b/src/components-examples/cdk-experimental/selection/cdk-selection-list/cdk-selection-list-example.ts @@ -0,0 +1,53 @@ +import {SelectionChange} from '@angular/cdk-experimental/selection'; +import {Component, OnDestroy} from '@angular/core'; +import {ReplaySubject} from 'rxjs'; + +/** + * @title CDK Selection on a simple list. + */ +@Component({ + selector: 'cdk-selection-list-example', + templateUrl: 'cdk-selection-list-example.html', +}) +export class CdkSelectionListExample implements OnDestroy { + private readonly _destroyed = new ReplaySubject(1); + + data = ELEMENT_NAMES; + + selected1: string[] = []; + selected2: string[] = []; + selected3: string[] = []; + selected4: string[] = []; + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + + getCurrentSelected(event: SelectionChange) { + return event.after.map((select) => select.value); + } + + trackByFn(index: number, value: string) { + return index; + } + + changeElementName() { + this.data = ELEMENT_SYMBOLS; + } + + reset() { + this.data = ELEMENT_NAMES; + } +} + +const ELEMENT_NAMES = [ + 'Hydrogen', 'Helium', 'Lithium', 'Beryllium', 'Boron', 'Carbon', 'Nitrogen', + 'Oxygen', 'Fluorine', 'Neon', 'Sodium', 'Magnesium', 'Aluminum', 'Silicon', + 'Phosphorus', 'Sulfur', 'Chlorine', 'Argon', 'Potassium', 'Calcium', +]; + +const ELEMENT_SYMBOLS = [ + 'H', 'He', 'Li', 'Be', 'B', 'C', 'N', 'O', 'F', 'Ne', + 'Na', 'Mg', 'Al', 'Si', 'P', 'S', 'Cl', 'Ar', 'K', 'Ca' +]; diff --git a/src/components-examples/cdk-experimental/selection/index.ts b/src/components-examples/cdk-experimental/selection/index.ts new file mode 100644 index 000000000000..e252984ec552 --- /dev/null +++ b/src/components-examples/cdk-experimental/selection/index.ts @@ -0,0 +1,35 @@ +import {CdkSelectionModule} from '@angular/cdk-experimental/selection'; +import {CdkTableModule} from '@angular/cdk/table'; +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatCheckboxModule} from '@angular/material/checkbox'; + +import {CdkSelectionColumnExample} from './cdk-selection-column/cdk-selection-column-example'; +import {CdkSelectionListExample} from './cdk-selection-list/cdk-selection-list-example'; + +export { + CdkSelectionColumnExample, + CdkSelectionListExample, +}; + +const EXAMPLES = [ + CdkSelectionListExample, + CdkSelectionColumnExample, +]; + +@NgModule({ + imports: [ + CdkSelectionModule, + CdkTableModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + MatCheckboxModule, + + ], + declarations: EXAMPLES, + exports: EXAMPLES, +}) +export class CdkSelectionExamplesModule { +} diff --git a/src/components-examples/material-experimental/selection/BUILD.bazel b/src/components-examples/material-experimental/selection/BUILD.bazel new file mode 100644 index 000000000000..6b5cc62377a9 --- /dev/null +++ b/src/components-examples/material-experimental/selection/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "selection", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + module_name = "@angular/components-examples/material-experimental/selection", + deps = [ + "//src/cdk/collections", + "//src/cdk/table", + "//src/material-experimental/selection", + "//src/material/checkbox", + "@npm//@angular/forms", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/material-experimental/selection/index.ts b/src/components-examples/material-experimental/selection/index.ts new file mode 100644 index 000000000000..2e8fdc1e0cbe --- /dev/null +++ b/src/components-examples/material-experimental/selection/index.ts @@ -0,0 +1,34 @@ +import {MatSelectionModule} from '@angular/material-experimental/selection'; +import {MatTableModule} from '@angular/material/table'; +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatCheckboxModule} from '@angular/material/checkbox'; + +import {MatSelectionColumnExample} from './mat-selection-column/mat-selection-column-example'; +import {MatSelectionListExample} from './mat-selection-list/mat-selection-list-example'; + +export { + MatSelectionListExample, + MatSelectionColumnExample, +}; + +const EXAMPLES = [ + MatSelectionListExample, + MatSelectionColumnExample, +]; + +@NgModule({ + imports: [ + MatSelectionModule, + MatTableModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + MatCheckboxModule, + ], + declarations: EXAMPLES, + exports: EXAMPLES, +}) +export class MatSelectionExamplesModule { +} diff --git a/src/components-examples/material-experimental/selection/mat-selection-column/mat-selection-column-example.css b/src/components-examples/material-experimental/selection/mat-selection-column/mat-selection-column-example.css new file mode 100644 index 000000000000..cedd44731369 --- /dev/null +++ b/src/components-examples/material-experimental/selection/mat-selection-column/mat-selection-column-example.css @@ -0,0 +1,3 @@ +.example-table { + width: 100%; +} diff --git a/src/components-examples/material-experimental/selection/mat-selection-column/mat-selection-column-example.html b/src/components-examples/material-experimental/selection/mat-selection-column/mat-selection-column-example.html new file mode 100644 index 000000000000..b528c55fba87 --- /dev/null +++ b/src/components-examples/material-experimental/selection/mat-selection-column/mat-selection-column-example.html @@ -0,0 +1,30 @@ +Selected: {{selected}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} Name {{element.name}} Weight {{element.weight}} Symbol {{element.symbol}}
diff --git a/src/components-examples/material-experimental/selection/mat-selection-column/mat-selection-column-example.ts b/src/components-examples/material-experimental/selection/mat-selection-column/mat-selection-column-example.ts new file mode 100644 index 000000000000..72b8eed687e4 --- /dev/null +++ b/src/components-examples/material-experimental/selection/mat-selection-column/mat-selection-column-example.ts @@ -0,0 +1,58 @@ +import {Component, OnDestroy} from '@angular/core'; +import {SelectionChange} from '@angular/material-experimental/selection'; +import {ReplaySubject} from 'rxjs'; + +/** + * @title Table that uses `matSelectionColumn` which allows users to select rows. + */ +@Component({ + selector: 'mat-selection-column-example', + templateUrl: 'mat-selection-column-example.html', + styleUrls: ['mat-selection-column-example.css'], +}) +export class MatSelectionColumnExample implements OnDestroy { + private readonly _destroyed = new ReplaySubject(1); + + displayedColumns: string[] = ['select', 'position', 'name', 'weight', 'symbol']; + dataSource = ELEMENT_DATA; + selected: string[] = []; + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + + selectionChanged(event: SelectionChange) { + this.selected = event.after.map((select) => select.value.name); + } +} + +interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; +} + +const ELEMENT_DATA: PeriodicElement[] = [ + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, + {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, + {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, + {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, + {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, + {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, + {position: 11, name: 'Sodium', weight: 22.9897, symbol: 'Na'}, + {position: 12, name: 'Magnesium', weight: 24.305, symbol: 'Mg'}, + {position: 13, name: 'Aluminum', weight: 26.9815, symbol: 'Al'}, + {position: 14, name: 'Silicon', weight: 28.0855, symbol: 'Si'}, + {position: 15, name: 'Phosphorus', weight: 30.9738, symbol: 'P'}, + {position: 16, name: 'Sulfur', weight: 32.065, symbol: 'S'}, + {position: 17, name: 'Chlorine', weight: 35.453, symbol: 'Cl'}, + {position: 18, name: 'Argon', weight: 39.948, symbol: 'Ar'}, + {position: 19, name: 'Potassium', weight: 39.0983, symbol: 'K'}, + {position: 20, name: 'Calcium', weight: 40.078, symbol: 'Ca'}, +]; diff --git a/src/components-examples/material-experimental/selection/mat-selection-list/mat-selection-list-example.html b/src/components-examples/material-experimental/selection/mat-selection-list/mat-selection-list-example.html new file mode 100644 index 000000000000..bec0b35b1cc5 --- /dev/null +++ b/src/components-examples/material-experimental/selection/mat-selection-list/mat-selection-list-example.html @@ -0,0 +1,45 @@ +

native input

+Selected: {{selected1}} +
    + +
  • + + {{item}} +
  • +
+ +

mat-checkbox

+Selected: {{selected2}} +
    + +
  • + + {{item}} +
  • +
+ +

Single select with mat-checkbox

+Selected: {{selected3}} +
    +
  • + + {{item}} +
  • +
+ +

with trackBy

+Selected: {{selected4}} +
    + +
  • + + {{item}} +
  • +
+ + + diff --git a/src/components-examples/material-experimental/selection/mat-selection-list/mat-selection-list-example.ts b/src/components-examples/material-experimental/selection/mat-selection-list/mat-selection-list-example.ts new file mode 100644 index 000000000000..c9af7c0b300e --- /dev/null +++ b/src/components-examples/material-experimental/selection/mat-selection-list/mat-selection-list-example.ts @@ -0,0 +1,53 @@ +import {SelectionChange} from '@angular/cdk-experimental/selection'; +import {Component, OnDestroy} from '@angular/core'; +import {ReplaySubject} from 'rxjs'; + +/** + * @title Mat Selection on a simple list. + */ +@Component({ + selector: 'mat-selection-list-example', + templateUrl: 'mat-selection-list-example.html', +}) +export class MatSelectionListExample implements OnDestroy { + private readonly _destroyed = new ReplaySubject(1); + + data = ELEMENT_NAMES; + + selected1: string[] = []; + selected2: string[] = []; + selected3: string[] = []; + selected4: string[] = []; + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + + getCurrentSelected(event: SelectionChange) { + return event.after.map((select) => select.value); + } + + trackByFn(index: number, value: string) { + return index; + } + + changeElementName() { + this.data = ELEMENT_SYMBOLS; + } + + reset() { + this.data = ELEMENT_NAMES; + } +} + +const ELEMENT_NAMES = [ + 'Hydrogen', 'Helium', 'Lithium', 'Beryllium', 'Boron', 'Carbon', 'Nitrogen', + 'Oxygen', 'Fluorine', 'Neon', 'Sodium', 'Magnesium', 'Aluminum', 'Silicon', + 'Phosphorus', 'Sulfur', 'Chlorine', 'Argon', 'Potassium', 'Calcium', +]; + +const ELEMENT_SYMBOLS = [ + 'H', 'He', 'Li', 'Be', 'B', 'C', 'N', 'O', 'F', 'Ne', + 'Na', 'Mg', 'Al', 'Si', 'P', 'S', 'Cl', 'Ar', 'K', 'Ca' +]; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 886792ef85ee..457ef09ac4de 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -75,6 +75,7 @@ ng_module( "//src/dev-app/ripple", "//src/dev-app/screen-type", "//src/dev-app/select", + "//src/dev-app/selection", "//src/dev-app/sidenav", "//src/dev-app/slide-toggle", "//src/dev-app/slider", diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 87493fc13353..28fa3119c19a 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -8,8 +8,9 @@ import {Directionality} from '@angular/cdk/bidi'; import {ChangeDetectorRef, Component, ElementRef, Inject, ViewEncapsulation} from '@angular/core'; -import {DevAppRippleOptions} from './ripple-options'; + import {DevAppDirectionality} from './dev-app-directionality'; +import {DevAppRippleOptions} from './ripple-options'; import {DOCUMENT} from '@angular/common'; const isDarkThemeKey = 'ANGULAR_COMPONENTS_DEV_APP_DARK_THEME'; @@ -65,6 +66,7 @@ export class DevAppLayout { {name: 'Ripple', route: '/ripple'}, {name: 'Screen Type', route: '/screen-type'}, {name: 'Select', route: '/select'}, + {name: 'Selection', route: '/selection'}, {name: 'Sidenav', route: '/sidenav'}, {name: 'Slide Toggle', route: '/slide-toggle'}, {name: 'Slider', route: '/slider'}, diff --git a/src/dev-app/dev-app/dev-app-module.ts b/src/dev-app/dev-app/dev-app-module.ts index 707148465808..d09902019f09 100644 --- a/src/dev-app/dev-app/dev-app-module.ts +++ b/src/dev-app/dev-app/dev-app-module.ts @@ -14,6 +14,7 @@ import {MatListModule} from '@angular/material/list'; import {MatSidenavModule} from '@angular/material/sidenav'; import {MatToolbarModule} from '@angular/material/toolbar'; import {RouterModule} from '@angular/router'; + import {DevApp404} from './dev-app-404'; import {DevAppHome} from './dev-app-home'; import {DevAppLayout} from './dev-app-layout'; diff --git a/src/dev-app/dev-app/routes.ts b/src/dev-app/dev-app/routes.ts index a0a71b07246a..e7f189985dcc 100644 --- a/src/dev-app/dev-app/routes.ts +++ b/src/dev-app/dev-app/routes.ts @@ -52,10 +52,7 @@ export const DEV_APP_ROUTES: Routes = [ path: 'focus-origin', loadChildren: 'focus-origin/focus-origin-demo-module#FocusOriginDemoModule' }, - { - path: 'focus-trap', - loadChildren: 'focus-trap/focus-trap-demo-module#FocusTrapDemoModule' - }, + {path: 'focus-trap', loadChildren: 'focus-trap/focus-trap-demo-module#FocusTrapDemoModule'}, {path: 'google-map', loadChildren: 'google-map/google-map-demo-module#GoogleMapDemoModule'}, {path: 'grid-list', loadChildren: 'grid-list/grid-list-demo-module#GridListDemoModule'}, {path: 'icon', loadChildren: 'icon/icon-demo-module#IconDemoModule'}, @@ -148,6 +145,7 @@ export const DEV_APP_ROUTES: Routes = [ path: 'youtube-player', loadChildren: 'youtube-player/youtube-player-demo-module#YouTubePlayerDemoModule', }, + {path: 'selection', loadChildren: 'selection/selection-demo-module#SelectionDemoModule'}, {path: 'examples', loadChildren: 'examples-page/examples-page-module#ExamplesPageModule'}, {path: '**', component: DevApp404}, ]; diff --git a/src/dev-app/example/example-list.ts b/src/dev-app/example/example-list.ts index c6c4e0e0a8fb..05d5a43a366c 100644 --- a/src/dev-app/example/example-list.ts +++ b/src/dev-app/example/example-list.ts @@ -6,9 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import {Component, Input} from '@angular/core'; -import {EXAMPLE_COMPONENTS} from '@angular/components-examples'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; +import {EXAMPLE_COMPONENTS} from '@angular/components-examples'; +import {Component, Input} from '@angular/core'; /** Displays a set of components-examples in a mat-accordion. */ @Component({ @@ -60,8 +60,12 @@ export class ExampleList { @Input() ids: string[]; @Input() - get expandAll(): boolean { return this._expandAll; } - set expandAll(v: boolean) { this._expandAll = coerceBooleanProperty(v); } + get expandAll(): boolean { + return this._expandAll; + } + set expandAll(v: boolean) { + this._expandAll = coerceBooleanProperty(v); + } _expandAll: boolean; exampleComponents = EXAMPLE_COMPONENTS; diff --git a/src/dev-app/example/example-module.ts b/src/dev-app/example/example-module.ts index 7ebe754afa41..790bd8c3ebb2 100644 --- a/src/dev-app/example/example-module.ts +++ b/src/dev-app/example/example-module.ts @@ -9,9 +9,8 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {MatExpansionModule} from '@angular/material/expansion'; -import {Example} from './example'; - +import {Example} from './example'; import {ExampleList} from './example-list'; @NgModule({ diff --git a/src/dev-app/selection/BUILD.bazel b/src/dev-app/selection/BUILD.bazel new file mode 100644 index 000000000000..ac897a67e329 --- /dev/null +++ b/src/dev-app/selection/BUILD.bazel @@ -0,0 +1,15 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "selection", + srcs = glob(["**/*.ts"]), + deps = [ + "//src/components-examples/cdk-experimental/selection", + "//src/components-examples/material-experimental/selection", + "//src/dev-app/example", + "@npm//@angular/forms", + "@npm//@angular/router", + ], +) diff --git a/src/dev-app/selection/selection-demo-module.ts b/src/dev-app/selection/selection-demo-module.ts new file mode 100644 index 000000000000..8fe8841bab70 --- /dev/null +++ b/src/dev-app/selection/selection-demo-module.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CdkSelectionExamplesModule} from '@angular/components-examples/cdk-experimental/selection'; +import {MatSelectionExamplesModule} from +'@angular/components-examples/material-experimental/selection'; +import {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {RouterModule} from '@angular/router'; + +import {SelectionDemo} from './selection-demo'; + +@NgModule({ + imports: [ + CdkSelectionExamplesModule, + MatSelectionExamplesModule, + FormsModule, + RouterModule.forChild([{path: '', component: SelectionDemo}]), + ], + declarations: [SelectionDemo], +}) +export class SelectionDemoModule { +} diff --git a/src/dev-app/selection/selection-demo.ts b/src/dev-app/selection/selection-demo.ts new file mode 100644 index 000000000000..a7d8f1c4fa4b --- /dev/null +++ b/src/dev-app/selection/selection-demo.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component} from '@angular/core'; + +@Component({ + template: ` +

CDK selection with a list

+ + +

CDK selection column and CDK row selection with CDK table

+ + +

Mat selection with a list

+ + +

Mat selection column and Mat row selection with Mat table

+ + `, +}) +export class SelectionDemo { +} diff --git a/src/material-experimental/config.bzl b/src/material-experimental/config.bzl index 13796e01be13..184c9b157e88 100644 --- a/src/material-experimental/config.bzl +++ b/src/material-experimental/config.bzl @@ -34,6 +34,7 @@ entryPoints = [ "mdc-tabs", "menubar", "popover-edit", + "selection", ] # List of all non-testing entry-points of the Angular material-experimental package. diff --git a/src/material-experimental/selection/BUILD.bazel b/src/material-experimental/selection/BUILD.bazel new file mode 100644 index 000000000000..94cd13789e70 --- /dev/null +++ b/src/material-experimental/selection/BUILD.bazel @@ -0,0 +1,35 @@ +load("//tools:defaults.bzl", "ng_module", "sass_binary", "sass_library") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "selection", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + assets = [":selection_column_scss"], + module_name = "@angular/material-experimental/selection", + deps = [ + "//src/cdk-experimental/selection", + "//src/material/checkbox", + "//src/material/table", + "@npm//@angular/core", + ], +) + +sass_library( + name = "selection_scss_lib", + srcs = glob(["**/_*.scss"]), + deps = [], +) + +sass_binary( + name = "selection_column_scss", + src = "selection-column.scss", + include_paths = [ + "external/npm/node_modules", + ], + deps = [ + ], +) diff --git a/src/material-experimental/selection/_selection.scss b/src/material-experimental/selection/_selection.scss new file mode 100644 index 000000000000..d2abf6b4aca8 --- /dev/null +++ b/src/material-experimental/selection/_selection.scss @@ -0,0 +1,8 @@ +@import '../../material/core/theming/check-duplicate-styles'; + +@mixin mat-selection-theme($theme-or-color-config) { + $theme: _mat-legacy-get-theme($theme-or-color-config); + @include _mat-check-duplicate-theme-styles($theme, 'mat-selection'); +} + +@mixin mat-selection-typography($config-or-theme) {} diff --git a/src/material-experimental/selection/index.ts b/src/material-experimental/selection/index.ts new file mode 100644 index 000000000000..e1fc5bfc0361 --- /dev/null +++ b/src/material-experimental/selection/index.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; + diff --git a/src/material-experimental/selection/public-api.ts b/src/material-experimental/selection/public-api.ts new file mode 100644 index 000000000000..8cae343cd78d --- /dev/null +++ b/src/material-experimental/selection/public-api.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './selection'; +export * from './select-all'; +export * from './selection-toggle'; +export * from './selection-column'; +export * from './row-selection'; +export * from './selection-module'; diff --git a/src/material-experimental/selection/row-selection.ts b/src/material-experimental/selection/row-selection.ts new file mode 100644 index 000000000000..518fb70e899c --- /dev/null +++ b/src/material-experimental/selection/row-selection.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CdkRowSelection} from '@angular/cdk-experimental/selection'; +import {Input, Directive} from '@angular/core'; + + +/** + * Applies `mat-selected` class and `aria-selected` to an element. + * + * Must be used within a parent `MatSelection` directive. + * Must be provided with the value. The index is required if `trackBy` is used on the `CdkSelection` + * directive. + */ +@Directive({ + selector: '[matRowSelection]', + host: { + '[class.mat-selected]': '_selection.isSelected(this.value, this.index)', + '[attr.aria-selected]': '_selection.isSelected(this.value, this.index)', + }, + providers: [{provide: CdkRowSelection, useExisting: MatRowSelection}] +}) +export class MatRowSelection extends CdkRowSelection { + /** The value that is associated with the row */ + @Input('matRowSelectionValue') value: T; + + /** The index of the value in the list. Required when used with `trackBy` */ + @Input('matRowSelectionIndex') index: number|undefined; +} diff --git a/src/material-experimental/selection/select-all.ts b/src/material-experimental/selection/select-all.ts new file mode 100644 index 000000000000..2501bb9dfce4 --- /dev/null +++ b/src/material-experimental/selection/select-all.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CdkSelectAll} from '@angular/cdk-experimental/selection'; +import {Directive} from '@angular/core'; + + +/** + * Makes the element a select-all toggle. + * + * Must be used within a parent `MatSelection` directive. It toggles the selection states + * of all the selection toggles connected with the `MatSelection` directive. + * If the element implements `ControlValueAccessor`, e.g. `MatCheckbox`, the directive + * automatically connects it with the select-all state provided by the `MatSelection` directive. If + * not, use `checked` to get the checked state, `indeterminate` to get the indeterminate state, + * and `toggle()` to change the selection state. + */ +@Directive({ + selector: '[matSelectAll]', + exportAs: 'matSelectAll', + providers: [{provide: CdkSelectAll, useExisting: MatSelectAll}] +}) +export class MatSelectAll extends CdkSelectAll { +} diff --git a/src/material-experimental/selection/selection-column.scss b/src/material-experimental/selection/selection-column.scss new file mode 100644 index 000000000000..81d4f297d7a7 --- /dev/null +++ b/src/material-experimental/selection/selection-column.scss @@ -0,0 +1,5 @@ +th.mat-selection-column-header, +td.mat-selection-column-cell { + text-align: center; + width: 48px; +} diff --git a/src/material-experimental/selection/selection-column.ts b/src/material-experimental/selection/selection-column.ts new file mode 100644 index 000000000000..8fba868c49cd --- /dev/null +++ b/src/material-experimental/selection/selection-column.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {MatCellDef, MatColumnDef, MatHeaderCellDef, MatTable} from '@angular/material/table'; +import { + Component, + Input, + isDevMode, + OnDestroy, + OnInit, + Optional, + ViewChild, + ChangeDetectionStrategy, + ViewEncapsulation, + Inject, +} from '@angular/core'; + +import {MatSelection} from './selection'; + +/** + * Column that adds row selecting checkboxes and a select-all checkbox if `matSelectionMultiple` is + * `true`. + * + * Must be used within a parent `MatSelection` directive. + */ +@Component({ + selector: 'mat-selection-column', + template: ` + + + + + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['selection-column.css'], + encapsulation: ViewEncapsulation.None, +}) +export class MatSelectionColumn implements OnInit, OnDestroy { + /** Column name that should be used to reference this column. */ + @Input() + get name(): string { + return this._name; + } + set name(name: string) { + this._name = name; + + this._syncColumnDefName(); + } + private _name: string; + + @ViewChild(MatColumnDef, {static: true}) private readonly _columnDef: MatColumnDef; + @ViewChild(MatCellDef, {static: true}) private readonly _cell: MatCellDef; + @ViewChild(MatHeaderCellDef, {static: true}) private readonly _headerCell: MatHeaderCellDef; + + constructor( + @Optional() @Inject(MatTable) private _table: MatTable, + @Optional() @Inject(MatSelection) readonly selection: MatSelection, + ) {} + + ngOnInit() { + if (!this.selection && isDevMode()) { + throw Error('MatSelectionColumn: missing MatSelection in the parent'); + } + + this._syncColumnDefName(); + + if (this._table) { + this._columnDef.cell = this._cell; + this._columnDef.headerCell = this._headerCell; + this._table.addColumnDef(this._columnDef); + } else if (isDevMode()) { + throw Error('MatSelectionColumn: missing parent table'); + } + } + + ngOnDestroy() { + if (this._table) { + this._table.removeColumnDef(this._columnDef); + } + } + + private _syncColumnDefName() { + if (this._columnDef) { + this._columnDef.name = this._name; + } + } +} diff --git a/src/material-experimental/selection/selection-module.ts b/src/material-experimental/selection/selection-module.ts new file mode 100644 index 000000000000..f8e90a2d0466 --- /dev/null +++ b/src/material-experimental/selection/selection-module.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +// TODO(yifange): Move the table-specific code to a separate module from the other selection +// behaviors once we move it out of experiemental. +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {MatTableModule} from '@angular/material/table'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatSelectAll} from './select-all'; +import {MatSelection} from './selection'; +import {MatSelectionToggle} from './selection-toggle'; +import {MatSelectionColumn} from './selection-column'; +import {MatRowSelection} from './row-selection'; + +@NgModule({ + imports: [ + CommonModule, + MatTableModule, + MatCheckboxModule, + ], + exports: [ + MatSelectAll, + MatSelection, + MatSelectionToggle, + MatSelectionColumn, + MatRowSelection, + ], + declarations: [ + MatSelectAll, + MatSelection, + MatSelectionToggle, + MatSelectionColumn, + MatRowSelection, + ], +}) +export class MatSelectionModule { +} diff --git a/src/material-experimental/selection/selection-toggle.ts b/src/material-experimental/selection/selection-toggle.ts new file mode 100644 index 000000000000..32e36dd11d48 --- /dev/null +++ b/src/material-experimental/selection/selection-toggle.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CdkSelectionToggle} from '@angular/cdk-experimental/selection'; +import {Directive, Input} from '@angular/core'; + +/** + * Makes the element a selection toggle. + * + * Must be used within a parent `MatSelection` directive. + * Must be provided with the value. If `trackBy` is used on `MatSelection`, the index of the value + * is required. If the element implements `ControlValueAccessor`, e.g. `MatCheckbox`, the directive + * automatically connects it with the selection state provided by the `MatSelection` directive. If + * not, use `checked$` to get the checked state of the value, and `toggle()` to change the selection + * state. + */ +@Directive({ + selector: '[matSelectionToggle]', + exportAs: 'matSelectionToggle', + providers: [{provide: CdkSelectionToggle, useExisting: MatSelectionToggle}] +}) +export class MatSelectionToggle extends CdkSelectionToggle { + /** The value that is associated with the toggle */ + @Input('matSelectionToggleValue') value: T; + + /** The index of the value in the list. Required when used with `trackBy` */ + @Input('matSelectionToggleIndex') index: number|undefined; +} diff --git a/src/material-experimental/selection/selection.ts b/src/material-experimental/selection/selection.ts new file mode 100644 index 000000000000..62ab56468ec3 --- /dev/null +++ b/src/material-experimental/selection/selection.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CdkSelection, SelectionChange} from '@angular/cdk-experimental/selection'; +import {Directive, Input, Output, EventEmitter} from '@angular/core'; + + +/** + * Manages the selection states of the items and provides methods to check and update the selection + * states. + * It must be applied to the parent element if `matSelectionToggle`, `matSelectAll`, + * `matRowSelection` and `matSelectionColumn` are applied. + */ +@Directive({ + selector: '[matSelection]', + exportAs: 'matSelection', + providers: [{provide: CdkSelection, useExisting: MatSelection}] +}) +export class MatSelection extends CdkSelection { + /** Whether to support multiple selection */ + @Input('matSelectionMultiple') multiple: boolean; + + /** Emits when selection changes. */ + @Output('matSelectionChange') change = new EventEmitter>(); +} + +/** + * Represents the change in the selection set. + */ +export {SelectionChange} from '@angular/cdk-experimental/selection';