diff --git a/src/dev-app/list/list-demo.html b/src/dev-app/list/list-demo.html index fbb560fdf191..2be72e3bbba6 100644 --- a/src/dev-app/list/list-demo.html +++ b/src/dev-app/list/list-demo.html @@ -123,6 +123,7 @@

Selection list

(selectionChange)="changeEventCount = changeEventCount + 1" [disabled]="selectionListDisabled" [disableRipple]="selectionListRippleDisabled" + [hideSingleSelectionIndicator]="selectionListSingleSelectionIndicatorHidden" color="primary">
Groceries
@@ -161,6 +162,12 @@

Selection list

+

+ +

@@ -173,6 +180,7 @@

Single Selection list

Favorite Grocery
diff --git a/src/dev-app/list/list-demo.ts b/src/dev-app/list/list-demo.ts index bad097b791fa..30ff2459576f 100644 --- a/src/dev-app/list/list-demo.ts +++ b/src/dev-app/list/list-demo.ts @@ -64,6 +64,7 @@ export class ListDemo { infoClicked = false; selectionListDisabled = false; selectionListRippleDisabled = false; + selectionListSingleSelectionIndicatorHidden = false; selectedOptions: string[] = ['apples']; changeEventCount = 0; diff --git a/src/material/list/_interactive-list-theme.scss b/src/material/list/_interactive-list-theme.scss index 8c40ba7585e8..b3c36d8f005d 100644 --- a/src/material/list/_interactive-list-theme.scss +++ b/src/material/list/_interactive-list-theme.scss @@ -1,11 +1,13 @@ @use 'sass:map'; @use '@material/ripple' as mdc-ripple; +@use '../core/theming/theming'; // Mixin that provides colors for the various states of an interactive list-item. MDC // has integrated styles for these states but relies on their complex ripples for it. @mixin private-interactive-list-item-state-colors($config) { $is-dark-theme: map.get($config, is-dark); $active-base-color: if($is-dark-theme, white, black); + $selected-color: theming.get-color-from-palette(map.get($config, primary)); .mat-mdc-list-item-interactive { &::before { @@ -19,5 +21,20 @@ &:focus::before { opacity: mdc-ripple.states-opacity($active-base-color, focus); } + + &.mdc-list-item--selected { + &::before { + background: $selected-color; + opacity: mdc-ripple.states-opacity($selected-color, selected); + } + + &:not(:focus):not(.mdc-list-item--disabled):hover::before { + // The hover and selected opacities need to be combined to match with what the MDC + // ripple state would render. More details here: + // https://github.com/material-components/material-components-web/blob/348665978ce73694ad4518626dd70cdf5b984113/packages/mdc-ripple/_ripple-theme.scss#L450. + opacity: mdc-ripple.states-opacity($selected-color, hover) + + mdc-ripple.states-opacity($selected-color, selected); + } + } } } diff --git a/src/material/list/list-base.ts b/src/material/list/list-base.ts index a5341e05cc9e..327d2ec3698c 100644 --- a/src/material/list/list-base.ts +++ b/src/material/list/list-base.ts @@ -13,6 +13,7 @@ import { ContentChildren, Directive, ElementRef, + inject, Inject, Input, NgZone, @@ -35,6 +36,7 @@ import { MatListItemIcon, MatListItemAvatar, } from './list-item-sections'; +import {MAT_LIST_CONFIG} from './tokens'; @Directive({ host: { @@ -67,6 +69,8 @@ export abstract class MatListBase { this._disabled = coerceBooleanProperty(value); } private _disabled = false; + + protected _defaultOptions = inject(MAT_LIST_CONFIG, {optional: true}); } @Directive({ diff --git a/src/material/list/list-option.ts b/src/material/list/list-option.ts index 1bcf18d1360c..25552ee5924c 100644 --- a/src/material/list/list-option.ts +++ b/src/material/list/list-option.ts @@ -50,6 +50,7 @@ export interface SelectionList extends MatListBase { multiple: boolean; color: ThemePalette; selectedOptions: SelectionModel; + hideSingleSelectionIndicator: boolean; compareWith: (o1: any, o2: any) => boolean; _value: string[] | null; _reportValueChange(): void; @@ -64,6 +65,10 @@ export interface SelectionList extends MatListBase { host: { 'class': 'mat-mdc-list-item mat-mdc-list-option mdc-list-item', 'role': 'option', + // As per MDC, only list items without checkbox or radio indicator should receive the + // `--selected` class. + '[class.mdc-list-item--selected]': + 'selected && !_selectionList.multiple && _selectionList.hideSingleSelectionIndicator', // Based on the checkbox/radio position and whether there are icons or avatars, we apply MDC's // list-item `--leading` and `--trailing` classes. '[class.mdc-list-item--with-leading-avatar]': '_hasProjected("avatars", "before")', @@ -243,7 +248,11 @@ export class MatListOption extends MatListItemBase implements ListOption, OnInit /** Where a radio indicator is shown at the given position. */ _hasRadioAt(position: MatListOptionTogglePosition): boolean { - return !this._selectionList.multiple && this._getTogglePosition() === position; + return ( + !this._selectionList.multiple && + this._getTogglePosition() === position && + !this._selectionList.hideSingleSelectionIndicator + ); } /** Whether icons or avatars are shown at the given position. */ diff --git a/src/material/list/public-api.ts b/src/material/list/public-api.ts index 010eab276ae0..8f1c32f610fd 100644 --- a/src/material/list/public-api.ts +++ b/src/material/list/public-api.ts @@ -14,6 +14,7 @@ export * from './selection-list'; export * from './list-option'; export * from './subheader'; export * from './list-item-sections'; +export * from './tokens'; export {MatListOption} from './list-option'; diff --git a/src/material/list/selection-list.spec.ts b/src/material/list/selection-list.spec.ts index 3aff5f76e789..c908b52a24f6 100644 --- a/src/material/list/selection-list.spec.ts +++ b/src/material/list/selection-list.spec.ts @@ -30,6 +30,8 @@ import { MatListOptionTogglePosition, MatSelectionList, MatSelectionListChange, + MatListConfig, + MAT_LIST_CONFIG, } from './index'; describe('MDC-based MatSelectionList without forms', () => { @@ -663,7 +665,7 @@ describe('MDC-based MatSelectionList without forms', () => { }); }); - describe('with list option selected', () => { + describe('multiple-selection with list option selected', () => { let fixture: ComponentFixture; let listOptionElements: DebugElement[]; let selectionList: DebugElement; @@ -703,6 +705,79 @@ describe('MDC-based MatSelectionList without forms', () => { })); }); + describe('single-selection with list option selected', () => { + let fixture: ComponentFixture; + let listOptionElements: DebugElement[]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [MatListModule], + declarations: [SingleSelectionListWithSelectedOption], + }); + + TestBed.compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(SingleSelectionListWithSelectedOption); + listOptionElements = fixture.debugElement.queryAll(By.directive(MatListOption))!; + fixture.detectChanges(); + })); + + it('displays radio indicators by default', () => { + expect( + listOptionElements[0].nativeElement.querySelector('input[type="radio"]'), + ).not.toBeNull(); + expect( + listOptionElements[1].nativeElement.querySelector('input[type="radio"]'), + ).not.toBeNull(); + + expect(listOptionElements[0].nativeElement.classList).not.toContain( + 'mdc-list-item--selected', + ); + expect(listOptionElements[1].nativeElement.classList).not.toContain( + 'mdc-list-item--selected', + ); + }); + }); + + describe('with token to hide radio indicators', () => { + let fixture: ComponentFixture; + let listOptionElements: DebugElement[]; + + beforeEach(waitForAsync(() => { + const matListConfig: MatListConfig = {hideSingleSelectionIndicator: true}; + + TestBed.configureTestingModule({ + imports: [MatListModule], + declarations: [SingleSelectionListWithSelectedOption], + providers: [{provide: MAT_LIST_CONFIG, useValue: matListConfig}], + }); + + TestBed.compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(SingleSelectionListWithSelectedOption); + listOptionElements = fixture.debugElement.queryAll(By.directive(MatListOption))!; + fixture.detectChanges(); + })); + + it('does not display radio indicators', () => { + expect(listOptionElements[0].nativeElement.querySelector('input[type="radio"]')).toBeNull(); + expect(listOptionElements[1].nativeElement.querySelector('input[type="radio"]')).toBeNull(); + + expect(listOptionElements[0].nativeElement.classList).not.toContain( + 'mdc-list-item--selected', + ); + + expect(listOptionElements[1].nativeElement.getAttribute('aria-selected')) + .withContext('Expected second option to be selected') + .toBe('true'); + expect(listOptionElements[1].nativeElement.classList).toContain('mdc-list-item--selected'); + }); + }); + describe('with option disabled', () => { let fixture: ComponentFixture; let listOptionEl: HTMLElement; @@ -1727,6 +1802,15 @@ class SelectionListWithDisabledOption { }) class SelectionListWithSelectedOption {} +@Component({ + template: ` + + Not selected - Item #1 + Pre-selected - Item #2 + `, +}) +class SingleSelectionListWithSelectedOption {} + @Component({ template: ` diff --git a/src/material/list/selection-list.ts b/src/material/list/selection-list.ts index 7f5f91696c20..0ec4ac7a9e57 100644 --- a/src/material/list/selection-list.ts +++ b/src/material/list/selection-list.ts @@ -123,6 +123,17 @@ export class MatSelectionList } private _multiple = true; + /** Whether radio indicator for all list items is hidden. */ + @Input() + get hideSingleSelectionIndicator(): boolean { + return this._hideSingleSelectionIndicator; + } + set hideSingleSelectionIndicator(value: BooleanInput) { + this._hideSingleSelectionIndicator = coerceBooleanProperty(value); + } + private _hideSingleSelectionIndicator: boolean = + this._defaultOptions?.hideSingleSelectionIndicator ?? false; + /** The currently selected options. */ selectedOptions = new SelectionModel(this._multiple); @@ -160,10 +171,12 @@ export class MatSelectionList ngOnChanges(changes: SimpleChanges) { const disabledChanges = changes['disabled']; const disableRippleChanges = changes['disableRipple']; + const hideSingleSelectionIndicatorChanges = changes['hideSingleSelectionIndicator']; if ( (disableRippleChanges && !disableRippleChanges.firstChange) || - (disabledChanges && !disabledChanges.firstChange) + (disabledChanges && !disabledChanges.firstChange) || + (hideSingleSelectionIndicatorChanges && !hideSingleSelectionIndicatorChanges.firstChange) ) { this._markOptionsForCheck(); } diff --git a/src/material/list/tokens.ts b/src/material/list/tokens.ts new file mode 100644 index 000000000000..9f0ccb30ff0a --- /dev/null +++ b/src/material/list/tokens.ts @@ -0,0 +1,18 @@ +/** + * @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 {InjectionToken} from '@angular/core'; + +/** Object that can be used to configure the default options for the list module. */ +export interface MatListConfig { + /** Wheter icon indicators should be hidden for single-selection. */ + hideSingleSelectionIndicator?: boolean; +} + +/** Injection token that can be used to provide the default options the list module. */ +export const MAT_LIST_CONFIG = new InjectionToken('MAT_LIST_CONFIG'); diff --git a/tools/public_api_guard/material/list.md b/tools/public_api_guard/material/list.md index e5c92cb29e89..5ccf15c94b80 100644 --- a/tools/public_api_guard/material/list.md +++ b/tools/public_api_guard/material/list.md @@ -32,6 +32,9 @@ import { ThemePalette } from '@angular/material/core'; // @public export const MAT_LIST: InjectionToken; +// @public +export const MAT_LIST_CONFIG: InjectionToken; + // @public export const MAT_NAV_LIST: InjectionToken; @@ -56,6 +59,11 @@ export class MatList extends MatListBase { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export interface MatListConfig { + hideSingleSelectionIndicator?: boolean; +} + // @public (undocumented) export class MatListItem extends MatListItemBase { constructor(element: ElementRef, ngZone: NgZone, listBase: MatListBase | null, platform: Platform, globalRippleOptions?: RippleGlobalOptions, animationMode?: string); @@ -229,6 +237,8 @@ export class MatSelectionList extends MatListBase implements SelectionList, Cont _emitChangeEvent(options: MatListOption[]): void; focus(options?: FocusOptions): void; _handleKeydown(event: KeyboardEvent): void; + get hideSingleSelectionIndicator(): boolean; + set hideSingleSelectionIndicator(value: BooleanInput); // (undocumented) _items: QueryList; get multiple(): boolean; @@ -251,7 +261,7 @@ export class MatSelectionList extends MatListBase implements SelectionList, Cont _value: string[] | null; writeValue(values: string[]): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -277,6 +287,8 @@ export interface SelectionList extends MatListBase { // (undocumented) _emitChangeEvent(options: MatListOption[]): void; // (undocumented) + hideSingleSelectionIndicator: boolean; + // (undocumented) multiple: boolean; // (undocumented) _onTouched(): void;