From 00772010147f45c87eea806a777bc6a62369f59d Mon Sep 17 00:00:00 2001 From: Zach Arend Date: Thu, 10 Nov 2022 04:36:25 +0000 Subject: [PATCH] fix(material/core): add checkmark for single-select Add checkmark to mat-option for single-select. Fix a11y issues where selected state is visually communicated with color alone. Communicate selection with both color and a checkmark indicator. Affect components that use mat-option for single-selection, which include select and autocomplete. Add an `appearance` Input to mat-pseudo-checkbox. "full" appearance renders a checkbox, which is the current behavior. Render "full" appearance by default. "minimal" apperance renders only a checkmark. Add an opt-out to Selection and Autocomplete components for checkmark indicators for single-selection. Add both Input and DI token to specify if checkmark indicators are hidden for single-select. By default display checkmark indicators for single-selection. If both DI token and Input are specified, the Input wins. Does not affect multiple-selection. Does not affect legacy components. Summary of API and behavior changes: - Add an `@Input appearance` to pseudo-checkbox with options for "full" and "minimal". "full" appearance is same and current appearance, which renders a checkmark inside a box. "minimal" appearance renders the checkmark without a box. - By default, mat-option renders "minimal" appearance for single-select. - Add `hideSingleSelectionIndicator` property to `MatOptionParentComponent`. mat-option hides single-selection indicator when specified by its parent. - by default, Select and Autocomplete components display checkmark on selected option. - Both Autocomplete and Select add `@Input hideSingleSelectionIndicator` to specify if checkmark indicator is displayed for single-selection. - Add `hideSingleSelectionIndicator` property to `MatSelectConfig`, which specifies default value for `hideSingleSelectionIndicator`. - Add `hideSingleSelectionIndicator` property to `MatAutocompleteDefaultOptions`, which specifies default value for `hideSingleSelectionIndicator`. Fixes: #25961 --- src/dev-app/autocomplete/BUILD.bazel | 1 + .../autocomplete/autocomplete-demo.html | 21 +++-- src/dev-app/autocomplete/autocomplete-demo.ts | 6 ++ src/dev-app/checkbox/checkbox-demo.html | 27 ++++-- src/dev-app/select/BUILD.bazel | 1 + src/dev-app/select/select-demo.html | 46 +++++++++- src/dev-app/select/select-demo.scss | 9 ++ src/dev-app/select/select-demo.ts | 46 ++++++++-- .../autocomplete/autocomplete.spec.ts | 47 +++++++++++ src/material/autocomplete/autocomplete.ts | 38 +++++++-- src/material/core/option/option-parent.ts | 1 + src/material/core/option/option.html | 5 ++ src/material/core/option/option.scss | 19 ++++- src/material/core/option/option.ts | 7 +- .../_pseudo-checkbox-common.scss | 73 +++++++++++++--- .../_pseudo-checkbox-theme.scss | 69 +++++++++------ .../pseudo-checkbox/pseudo-checkbox.scss | 12 +-- .../pseudo-checkbox/pseudo-checkbox.ts | 8 ++ src/material/select/select.spec.ts | 83 +++++++++++++++++++ src/material/select/select.ts | 26 +++++- .../public_api_guard/material/autocomplete.md | 10 ++- tools/public_api_guard/material/core.md | 8 +- tools/public_api_guard/material/select.md | 8 +- 23 files changed, 492 insertions(+), 79 deletions(-) diff --git a/src/dev-app/autocomplete/BUILD.bazel b/src/dev-app/autocomplete/BUILD.bazel index 078e5bf15d53..efc5063c8511 100644 --- a/src/dev-app/autocomplete/BUILD.bazel +++ b/src/dev-app/autocomplete/BUILD.bazel @@ -13,6 +13,7 @@ ng_module( "//src/material/autocomplete", "//src/material/button", "//src/material/card", + "//src/material/checkbox", "//src/material/form-field", "//src/material/input", "@npm//@angular/forms", diff --git a/src/dev-app/autocomplete/autocomplete-demo.html b/src/dev-app/autocomplete/autocomplete-demo.html index 13ef205b7868..546de5dd9c39 100644 --- a/src/dev-app/autocomplete/autocomplete-demo.html +++ b/src/dev-app/autocomplete/autocomplete-demo.html @@ -10,7 +10,8 @@ State - + {{ state.name }} ({{ state.code }}) @@ -23,11 +24,11 @@ - + + + + Hide Single-Selection Indicator + @@ -42,7 +43,8 @@ State - + {{ state.name }} @@ -61,6 +63,11 @@ + + + Hide Single-Selection Indicator + + diff --git a/src/dev-app/autocomplete/autocomplete-demo.ts b/src/dev-app/autocomplete/autocomplete-demo.ts index a64d9b87a03d..18493a066dac 100644 --- a/src/dev-app/autocomplete/autocomplete-demo.ts +++ b/src/dev-app/autocomplete/autocomplete-demo.ts @@ -12,6 +12,7 @@ import {CommonModule} from '@angular/common'; import {MatAutocompleteModule} from '@angular/material/autocomplete'; import {MatButtonModule} from '@angular/material/button'; import {MatCardModule} from '@angular/material/card'; +import {MatCheckboxModule} from '@angular/material/checkbox'; import {MatInputModule} from '@angular/material/input'; import {Observable} from 'rxjs'; import {map, startWith} from 'rxjs/operators'; @@ -38,6 +39,7 @@ export interface StateGroup { MatAutocompleteModule, MatButtonModule, MatCardModule, + MatCheckboxModule, MatInputModule, ReactiveFormsModule, ], @@ -52,6 +54,7 @@ export class AutocompleteDemo { tdStates: State[]; tdDisabled = false; + hideSingleSelectionIndicators = false; reactiveStatesTheme: ThemePalette = 'primary'; templateStatesTheme: ThemePalette = 'primary'; @@ -62,6 +65,9 @@ export class AutocompleteDemo { {value: 'warn', name: 'Warn'}, ]; + reactiveHideSingleSelectionIndicator = false; + templateHideSingleSelectionIndicator = false; + @ViewChild(NgModel) modelDir: NgModel; groupedStates: StateGroup[]; diff --git a/src/dev-app/checkbox/checkbox-demo.html b/src/dev-app/checkbox/checkbox-demo.html index 73937c93eb2f..d185850f53a0 100644 --- a/src/dev-app/checkbox/checkbox-demo.html +++ b/src/dev-app/checkbox/checkbox-demo.html @@ -64,14 +64,29 @@

