From 11334dfcd92e5596802dfe24dd38a6dde4af6ed4 Mon Sep 17 00:00:00 2001 From: Zach Arend Date: Thu, 15 Dec 2022 18:11:35 +0000 Subject: [PATCH] fix(material/list): add opt-out for radio indicators Add an opt-out for radio indicators for single-selection. Adds both an Input and DI token to specify if radio indicators are hidden. By default, display radio indicators. If both DI token and Input are specified, the Input wins. PR #25933 added radio toggles for single-selection. Add an opt-out to provide a way to have same appearance as before #25933. API changes - add `@Input hideSingleSelectionIndicator` to specify if radio indicators are displayed - add MAT_LIST_CONFIG token to specify default value for `hideSingleSelectionIndicator` --- src/dev-app/list/list-demo.html | 8 ++ src/dev-app/list/list-demo.ts | 1 + .../list/_interactive-list-theme.scss | 17 ++++ src/material/list/list-base.ts | 4 + src/material/list/list-option.ts | 11 ++- src/material/list/public-api.ts | 1 + src/material/list/selection-list.spec.ts | 86 ++++++++++++++++++- src/material/list/selection-list.ts | 15 +++- src/material/list/tokens.ts | 18 ++++ tools/public_api_guard/material/list.md | 14 ++- 10 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 src/material/list/tokens.ts 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;