diff --git a/src/components-examples/material/list/index.ts b/src/components-examples/material/list/index.ts index eea8dd9e66de..a4bac254fe6d 100644 --- a/src/components-examples/material/list/index.ts +++ b/src/components-examples/material/list/index.ts @@ -5,17 +5,20 @@ import {MatListModule} from '@angular/material/list'; import {ListOverviewExample} from './list-overview/list-overview-example'; import {ListSectionsExample} from './list-sections/list-sections-example'; import {ListSelectionExample} from './list-selection/list-selection-example'; +import {ListSingleSelectionExample} from './list-single-selection/list-single-selection-example'; export { ListOverviewExample, ListSectionsExample, ListSelectionExample, + ListSingleSelectionExample, }; const EXAMPLES = [ ListOverviewExample, ListSectionsExample, ListSelectionExample, + ListSingleSelectionExample, ]; @NgModule({ diff --git a/src/components-examples/material/list/list-single-selection/list-single-selection-example.css b/src/components-examples/material/list/list-single-selection/list-single-selection-example.css new file mode 100644 index 000000000000..7949471cec95 --- /dev/null +++ b/src/components-examples/material/list/list-single-selection/list-single-selection-example.css @@ -0,0 +1 @@ +/** No styles for this example. */ diff --git a/src/components-examples/material/list/list-single-selection/list-single-selection-example.html b/src/components-examples/material/list/list-single-selection/list-single-selection-example.html new file mode 100644 index 000000000000..29cd74bbe71d --- /dev/null +++ b/src/components-examples/material/list/list-single-selection/list-single-selection-example.html @@ -0,0 +1,9 @@ + + + {{shoe}} + + + +

+ Option selected: {{shoes.selectedOptions.selected}} +

diff --git a/src/components-examples/material/list/list-single-selection/list-single-selection-example.ts b/src/components-examples/material/list/list-single-selection/list-single-selection-example.ts new file mode 100644 index 000000000000..8e1ee0de2ead --- /dev/null +++ b/src/components-examples/material/list/list-single-selection/list-single-selection-example.ts @@ -0,0 +1,13 @@ +import {Component} from '@angular/core'; + +/** + * @title List with single selection + */ +@Component({ + selector: 'list-single-selection-example', + styleUrls: ['list-single-selection-example.css'], + templateUrl: 'list-single-selection-example.html', +}) +export class ListSingleSelectionExample { + typesOfShoes: string[] = ['Boots', 'Clogs', 'Loafers', 'Moccasins', 'Sneakers']; +} diff --git a/src/dev-app/list/list-demo.html b/src/dev-app/list/list-demo.html index 5df3da6e3b16..3325bce2db43 100644 --- a/src/dev-app/list/list-demo.html +++ b/src/dev-app/list/list-demo.html @@ -162,4 +162,22 @@

Dogs

+ +
+

Single Selection list

+ + +

Favorite Grocery

+ + Bananas + Oranges + Apples + Strawberries +
+ +

Selected: {{favoriteOptions | json}}

+
diff --git a/src/dev-app/list/list-demo.ts b/src/dev-app/list/list-demo.ts index 84a6eaf0a37b..9c1ec2908019 100644 --- a/src/dev-app/list/list-demo.ts +++ b/src/dev-app/list/list-demo.ts @@ -70,6 +70,8 @@ export class ListDemo { this.modelChangeEventCount++; } + favoriteOptions: string[] = []; + alertItem(msg: string) { alert(msg); } diff --git a/src/material/list/_list-theme.scss b/src/material/list/_list-theme.scss index 71fe8e938724..9095273ca551 100644 --- a/src/material/list/_list-theme.scss +++ b/src/material/list/_list-theme.scss @@ -33,6 +33,12 @@ background: mat-color($background, 'hover'); } } + + .mat-list-single-selected-option { + &, &:hover, &:focus { + background: mat-color($background, hover, 0.12); + } + } } @mixin mat-list-typography($config) { diff --git a/src/material/list/list-option.html b/src/material/list/list-option.html index b785eca5a6e6..d8a34a308cbf 100644 --- a/src/material/list/list-option.html +++ b/src/material/list/list-option.html @@ -7,6 +7,7 @@ [matRippleDisabled]="_isRippleDisabled()"> diff --git a/src/material/list/selection-list.spec.ts b/src/material/list/selection-list.spec.ts index ce45f1aa4483..ed9644a37374 100644 --- a/src/material/list/selection-list.spec.ts +++ b/src/material/list/selection-list.spec.ts @@ -188,6 +188,16 @@ describe('MatSelectionList without forms', () => { expect(selectList.selected.length).toBe(0); }); + it('should not add the mat-list-single-selected-option class (in multiple mode)', () => { + let testListItem = listOptions[2].injector.get(MatListOption); + + testListItem._handleClick(); + fixture.detectChanges(); + + expect(listOptions[2].nativeElement.classList.contains('mat-list-single-selected-option')) + .toBe(false); + }); + it('should not allow selection of disabled items', () => { let testListItem = listOptions[0].injector.get(MatListOption); let selectList = @@ -882,6 +892,80 @@ describe('MatSelectionList without forms', () => { expect(listOption.classList).toContain('mat-list-item-with-avatar'); }); }); + + describe('with single selection', () => { + let fixture: ComponentFixture; + let listOption: DebugElement[]; + let selectionList: DebugElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MatListModule], + declarations: [ + SelectionListWithListOptions, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SelectionListWithListOptions); + fixture.componentInstance.multiple = false; + listOption = fixture.debugElement.queryAll(By.directive(MatListOption)); + selectionList = fixture.debugElement.query(By.directive(MatSelectionList))!; + fixture.detectChanges(); + })); + + it('should select one option at a time', () => { + const testListItem1 = listOption[1].injector.get(MatListOption); + const testListItem2 = listOption[2].injector.get(MatListOption); + const selectList = + selectionList.injector.get(MatSelectionList).selectedOptions; + + expect(selectList.selected.length).toBe(0); + + dispatchFakeEvent(testListItem1._getHostElement(), 'click'); + fixture.detectChanges(); + + expect(selectList.selected).toEqual([testListItem1]); + expect(listOption[1].nativeElement.classList.contains('mat-list-single-selected-option')) + .toBe(true); + + dispatchFakeEvent(testListItem2._getHostElement(), 'click'); + fixture.detectChanges(); + + expect(selectList.selected).toEqual([testListItem2]); + expect(listOption[1].nativeElement.classList.contains('mat-list-single-selected-option')) + .toBe(false); + expect(listOption[2].nativeElement.classList.contains('mat-list-single-selected-option')) + .toBe(true); + }); + + it('should not show check boxes', () => { + expect(fixture.nativeElement.querySelector('mat-pseudo-checkbox')).toBeFalsy(); + }); + + it('should not deselect the target option on click', () => { + const testListItem1 = listOption[1].injector.get(MatListOption); + const selectList = + selectionList.injector.get(MatSelectionList).selectedOptions; + + expect(selectList.selected.length).toBe(0); + + dispatchFakeEvent(testListItem1._getHostElement(), 'click'); + fixture.detectChanges(); + + expect(selectList.selected).toEqual([testListItem1]); + + dispatchFakeEvent(testListItem1._getHostElement(), 'click'); + fixture.detectChanges(); + + expect(selectList.selected).toEqual([testListItem1]); + }); + + it('throws an exception when toggling single/multiple mode after bootstrap', () => { + fixture.componentInstance.multiple = true; + expect(() => fixture.detectChanges()).toThrow(new Error( + 'Cannot change `multiple` mode of mat-selection-list after initialization.')); + }); + }); }); describe('MatSelectionList with forms', () => { @@ -1255,7 +1339,8 @@ describe('MatSelectionList with forms', () => { id="selection-list-1" (selectionChange)="onValueChange($event)" [disableRipple]="listRippleDisabled" - [color]="selectionListColor"> + [color]="selectionListColor" + [multiple]="multiple"> Inbox (disabled selection-option) @@ -1274,6 +1359,7 @@ describe('MatSelectionList with forms', () => { class SelectionListWithListOptions { showLastOption: boolean = true; listRippleDisabled = false; + multiple = true; selectionListColor: ThemePalette; firstOptionColor: ThemePalette; diff --git a/src/material/list/selection-list.ts b/src/material/list/selection-list.ts index 559ec05863a1..21d04e81c275 100644 --- a/src/material/list/selection-list.ts +++ b/src/material/list/selection-list.ts @@ -40,6 +40,7 @@ import { SimpleChanges, ViewChild, ViewEncapsulation, + isDevMode, } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import { @@ -50,6 +51,7 @@ import { setLines, ThemePalette, } from '@angular/material/core'; + import {Subject} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; @@ -108,6 +110,7 @@ export class MatSelectionListChange { // be placed inside a parent that has one of the other colors with a higher specificity. '[class.mat-accent]': 'color !== "primary" && color !== "warn"', '[class.mat-warn]': 'color === "warn"', + '[class.mat-list-single-selected-option]': 'selected && !selectionList.multiple', '[attr.aria-selected]': 'selected', '[attr.aria-disabled]': 'disabled', }, @@ -255,7 +258,7 @@ export class MatListOption extends _MatListOptionMixinBase implements AfterConte } _handleClick() { - if (!this.disabled) { + if (!this.disabled && (this.selectionList.multiple || !this.selected)) { this.toggle(); // Emit a change event if the selected state of the option changed through user interaction. @@ -324,7 +327,7 @@ export class MatListOption extends _MatListOptionMixinBase implements AfterConte 'class': 'mat-selection-list mat-list-base', '(blur)': '_onTouched()', '(keydown)': '_keydown($event)', - 'aria-multiselectable': 'true', + '[attr.aria-multiselectable]': 'multiple', '[attr.aria-disabled]': 'disabled.toString()', }, template: '', @@ -335,6 +338,8 @@ export class MatListOption extends _MatListOptionMixinBase implements AfterConte }) export class MatSelectionList extends _MatSelectionListMixinBase implements CanDisableRipple, AfterContentInit, ControlValueAccessor, OnDestroy, OnChanges { + private _multiple = true; + private _contentInitialized = false; /** The FocusKeyManager which handles focus. */ _keyManager: FocusKeyManager; @@ -373,8 +378,25 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD } private _disabled: boolean = false; + /** Whether selection is limited to one or multiple items (default multiple). */ + @Input() + get multiple(): boolean { return this._multiple; } + set multiple(value: boolean) { + const newValue = coerceBooleanProperty(value); + + if (newValue !== this._multiple) { + if (isDevMode() && this._contentInitialized) { + throw new Error( + 'Cannot change `multiple` mode of mat-selection-list after initialization.'); + } + + this._multiple = newValue; + this.selectedOptions = new SelectionModel(this._multiple, this.selectedOptions.selected); + } + } + /** The currently selected options. */ - selectedOptions: SelectionModel = new SelectionModel(true); + selectedOptions = new SelectionModel(this._multiple); /** View to model callback that should be called whenever the selected options change. */ private _onChange: (value: any) => void = (_: any) => {}; @@ -397,6 +419,8 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD } ngAfterContentInit(): void { + this._contentInitialized = true; + this._keyManager = new FocusKeyManager(this.options) .withWrap() .withTypeAhead() @@ -589,7 +613,7 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD if (focusedIndex != null && this._isValidIndex(focusedIndex)) { let focusedOption: MatListOption = this.options.toArray()[focusedIndex]; - if (focusedOption && !focusedOption.disabled) { + if (focusedOption && !focusedOption.disabled && (this._multiple || !focusedOption.selected)) { focusedOption.toggle(); // Emit a change event because the focused option changed its state through user @@ -642,4 +666,5 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD static ngAcceptInputType_disabled: BooleanInput; static ngAcceptInputType_disableRipple: BooleanInput; + static ngAcceptInputType_multiple: BooleanInput; } diff --git a/tools/public_api_guard/material/list.d.ts b/tools/public_api_guard/material/list.d.ts index 8b7ad109681d..57c2d107e6ec 100644 --- a/tools/public_api_guard/material/list.d.ts +++ b/tools/public_api_guard/material/list.d.ts @@ -94,6 +94,7 @@ export declare class MatSelectionList extends _MatSelectionListMixinBase impleme color: ThemePalette; compareWith: (o1: any, o2: any) => boolean; disabled: boolean; + multiple: boolean; options: QueryList; selectedOptions: SelectionModel; readonly selectionChange: EventEmitter; @@ -116,7 +117,8 @@ export declare class MatSelectionList extends _MatSelectionListMixinBase impleme writeValue(values: string[]): void; static ngAcceptInputType_disableRipple: BooleanInput; static ngAcceptInputType_disabled: BooleanInput; - static ɵcmp: i0.ɵɵComponentDefWithMeta; + static ngAcceptInputType_multiple: BooleanInput; + static ɵcmp: i0.ɵɵComponentDefWithMeta; static ɵfac: i0.ɵɵFactoryDef; }