mat-checkbox: Basic Example

Pseudo checkboxes

- - +
+

Full appearance

+ + + + + + + + +
+
+

Minimal appearance

+ + - - + + - - + + + +

Nested Checklist

diff --git a/src/dev-app/select/BUILD.bazel b/src/dev-app/select/BUILD.bazel index 29f86eda8d8d..ba99ea4e37ce 100644 --- a/src/dev-app/select/BUILD.bazel +++ b/src/dev-app/select/BUILD.bazel @@ -12,6 +12,7 @@ ng_module( deps = [ "//src/material/button", "//src/material/card", + "//src/material/checkbox", "//src/material/form-field", "//src/material/icon", "//src/material/input", diff --git a/src/dev-app/select/select-demo.html b/src/dev-app/select/select-demo.html index d2b63249dcca..d5639d040139 100644 --- a/src/dev-app/select/select-demo.html +++ b/src/dev-app/select/select-demo.html @@ -12,8 +12,8 @@ Drink - None - + None + {{ drink.viewValue }} @@ -52,6 +52,7 @@ + @@ -64,7 +65,7 @@ Pokemon - + {{ creature.viewValue }} @@ -82,6 +83,7 @@ + @@ -356,3 +358,41 @@

Error message with errorStateMatcher

+ + + Narrow + +

+ + Bread + + + {{ bread.viewValue }} + + + + + Meat + + + {{ meat.viewValue }} + + + + + Cheese + + + {{ cheese.viewValue }} + + + +

+ + Hide Single-Selection Indicator + +
+
\ No newline at end of file diff --git a/src/dev-app/select/select-demo.scss b/src/dev-app/select/select-demo.scss index 448dc495ffa9..4c0776362eb8 100644 --- a/src/dev-app/select/select-demo.scss +++ b/src/dev-app/select/select-demo.scss @@ -25,3 +25,12 @@ .demo-card { margin: 30px 0; } + +.demo-narrow { + max-width: 450px; + + .demo-narrow-sandwich { + display: flex; + gap: 16px; + } +} diff --git a/src/dev-app/select/select-demo.ts b/src/dev-app/select/select-demo.ts index 8fe6b1a7461e..0f8fb788672e 100644 --- a/src/dev-app/select/select-demo.ts +++ b/src/dev-app/select/select-demo.ts @@ -16,6 +16,7 @@ import {MatCardModule} from '@angular/material/card'; import {MatIconModule} from '@angular/material/icon'; import {MatButtonModule} from '@angular/material/button'; import {MatInputModule} from '@angular/material/input'; +import {MatCheckboxModule} from '@angular/material/checkbox'; /** Error any time control is invalid */ export class MyErrorStateMatcher implements ErrorStateMatcher { @@ -37,6 +38,7 @@ export class MyErrorStateMatcher implements ErrorStateMatcher { FormsModule, MatButtonModule, MatCardModule, + MatCheckboxModule, MatIconModule, MatInputModule, MatSelectModule, @@ -48,7 +50,9 @@ export class SelectDemo { drinkObjectRequired = false; pokemonRequired = false; drinksDisabled = false; + drinksOptionsDisabled = false; pokemonDisabled = false; + pokemonOptionsDisabled = false; showSelect = false; currentDrink: string; currentDrinkObject: {} | undefined = {value: 'tea-5', viewValue: 'Tea'}; @@ -66,6 +70,12 @@ export class SelectDemo { compareByValue = true; selectFormControl = new FormControl('', Validators.required); + sandwichBread = ''; + sandwichMeat = ''; + sandwichCheese = ''; + + sandwichHideSingleSelectionIndicator = false; + foods = [ {value: null, viewValue: 'None'}, {value: 'steak-0', viewValue: 'Steak'}, @@ -74,19 +84,19 @@ export class SelectDemo { ]; drinks = [ - {value: 'coke-0', viewValue: 'Coke', disabled: false}, + {value: 'coke-0', viewValue: 'Coke'}, { value: 'long-name-1', viewValue: 'Decaf Chocolate Brownie Vanilla Gingerbread Frappuccino', disabled: false, }, - {value: 'water-2', viewValue: 'Water', disabled: false}, - {value: 'pepper-3', viewValue: 'Dr. Pepper', disabled: false}, - {value: 'coffee-4', viewValue: 'Coffee', disabled: false}, - {value: 'tea-5', viewValue: 'Tea', disabled: false}, - {value: 'juice-6', viewValue: 'Orange juice', disabled: false}, - {value: 'wine-7', viewValue: 'Wine', disabled: false}, - {value: 'milk-8', viewValue: 'Milk', disabled: true}, + {value: 'water-2', viewValue: 'Water'}, + {value: 'pepper-3', viewValue: 'Dr. Pepper'}, + {value: 'coffee-4', viewValue: 'Coffee'}, + {value: 'tea-5', viewValue: 'Tea'}, + {value: 'juice-6', viewValue: 'Orange juice'}, + {value: 'wine-7', viewValue: 'Wine'}, + {value: 'milk-8', viewValue: 'Milk'}, ]; pokemon = [ @@ -149,6 +159,26 @@ export class SelectDemo { {value: 'indramon-5', viewValue: 'Indramon'}, ]; + breads = [ + {value: 'white', viewValue: 'White'}, + {value: 'white', viewValue: 'Wheat'}, + {value: 'white', viewValue: 'Sourdough'}, + ]; + + meats = [ + {value: 'turkey', viewValue: 'Turkey'}, + {value: 'bacon', viewValue: 'Bacon'}, + {value: 'veggiePatty', viewValue: 'Veggie Patty'}, + {value: 'tuna', viewValue: 'Tuna'}, + ]; + + cheeses = [ + {value: 'none', viewValue: 'None'}, + {value: 'swiss', viewValue: 'Swiss'}, + {value: 'american', viewValue: 'American'}, + {value: 'cheddar', viewValue: 'Cheddar'}, + ]; + toggleDisabled() { this.foodControl.enabled ? this.foodControl.disable() : this.foodControl.enable(); } diff --git a/src/material/autocomplete/autocomplete.spec.ts b/src/material/autocomplete/autocomplete.spec.ts index caba31899329..86a97786e92f 100644 --- a/src/material/autocomplete/autocomplete.spec.ts +++ b/src/material/autocomplete/autocomplete.spec.ts @@ -46,6 +46,7 @@ import {map, startWith} from 'rxjs/operators'; import { getMatAutocompleteMissingPanelError, MatAutocomplete, + MatAutocompleteDefaultOptions, MatAutocompleteModule, MatAutocompleteOrigin, MatAutocompleteSelectedEvent, @@ -3412,6 +3413,50 @@ describe('MDC-based MatAutocomplete', () => { subscription.unsubscribe(); })); + + describe('a11y', () => { + it('should display checkmark for selection by default', () => { + const fixture = createComponent(AutocompleteWithNgModel); + fixture.componentInstance.selectedState = 'New York'; + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + dispatchFakeEvent(document.querySelector('mat-option')!, 'click'); + fixture.detectChanges(); + + const selectedOption = document.querySelector('mat-option[aria-selected="true"'); + expect(selectedOption).withContext('Expected an option to be selected.').not.toBeNull(); + expect(selectedOption?.querySelector('.mat-pseudo-checkbox.mat-pseudo-checkbox-minimal')) + .withContext( + 'Expected selection option to have a pseudo-checkbox with "minimal" appearance.', + ) + .toBeTruthy(); + }); + }); + + describe('with token to hide single selection indicator', () => { + it('should not display checkmark', () => { + const defaultOptions: MatAutocompleteDefaultOptions = { + hideSingleSelectionIndicator: true, + }; + const fixture = createComponent(AutocompleteWithNgModel, [ + {provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: defaultOptions}, + ]); + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + dispatchFakeEvent(document.querySelector('mat-option')!, 'click'); + fixture.detectChanges(); + + const selectedOption = document.querySelector('mat-option[aria-selected="true"'); + expect(selectedOption).withContext('Expected an option to be selected.').not.toBeNull(); + expect(document.querySelectorAll('.mat-pseudo-checkbox').length).toBe(0); + }); + }); }); const SIMPLE_AUTOCOMPLETE_TEMPLATE = ` @@ -3576,6 +3621,8 @@ class AutocompleteWithNgModel { selectedState: string; states = ['New York', 'Washington', 'Oregon']; + @ViewChild(MatAutocompleteTrigger, {static: true}) trigger: MatAutocompleteTrigger; + constructor() { this.filteredStates = this.states.slice(); } diff --git a/src/material/autocomplete/autocomplete.ts b/src/material/autocomplete/autocomplete.ts index 01fe3576ab02..3e28603edcde 100644 --- a/src/material/autocomplete/autocomplete.ts +++ b/src/material/autocomplete/autocomplete.ts @@ -81,6 +81,9 @@ export interface MatAutocompleteDefaultOptions { /** Class or list of classes to be applied to the autocomplete's overlay panel. */ overlayPanelClass?: string | string[]; + + /** Wheter icon indicators should be hidden for single-selection. */ + hideSingleSelectionIndicator?: boolean; } /** Injection token to be used to override the default options for `mat-autocomplete`. */ @@ -94,7 +97,11 @@ export const MAT_AUTOCOMPLETE_DEFAULT_OPTIONS = new InjectionToken, - @Inject(MAT_AUTOCOMPLETE_DEFAULT_OPTIONS) defaults: MatAutocompleteDefaultOptions, + @Inject(MAT_AUTOCOMPLETE_DEFAULT_OPTIONS) protected _defaults: MatAutocompleteDefaultOptions, platform?: Platform, ) { super(); @@ -242,8 +249,6 @@ export abstract class _MatAutocompleteBase // wasn't resolved in VoiceOver, and if it has, we can remove this and the `inertGroups` // option altogether. this.inertGroups = platform?.SAFARI || false; - this._autoActiveFirstOption = !!defaults.autoActiveFirstOption; - this._autoSelectActiveOption = !!defaults.autoSelectActiveOption; } ngAfterContentInit() { @@ -336,4 +341,25 @@ export class MatAutocomplete extends _MatAutocompleteBase { @ContentChildren(MatOption, {descendants: true}) options: QueryList; protected _visibleClass = 'mat-mdc-autocomplete-visible'; protected _hiddenClass = 'mat-mdc-autocomplete-hidden'; + + /** Whether checkmark indicator for single-selection options is hidden. */ + @Input() + get hideSingleSelectionIndicator(): boolean { + return this._hideSingleSelectionIndicator; + } + set hideSingleSelectionIndicator(value: BooleanInput) { + this._hideSingleSelectionIndicator = coerceBooleanProperty(value); + this._syncParentProperties(); + } + private _hideSingleSelectionIndicator: boolean = + this._defaults.hideSingleSelectionIndicator ?? false; + + /** Syncs the parent state with the individual options. */ + _syncParentProperties(): void { + if (this.options) { + for (const option of this.options) { + option._changeDetectorRef.markForCheck(); + } + } + } } diff --git a/src/material/core/option/option-parent.ts b/src/material/core/option/option-parent.ts index 3cb515aedd97..bd617add9817 100644 --- a/src/material/core/option/option-parent.ts +++ b/src/material/core/option/option-parent.ts @@ -17,6 +17,7 @@ export interface MatOptionParentComponent { disableRipple?: boolean; multiple?: boolean; inertGroups?: boolean; + hideSingleSelectionIndicator?: boolean; } /** diff --git a/src/material/core/option/option.html b/src/material/core/option/option.html index d29859b31e12..e6e94c70c81f 100644 --- a/src/material/core/option/option.html +++ b/src/material/core/option/option.html @@ -5,6 +5,11 @@ + + + ({{ group.label }}) diff --git a/src/material/core/option/option.scss b/src/material/core/option/option.scss index 1657e73b312a..a5f4a7a3ed19 100644 --- a/src/material/core/option/option.scss +++ b/src/material/core/option/option.scss @@ -48,7 +48,7 @@ } .mat-icon, - .mat-pseudo-checkbox { + .mat-pseudo-checkbox-full { margin-right: mdc-list-variables.$side-padding; flex-shrink: 0; @@ -58,6 +58,16 @@ } } + .mat-pseudo-checkbox-minimal { + margin-left: mdc-list-variables.$side-padding; + flex-shrink: 0; + + [dir='rtl'] & { + margin-right: mdc-list-variables.$side-padding; + margin-left: 0; + } + } + // Increase specificity because ripple styles are part of the `mat-core` mixin and can // potentially overwrite the absolute position of the container. .mat-mdc-option-ripple { @@ -84,6 +94,13 @@ font-family: inherit; text-decoration: inherit; text-transform: inherit; + + margin-right: auto; + + [dir='rtl'] & { + margin-right: 0; + margin-left: auto; + } } @include cdk.high-contrast(active, off) { diff --git a/src/material/core/option/option.ts b/src/material/core/option/option.ts index 97e386d9599d..2f427ff9b256 100644 --- a/src/material/core/option/option.ts +++ b/src/material/core/option/option.ts @@ -83,6 +83,11 @@ export class _MatOptionBase implements FocusableOption, AfterViewChecke return !!(this._parent && this._parent.disableRipple); } + /** Whether to display checkmark for single-selection. */ + get hideSingleSelectionIndicator(): boolean { + return !!(this._parent && this._parent.hideSingleSelectionIndicator); + } + /** Event emitted when the option is selected or deselected. */ // tslint:disable-next-line:no-output-on-prefix @Output() readonly onSelectionChange = new EventEmitter>(); @@ -95,7 +100,7 @@ export class _MatOptionBase implements FocusableOption, AfterViewChecke constructor( private _element: ElementRef, - private _changeDetectorRef: ChangeDetectorRef, + public _changeDetectorRef: ChangeDetectorRef, private _parent: MatOptionParentComponent, readonly group: _MatOptgroupBase, ) {} diff --git a/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-common.scss b/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-common.scss index dd93f9cfc93a..6532895a189c 100644 --- a/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-common.scss +++ b/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-common.scss @@ -4,26 +4,79 @@ // Padding inside of a pseudo checkbox. $padding: checkbox-common.$border-width * 2; +// Center a checkmark indicator inside the checkbox. +// +// `$box-size`: size of the checkbox +// `$mark-size`: size of the checkmark indicator +@mixin _checkbox-checked-styles-with-size($box-size, $mark-size) { + // Center a checkmark. `$checkbox-cmmon.$border-width` is the width of the line of the checkmark. + $width: $mark-size; + $height: math.div($mark-size - checkbox-common.$border-width, 2); + + // The rendered length of the short-side of the checkmark graphic when rendered. Add length of + // the border-width since this element is content-box. + $short-side: $height + checkbox-common.$border-width; + + width: $width; + height: $height; + + // Rotate on the center of the element. This makes it easier to center the checkmark graphic. + transform-origin: center; + + // Take negative one times the distance from the top corner of the checkmark graphic to the top + // of the element in its rotated position. This accounts for the top corner of the elemant being + // blank since we only use the left and bottom borders to draw the checkmark graphic. + top: -1 * math.div($short-side - checkbox-common.$border-width, math.sqrt(2)); + + left: 0; + bottom: 0; + right: 0; + + // center the checkmark graphic with margin auto + margin: auto; +} + +// Center a horizontal line placed in the vertical and horizontal center of the checkbox. It does +// not touch the border of the checkbox. +// +// `$box-size`: size of the checkbox. +// `$border-size`: size of the checkbox's border. +@mixin _checkbox-indeterminate-styles-with-size($box-size, $border-size) { + // Center the line in the the checkbox. `$checkbox-common.$border-width` is the width of the line. + top: math.div($box-size - checkbox-common.$border-width, 2) - $border-size; + width: $box-size - checkbox-common.$border-width - (2 * $border-size); +} + /// Applies the styles that set the size of the pseudo checkbox @mixin size($box-size) { - $mark-size: $box-size - (2 * $padding); .mat-pseudo-checkbox { width: $box-size; height: $box-size; } - .mat-pseudo-checkbox-indeterminate::after { - top: math.div($box-size - checkbox-common.$border-width, 2) - - checkbox-common.$border-width; - width: $box-size - 6px; + .mat-pseudo-checkbox-minimal { + $mark-size: $box-size - $padding; + $border-size: 0; // Minimal appearance does not have a border. + + &.mat-pseudo-checkbox-checked::after { + @include _checkbox-checked-styles-with-size($box-size, $mark-size); + } + &.mat-pseudo-checkbox-indeterminate::after { + @include _checkbox-indeterminate-styles-with-size($box-size, $border-size); + } } - .mat-pseudo-checkbox-checked::after { - top: math.div($box-size, 2) - math.div($mark-size, 4) - - math.div($box-size, 10) - checkbox-common.$border-width; - width: $mark-size; - height: math.div($mark-size - checkbox-common.$border-width, 2); + .mat-pseudo-checkbox-full { + $mark-size: $box-size - (2 * $padding); // Apply a smaller mark to account for the border. + $border-size: checkbox-common.$border-width; + + &.mat-pseudo-checkbox-checked::after { + @include _checkbox-checked-styles-with-size($box-size, $mark-size); + } + &.mat-pseudo-checkbox-indeterminate::after { + @include _checkbox-indeterminate-styles-with-size($box-size, $border-size); + } } } diff --git a/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss b/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss index a83fa1410b6d..e0e9d624b338 100644 --- a/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss +++ b/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss @@ -1,13 +1,33 @@ @use 'sass:map'; @use '../../theming/theming'; +@mixin _psuedo-checkbox-styles-with-color($text-color, $background) { + .mat-pseudo-checkbox-checked, + .mat-pseudo-checkbox-indeterminate { + &.mat-pseudo-checkbox-minimal::after { + color: $text-color; + } + + // Full (checkbox) appearance inverts colors of text and background. + &.mat-pseudo-checkbox-full { + &::after { + color: $background; + } + + background: $text-color; + } + } +} + @mixin color($config-or-theme) { $config: theming.get-color-config($config-or-theme); $is-dark-theme: map.get($config, is-dark); - $primary: map.get($config, primary); - $accent: map.get($config, accent); - $warn: map.get($config, warn); - $background: map.get($config, background); + + $primary: theming.get-color-from-palette(map.get($config, primary)); + $accent: theming.get-color-from-palette(map.get($config, accent)); + $warn: theming.get-color-from-palette(map.get($config, warn)); + $background: theming.get-color-from-palette(map.get($config, background), background); + $secondary-text: theming.get-color-from-palette(map.get($config, foreground), secondary-text); // NOTE(traviskaufman): While the spec calls for translucent blacks/whites for disabled colors, // this does not work well with elements layered on top of one another. To get around this we @@ -17,21 +37,15 @@ $disabled-color: if($is-dark-theme, $white-30pct-opacity-on-dark, $black-26pct-opacity-on-light); $colored-box-selector: '.mat-pseudo-checkbox-checked, .mat-pseudo-checkbox-indeterminate'; - .mat-pseudo-checkbox { - color: theming.get-color-from-palette(map.get($config, foreground), secondary-text); - - &::after { - color: theming.get-color-from-palette($background, background); + .mat-pseudo-checkbox-full { + color: $secondary-text; + &.mat-pseudo-checkbox-disabled { + color: $disabled-color; } } - .mat-pseudo-checkbox-disabled { - color: $disabled-color; - } - - .mat-primary .mat-pseudo-checkbox-checked, - .mat-primary .mat-pseudo-checkbox-indeterminate { - background: theming.get-color-from-palette(map.get($config, primary)); + .mat-primary { + @include _psuedo-checkbox-styles-with-color($primary, $background); } // Default to the accent color. Note that the pseudo checkboxes are meant to inherit the @@ -39,21 +53,22 @@ // don't attach to the `mat-*` classes. Also note that this needs to be below `.mat-primary` // in order to allow for the color to be overwritten if the checkbox is inside a parent that // has `mat-accent` and is placed inside another parent that has `mat-primary`. - .mat-pseudo-checkbox-checked, - .mat-pseudo-checkbox-indeterminate, - .mat-accent .mat-pseudo-checkbox-checked, - .mat-accent .mat-pseudo-checkbox-indeterminate { - background: theming.get-color-from-palette(map.get($config, accent)); + @include _psuedo-checkbox-styles-with-color($accent, $background); + .mat-accent { + @include _psuedo-checkbox-styles-with-color($accent, $background); } - .mat-warn .mat-pseudo-checkbox-checked, - .mat-warn .mat-pseudo-checkbox-indeterminate { - background: theming.get-color-from-palette(map.get($config, warn)); + .mat-warn { + @include _psuedo-checkbox-styles-with-color($warn, $background); } - .mat-pseudo-checkbox-checked, - .mat-pseudo-checkbox-indeterminate { - &.mat-pseudo-checkbox-disabled { + .mat-pseudo-checkbox-disabled.mat-pseudo-checkbox-checked, + .mat-pseudo-checkbox-disabled.mat-pseudo-checkbox-indeterminate { + &.mat-pseudo-checkbox-minimal::after { + color: $disabled-color; + } + + &.mat-pseudo-checkbox-full { background: $disabled-color; } } diff --git a/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.scss b/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.scss index dccd8931124c..a1544d57239b 100644 --- a/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.scss +++ b/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.scss @@ -5,7 +5,6 @@ @use './pseudo-checkbox-common'; .mat-pseudo-checkbox { - border: checkbox-common.$border-width solid; border-radius: 2px; cursor: pointer; display: inline-block; @@ -27,10 +26,6 @@ variables.$linear-out-slow-in-timing-function; } - &.mat-pseudo-checkbox-checked, &.mat-pseudo-checkbox-indeterminate { - border-color: transparent; - } - @include private.private-animation-noop { &::after { transition: none; @@ -56,4 +51,11 @@ box-sizing: content-box; } +.mat-pseudo-checkbox-full { + border: checkbox-common.$border-width solid; + &.mat-pseudo-checkbox-checked, &.mat-pseudo-checkbox-indeterminate { + border-color: transparent; + } +} + @include pseudo-checkbox-common.size(checkbox-common.$size); diff --git a/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.ts b/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.ts index 1ce539c7ddd6..4c3252b71d94 100644 --- a/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.ts +++ b/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.ts @@ -46,6 +46,8 @@ export type MatPseudoCheckboxState = 'unchecked' | 'checked' | 'indeterminate'; '[class.mat-pseudo-checkbox-indeterminate]': 'state === "indeterminate"', '[class.mat-pseudo-checkbox-checked]': 'state === "checked"', '[class.mat-pseudo-checkbox-disabled]': 'disabled', + '[class.mat-pseudo-checkbox-minimal]': 'appearance === "minimal"', + '[class.mat-pseudo-checkbox-full]': 'appearance === "full"', '[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"', }, }) @@ -56,5 +58,11 @@ export class MatPseudoCheckbox { /** Whether the checkbox is disabled. */ @Input() disabled: boolean = false; + /** + * Appearance of the pseudo checkbox. Default appearance of 'full' renders a checkmark/mixedmark + * indicator inside a square box. 'minimal' appearance only renders the checkmark/mixedmark. + */ + @Input() appearance: 'minimal' | 'full' = 'full'; + constructor(@Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string) {} } diff --git a/src/material/select/select.spec.ts b/src/material/select/select.spec.ts index 7f3886c56609..3a541e180ffa 100644 --- a/src/material/select/select.spec.ts +++ b/src/material/select/select.spec.ts @@ -1321,6 +1321,56 @@ describe('MDC-based MatSelect', () => { ) .toEqual([options[7]]); })); + + it('should render a checkmark on selected option', fakeAsync(() => { + fixture.componentInstance.control.setValue(fixture.componentInstance.foods[2].value); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const pseudoCheckboxes = options + .map(option => option.querySelector('.mat-pseudo-checkbox-minimal')) + .filter((x): x is HTMLElement => !!x); + const selectedOption = options[2]; + + expect(selectedOption.querySelector('.mat-pseudo-checkbox-minimal')).not.toBeNull(); + expect(pseudoCheckboxes.length).toBe(1); + })); + + it('should render checkboxes for multi-select', fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + multiFixture.detectChanges(); + + multiFixture.componentInstance.control.setValue([ + multiFixture.componentInstance.foods[2].value, + ]); + multiFixture.detectChanges(); + + trigger = multiFixture.debugElement.query( + By.css('.mat-mdc-select-trigger'), + )!.nativeElement; + + trigger.click(); + multiFixture.detectChanges(); + flush(); + + options = Array.from(overlayContainerElement.querySelectorAll('mat-option')); + const pseudoCheckboxes = options + .map(option => option.querySelector('.mat-pseudo-checkbox.mat-pseudo-checkbox-full')) + .filter((x): x is HTMLElement => !!x); + const selectedPseudoCheckbox = pseudoCheckboxes[2]; + + expect(pseudoCheckboxes.length) + .withContext('expecting each option to have a pseudo-checkbox with "full" appearance') + .toEqual(options.length); + expect(selectedPseudoCheckbox.classList) + .withContext('expecting selected pseudo-checkbox to be checked') + .toContain('mat-pseudo-checkbox-checked'); + })); }); describe('for option groups', () => { @@ -4377,6 +4427,39 @@ describe('MDC-based MatSelect', () => { expect(document.querySelector('.cdk-overlay-pane')?.classList).toContain('test-panel-class'); })); + it('should be able to hide checkmark icon through an injection token', () => { + const matSelectConfig: MatSelectConfig = {hideSingleSelectionIndicator: true}; + configureMatSelectTestingModule( + [NgModelSelect], + [ + { + provide: MAT_SELECT_CONFIG, + useValue: matSelectConfig, + }, + ], + ); + const fixture = TestBed.createComponent(NgModelSelect); + fixture.detectChanges(); + const select = fixture.componentInstance.select; + + fixture.componentInstance.select.value = fixture.componentInstance.foods[0].value; + select.open(); + fixture.detectChanges(); + + // Select the first value to ensure selection state is displayed. That way this test ensures + // that the selection state hides the checkmark icon, rather than hiding the checkmark icon + // because nothing is selected. + expect(document.querySelector('mat-option[aria-selected="true"]')) + .withContext('expecting selection state to be displayed') + .not.toBeNull(); + + const pseudoCheckboxes = document.querySelectorAll('.mat-pseudo-checkbox'); + + expect(pseudoCheckboxes.length) + .withContext('expecting not to display a pseudo-checkbox') + .toBe(0); + }); + it('should not not throw if the select is inside an ng-container with ngIf', fakeAsync(() => { configureMatSelectTestingModule([SelectInNgContainer]); const fixture = TestBed.createComponent(SelectInNgContainer); diff --git a/src/material/select/select.ts b/src/material/select/select.ts index 95c9a5159dcf..b3b77e64b6fd 100644 --- a/src/material/select/select.ts +++ b/src/material/select/select.ts @@ -130,6 +130,9 @@ export interface MatSelectConfig { /** Class or list of classes to be applied to the menu's overlay panel. */ overlayPanelClass?: string | string[]; + + /** Wheter icon indicators should be hidden for single-selection. */ + hideSingleSelectionIndicator?: boolean; } /** Injection token that can be used to provide the default options the select module. */ @@ -483,7 +486,7 @@ export abstract class _MatSelectBase @Attribute('tabindex') tabIndex: string, @Inject(MAT_SELECT_SCROLL_STRATEGY) scrollStrategyFactory: any, private _liveAnnouncer: LiveAnnouncer, - @Optional() @Inject(MAT_SELECT_CONFIG) private _defaultOptions?: MatSelectConfig, + @Optional() @Inject(MAT_SELECT_CONFIG) protected _defaultOptions?: MatSelectConfig, ) { super(elementRef, _defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); @@ -1291,4 +1294,25 @@ export class MatSelect extends _MatSelectBase implements OnInit : this._preferredOverlayOrigin || this._elementRef; return refToMeasure.nativeElement.getBoundingClientRect().width; } + + /** Whether checkmark indicator for single-selection options is hidden. */ + @Input() + get hideSingleSelectionIndicator(): boolean { + return this._hideSingleSelectionIndicator; + } + set hideSingleSelectionIndicator(value: BooleanInput) { + this._hideSingleSelectionIndicator = coerceBooleanProperty(value); + this._syncParentProperties(); + } + private _hideSingleSelectionIndicator: boolean = + this._defaultOptions?.hideSingleSelectionIndicator ?? false; + + /** Syncs the parent state with the individual options. */ + _syncParentProperties(): void { + if (this.options) { + for (const option of this.options) { + option._changeDetectorRef.markForCheck(); + } + } + } } diff --git a/tools/public_api_guard/material/autocomplete.md b/tools/public_api_guard/material/autocomplete.md index 8ac93204e103..615c21e501ba 100644 --- a/tools/public_api_guard/material/autocomplete.md +++ b/tools/public_api_guard/material/autocomplete.md @@ -71,12 +71,15 @@ export const MAT_AUTOCOMPLETE_VALUE_ACCESSOR: any; export class MatAutocomplete extends _MatAutocompleteBase { // (undocumented) protected _hiddenClass: string; + get hideSingleSelectionIndicator(): boolean; + set hideSingleSelectionIndicator(value: BooleanInput); optionGroups: QueryList; options: QueryList; + _syncParentProperties(): void; // (undocumented) protected _visibleClass: string; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -89,7 +92,7 @@ export interface MatAutocompleteActivatedEvent { // @public export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase implements AfterContentInit, CanDisableRipple, OnDestroy { - constructor(_changeDetectorRef: ChangeDetectorRef, _elementRef: ElementRef, defaults: MatAutocompleteDefaultOptions, platform?: Platform); + constructor(_changeDetectorRef: ChangeDetectorRef, _elementRef: ElementRef, _defaults: MatAutocompleteDefaultOptions, platform?: Platform); ariaLabel: string; ariaLabelledby: string; get autoActiveFirstOption(): boolean; @@ -102,6 +105,8 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp [key: string]: boolean; }; readonly closed: EventEmitter; + // (undocumented) + protected _defaults: MatAutocompleteDefaultOptions; displayWith: ((value: any) => string) | null; _emitSelectEvent(option: _MatOptionBase): void; _getPanelAriaLabelledby(labelId: string | null): string | null; @@ -140,6 +145,7 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp export interface MatAutocompleteDefaultOptions { autoActiveFirstOption?: boolean; autoSelectActiveOption?: boolean; + hideSingleSelectionIndicator?: boolean; overlayPanelClass?: string | string[]; } diff --git a/tools/public_api_guard/material/core.md b/tools/public_api_guard/material/core.md index 4c68e948abf6..d636b62761f5 100644 --- a/tools/public_api_guard/material/core.md +++ b/tools/public_api_guard/material/core.md @@ -272,6 +272,8 @@ export class MatOption extends _MatOptionBase { export class _MatOptionBase implements FocusableOption, AfterViewChecked, OnDestroy { constructor(_element: ElementRef, _changeDetectorRef: ChangeDetectorRef, _parent: MatOptionParentComponent, group: _MatOptgroupBase); get active(): boolean; + // (undocumented) + _changeDetectorRef: ChangeDetectorRef; deselect(): void; get disabled(): boolean; set disabled(value: BooleanInput); @@ -284,6 +286,7 @@ export class _MatOptionBase implements FocusableOption, AfterViewChecke // (undocumented) readonly group: _MatOptgroupBase; _handleKeydown(event: KeyboardEvent): void; + get hideSingleSelectionIndicator(): boolean; id: string; get multiple(): boolean | undefined; // (undocumented) @@ -321,6 +324,8 @@ export interface MatOptionParentComponent { // (undocumented) disableRipple?: boolean; // (undocumented) + hideSingleSelectionIndicator?: boolean; + // (undocumented) inertGroups?: boolean; // (undocumented) multiple?: boolean; @@ -340,10 +345,11 @@ export class MatPseudoCheckbox { constructor(_animationMode?: string | undefined); // (undocumented) _animationMode?: string | undefined; + appearance: 'minimal' | 'full'; disabled: boolean; state: MatPseudoCheckboxState; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/tools/public_api_guard/material/select.md b/tools/public_api_guard/material/select.md index d34dfdf10020..3e1523a0e664 100644 --- a/tools/public_api_guard/material/select.md +++ b/tools/public_api_guard/material/select.md @@ -83,6 +83,8 @@ export class MatSelect extends _MatSelectBase implements OnInit customTrigger: MatSelectTrigger; // (undocumented) protected _getChangeEvent(value: any): MatSelectChange; + get hideSingleSelectionIndicator(): boolean; + set hideSingleSelectionIndicator(value: BooleanInput); // (undocumented) ngAfterViewInit(): void; // (undocumented) @@ -102,8 +104,9 @@ export class MatSelect extends _MatSelectBase implements OnInit protected _scrollOptionIntoView(index: number): void; // (undocumented) get shouldLabelFloat(): boolean; + _syncParentProperties(): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -128,6 +131,8 @@ export abstract class _MatSelectBase extends _MatSelectMixinBase implements A set compareWith(fn: (o1: any, o2: any) => boolean); controlType: string; abstract customTrigger: {}; + // (undocumented) + protected _defaultOptions?: MatSelectConfig | undefined; protected readonly _destroy: Subject; get disableOptionCentering(): boolean; set disableOptionCentering(value: BooleanInput); @@ -231,6 +236,7 @@ export class MatSelectChange { // @public export interface MatSelectConfig { disableOptionCentering?: boolean; + hideSingleSelectionIndicator?: boolean; overlayPanelClass?: string | string[]; typeaheadDebounceInterval?: number; }