Skip to content

Commit 3b242f0

Browse files
kseamonandrewseguin
authored andcommitted
feat(list): Add single select mode. (#18126)
* feat(list): Add single select mode. * review changes * lint * review changes * fix tests
1 parent d550e4e commit 3b242f0

File tree

11 files changed

+172
-6
lines changed

11 files changed

+172
-6
lines changed

src/components-examples/material/list/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@ import {MatListModule} from '@angular/material/list';
55
import {ListOverviewExample} from './list-overview/list-overview-example';
66
import {ListSectionsExample} from './list-sections/list-sections-example';
77
import {ListSelectionExample} from './list-selection/list-selection-example';
8+
import {ListSingleSelectionExample} from './list-single-selection/list-single-selection-example';
89

910
export {
1011
ListOverviewExample,
1112
ListSectionsExample,
1213
ListSelectionExample,
14+
ListSingleSelectionExample,
1315
};
1416

1517
const EXAMPLES = [
1618
ListOverviewExample,
1719
ListSectionsExample,
1820
ListSelectionExample,
21+
ListSingleSelectionExample,
1922
];
2023

2124
@NgModule({
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/** No styles for this example. */
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<mat-selection-list #shoes [multiple]="false">
2+
<mat-list-option *ngFor="let shoe of typesOfShoes">
3+
{{shoe}}
4+
</mat-list-option>
5+
</mat-selection-list>
6+
7+
<p>
8+
Option selected: {{shoes.selectedOptions.selected}}
9+
</p>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {Component} from '@angular/core';
2+
3+
/**
4+
* @title List with single selection
5+
*/
6+
@Component({
7+
selector: 'list-single-selection-example',
8+
styleUrls: ['list-single-selection-example.css'],
9+
templateUrl: 'list-single-selection-example.html',
10+
})
11+
export class ListSingleSelectionExample {
12+
typesOfShoes: string[] = ['Boots', 'Clogs', 'Loafers', 'Moccasins', 'Sneakers'];
13+
}

src/dev-app/list/list-demo.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,22 @@ <h3 mat-subheader>Dogs</h3>
162162
<button mat-raised-button (click)="groceries.deselectAll()">Deselect all</button>
163163
</p>
164164
</div>
165+
166+
<div>
167+
<h2>Single Selection list</h2>
168+
169+
<mat-selection-list #favorite
170+
[(ngModel)]="favoriteOptions"
171+
[multiple]="false"
172+
color="primary">
173+
<h3 mat-subheader>Favorite Grocery</h3>
174+
175+
<mat-list-option value="bananas">Bananas</mat-list-option>
176+
<mat-list-option selected value="oranges">Oranges</mat-list-option>
177+
<mat-list-option value="apples">Apples</mat-list-option>
178+
<mat-list-option value="strawberries" color="warn">Strawberries</mat-list-option>
179+
</mat-selection-list>
180+
181+
<p>Selected: {{favoriteOptions | json}}</p>
182+
</div>
165183
</div>

src/dev-app/list/list-demo.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export class ListDemo {
7070
this.modelChangeEventCount++;
7171
}
7272

73+
favoriteOptions: string[] = [];
74+
7375
alertItem(msg: string) {
7476
alert(msg);
7577
}

src/material/list/_list-theme.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
background: mat-color($background, 'hover');
3434
}
3535
}
36+
37+
.mat-list-single-selected-option {
38+
&, &:hover, &:focus {
39+
background: mat-color($background, hover, 0.12);
40+
}
41+
}
3642
}
3743

3844
@mixin mat-list-typography($config) {

src/material/list/list-option.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
[matRippleDisabled]="_isRippleDisabled()"></div>
88

99
<mat-pseudo-checkbox
10+
*ngIf="selectionList.multiple"
1011
[state]="selected ? 'checked' : 'unchecked'"
1112
[disabled]="disabled"></mat-pseudo-checkbox>
1213

src/material/list/selection-list.spec.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,16 @@ describe('MatSelectionList without forms', () => {
188188
expect(selectList.selected.length).toBe(0);
189189
});
190190

191+
it('should not add the mat-list-single-selected-option class (in multiple mode)', () => {
192+
let testListItem = listOptions[2].injector.get<MatListOption>(MatListOption);
193+
194+
testListItem._handleClick();
195+
fixture.detectChanges();
196+
197+
expect(listOptions[2].nativeElement.classList.contains('mat-list-single-selected-option'))
198+
.toBe(false);
199+
});
200+
191201
it('should not allow selection of disabled items', () => {
192202
let testListItem = listOptions[0].injector.get<MatListOption>(MatListOption);
193203
let selectList =
@@ -882,6 +892,80 @@ describe('MatSelectionList without forms', () => {
882892
expect(listOption.classList).toContain('mat-list-item-with-avatar');
883893
});
884894
});
895+
896+
describe('with single selection', () => {
897+
let fixture: ComponentFixture<SelectionListWithListOptions>;
898+
let listOption: DebugElement[];
899+
let selectionList: DebugElement;
900+
901+
beforeEach(async(() => {
902+
TestBed.configureTestingModule({
903+
imports: [MatListModule],
904+
declarations: [
905+
SelectionListWithListOptions,
906+
],
907+
}).compileComponents();
908+
909+
fixture = TestBed.createComponent(SelectionListWithListOptions);
910+
fixture.componentInstance.multiple = false;
911+
listOption = fixture.debugElement.queryAll(By.directive(MatListOption));
912+
selectionList = fixture.debugElement.query(By.directive(MatSelectionList))!;
913+
fixture.detectChanges();
914+
}));
915+
916+
it('should select one option at a time', () => {
917+
const testListItem1 = listOption[1].injector.get<MatListOption>(MatListOption);
918+
const testListItem2 = listOption[2].injector.get<MatListOption>(MatListOption);
919+
const selectList =
920+
selectionList.injector.get<MatSelectionList>(MatSelectionList).selectedOptions;
921+
922+
expect(selectList.selected.length).toBe(0);
923+
924+
dispatchFakeEvent(testListItem1._getHostElement(), 'click');
925+
fixture.detectChanges();
926+
927+
expect(selectList.selected).toEqual([testListItem1]);
928+
expect(listOption[1].nativeElement.classList.contains('mat-list-single-selected-option'))
929+
.toBe(true);
930+
931+
dispatchFakeEvent(testListItem2._getHostElement(), 'click');
932+
fixture.detectChanges();
933+
934+
expect(selectList.selected).toEqual([testListItem2]);
935+
expect(listOption[1].nativeElement.classList.contains('mat-list-single-selected-option'))
936+
.toBe(false);
937+
expect(listOption[2].nativeElement.classList.contains('mat-list-single-selected-option'))
938+
.toBe(true);
939+
});
940+
941+
it('should not show check boxes', () => {
942+
expect(fixture.nativeElement.querySelector('mat-pseudo-checkbox')).toBeFalsy();
943+
});
944+
945+
it('should not deselect the target option on click', () => {
946+
const testListItem1 = listOption[1].injector.get<MatListOption>(MatListOption);
947+
const selectList =
948+
selectionList.injector.get<MatSelectionList>(MatSelectionList).selectedOptions;
949+
950+
expect(selectList.selected.length).toBe(0);
951+
952+
dispatchFakeEvent(testListItem1._getHostElement(), 'click');
953+
fixture.detectChanges();
954+
955+
expect(selectList.selected).toEqual([testListItem1]);
956+
957+
dispatchFakeEvent(testListItem1._getHostElement(), 'click');
958+
fixture.detectChanges();
959+
960+
expect(selectList.selected).toEqual([testListItem1]);
961+
});
962+
963+
it('throws an exception when toggling single/multiple mode after bootstrap', () => {
964+
fixture.componentInstance.multiple = true;
965+
expect(() => fixture.detectChanges()).toThrow(new Error(
966+
'Cannot change `multiple` mode of mat-selection-list after initialization.'));
967+
});
968+
});
885969
});
886970

887971
describe('MatSelectionList with forms', () => {
@@ -1255,7 +1339,8 @@ describe('MatSelectionList with forms', () => {
12551339
id="selection-list-1"
12561340
(selectionChange)="onValueChange($event)"
12571341
[disableRipple]="listRippleDisabled"
1258-
[color]="selectionListColor">
1342+
[color]="selectionListColor"
1343+
[multiple]="multiple">
12591344
<mat-list-option checkboxPosition="before" disabled="true" value="inbox"
12601345
[color]="firstOptionColor">
12611346
Inbox (disabled selection-option)
@@ -1274,6 +1359,7 @@ describe('MatSelectionList with forms', () => {
12741359
class SelectionListWithListOptions {
12751360
showLastOption: boolean = true;
12761361
listRippleDisabled = false;
1362+
multiple = true;
12771363
selectionListColor: ThemePalette;
12781364
firstOptionColor: ThemePalette;
12791365

src/material/list/selection-list.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
SimpleChanges,
4141
ViewChild,
4242
ViewEncapsulation,
43+
isDevMode,
4344
} from '@angular/core';
4445
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
4546
import {
@@ -50,6 +51,7 @@ import {
5051
setLines,
5152
ThemePalette,
5253
} from '@angular/material/core';
54+
5355
import {Subject} from 'rxjs';
5456
import {takeUntil} from 'rxjs/operators';
5557

@@ -108,6 +110,7 @@ export class MatSelectionListChange {
108110
// be placed inside a parent that has one of the other colors with a higher specificity.
109111
'[class.mat-accent]': 'color !== "primary" && color !== "warn"',
110112
'[class.mat-warn]': 'color === "warn"',
113+
'[class.mat-list-single-selected-option]': 'selected && !selectionList.multiple',
111114
'[attr.aria-selected]': 'selected',
112115
'[attr.aria-disabled]': 'disabled',
113116
},
@@ -255,7 +258,7 @@ export class MatListOption extends _MatListOptionMixinBase implements AfterConte
255258
}
256259

257260
_handleClick() {
258-
if (!this.disabled) {
261+
if (!this.disabled && (this.selectionList.multiple || !this.selected)) {
259262
this.toggle();
260263

261264
// 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
324327
'class': 'mat-selection-list mat-list-base',
325328
'(blur)': '_onTouched()',
326329
'(keydown)': '_keydown($event)',
327-
'aria-multiselectable': 'true',
330+
'[attr.aria-multiselectable]': 'multiple',
328331
'[attr.aria-disabled]': 'disabled.toString()',
329332
},
330333
template: '<ng-content></ng-content>',
@@ -335,6 +338,8 @@ export class MatListOption extends _MatListOptionMixinBase implements AfterConte
335338
})
336339
export class MatSelectionList extends _MatSelectionListMixinBase implements CanDisableRipple,
337340
AfterContentInit, ControlValueAccessor, OnDestroy, OnChanges {
341+
private _multiple = true;
342+
private _contentInitialized = false;
338343

339344
/** The FocusKeyManager which handles focus. */
340345
_keyManager: FocusKeyManager<MatListOption>;
@@ -373,8 +378,25 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
373378
}
374379
private _disabled: boolean = false;
375380

381+
/** Whether selection is limited to one or multiple items (default multiple). */
382+
@Input()
383+
get multiple(): boolean { return this._multiple; }
384+
set multiple(value: boolean) {
385+
const newValue = coerceBooleanProperty(value);
386+
387+
if (newValue !== this._multiple) {
388+
if (isDevMode() && this._contentInitialized) {
389+
throw new Error(
390+
'Cannot change `multiple` mode of mat-selection-list after initialization.');
391+
}
392+
393+
this._multiple = newValue;
394+
this.selectedOptions = new SelectionModel(this._multiple, this.selectedOptions.selected);
395+
}
396+
}
397+
376398
/** The currently selected options. */
377-
selectedOptions: SelectionModel<MatListOption> = new SelectionModel<MatListOption>(true);
399+
selectedOptions = new SelectionModel<MatListOption>(this._multiple);
378400

379401
/** View to model callback that should be called whenever the selected options change. */
380402
private _onChange: (value: any) => void = (_: any) => {};
@@ -397,6 +419,8 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
397419
}
398420

399421
ngAfterContentInit(): void {
422+
this._contentInitialized = true;
423+
400424
this._keyManager = new FocusKeyManager<MatListOption>(this.options)
401425
.withWrap()
402426
.withTypeAhead()
@@ -589,7 +613,7 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
589613
if (focusedIndex != null && this._isValidIndex(focusedIndex)) {
590614
let focusedOption: MatListOption = this.options.toArray()[focusedIndex];
591615

592-
if (focusedOption && !focusedOption.disabled) {
616+
if (focusedOption && !focusedOption.disabled && (this._multiple || !focusedOption.selected)) {
593617
focusedOption.toggle();
594618

595619
// Emit a change event because the focused option changed its state through user
@@ -642,4 +666,5 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
642666

643667
static ngAcceptInputType_disabled: BooleanInput;
644668
static ngAcceptInputType_disableRipple: BooleanInput;
669+
static ngAcceptInputType_multiple: BooleanInput;
645670
}

tools/public_api_guard/material/list.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export declare class MatSelectionList extends _MatSelectionListMixinBase impleme
9494
color: ThemePalette;
9595
compareWith: (o1: any, o2: any) => boolean;
9696
disabled: boolean;
97+
multiple: boolean;
9798
options: QueryList<MatListOption>;
9899
selectedOptions: SelectionModel<MatListOption>;
99100
readonly selectionChange: EventEmitter<MatSelectionListChange>;
@@ -116,7 +117,8 @@ export declare class MatSelectionList extends _MatSelectionListMixinBase impleme
116117
writeValue(values: string[]): void;
117118
static ngAcceptInputType_disableRipple: BooleanInput;
118119
static ngAcceptInputType_disabled: BooleanInput;
119-
static ɵcmp: i0.ɵɵComponentDefWithMeta<MatSelectionList, "mat-selection-list", ["matSelectionList"], { "disableRipple": "disableRipple"; "tabIndex": "tabIndex"; "color": "color"; "compareWith": "compareWith"; "disabled": "disabled"; }, { "selectionChange": "selectionChange"; }, ["options"]>;
120+
static ngAcceptInputType_multiple: BooleanInput;
121+
static ɵcmp: i0.ɵɵComponentDefWithMeta<MatSelectionList, "mat-selection-list", ["matSelectionList"], { "disableRipple": "disableRipple"; "tabIndex": "tabIndex"; "color": "color"; "compareWith": "compareWith"; "disabled": "disabled"; "multiple": "multiple"; }, { "selectionChange": "selectionChange"; }, ["options"]>;
120122
static ɵfac: i0.ɵɵFactoryDef<MatSelectionList>;
121123
}
122124

0 commit comments

Comments
 (0)