Skip to content

Commit

Permalink
fix(material/core): add checkmark for single-select (#25962)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
zarend authored Jan 18, 2023
1 parent 3038d72 commit 46cfbe5
Show file tree
Hide file tree
Showing 23 changed files with 492 additions and 79 deletions.
1 change: 1 addition & 0 deletions src/dev-app/autocomplete/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 14 additions & 7 deletions src/dev-app/autocomplete/autocomplete-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
<mat-label>State</mat-label>
<input matInput [matAutocomplete]="reactiveAuto" [formControl]="stateCtrl">
</mat-form-field>
<mat-autocomplete #reactiveAuto="matAutocomplete" [displayWith]="displayFn">
<mat-autocomplete #reactiveAuto="matAutocomplete" [displayWith]="displayFn"
[hideSingleSelectionIndicator]="reactiveHideSingleSelectionIndicator">
<mat-option *ngFor="let state of tempStates" [value]="state">
<span>{{ state.name }}</span>
<span class="demo-secondary-text"> ({{ state.code }}) </span>
Expand All @@ -23,11 +24,11 @@
<button mat-button (click)="stateCtrl.enabled ? stateCtrl.disable() : stateCtrl.enable()">
TOGGLE DISABLED
</button>
<select [(ngModel)]="reactiveStatesTheme">
<option *ngFor="let theme of availableThemes" [value]="theme.value">
{{theme.name}}
</option>
</select>
</mat-card-actions>
<mat-card-actions>
<mat-checkbox [(ngModel)]="reactiveHideSingleSelectionIndicator">
Hide Single-Selection Indicator
</mat-checkbox>
</mat-card-actions>

</mat-card>
Expand All @@ -42,7 +43,8 @@
<mat-label>State</mat-label>
<input matInput [matAutocomplete]="tdAuto" [(ngModel)]="currentState"
(ngModelChange)="tdStates = filterStates(currentState)" [disabled]="tdDisabled">
<mat-autocomplete #tdAuto="matAutocomplete">
<mat-autocomplete #tdAuto="matAutocomplete"
[hideSingleSelectionIndicator]="templateHideSingleSelectionIndicator">
<mat-option *ngFor="let state of tdStates" [value]="state.name">
<span>{{ state.name }}</span>
</mat-option>
Expand All @@ -61,6 +63,11 @@
</option>
</select>
</mat-card-actions>
<mat-card-actions>
<mat-checkbox [(ngModel)]="templateHideSingleSelectionIndicator">
Hide Single-Selection Indicator
</mat-checkbox>
</mat-card-actions>

</mat-card>

Expand Down
6 changes: 6 additions & 0 deletions src/dev-app/autocomplete/autocomplete-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,6 +39,7 @@ export interface StateGroup {
MatAutocompleteModule,
MatButtonModule,
MatCardModule,
MatCheckboxModule,
MatInputModule,
ReactiveFormsModule,
],
Expand All @@ -52,6 +54,7 @@ export class AutocompleteDemo {
tdStates: State[];

tdDisabled = false;
hideSingleSelectionIndicators = false;

reactiveStatesTheme: ThemePalette = 'primary';
templateStatesTheme: ThemePalette = 'primary';
Expand All @@ -62,6 +65,9 @@ export class AutocompleteDemo {
{value: 'warn', name: 'Warn'},
];

reactiveHideSingleSelectionIndicator = false;
templateHideSingleSelectionIndicator = false;

@ViewChild(NgModel) modelDir: NgModel;

groupedStates: StateGroup[];
Expand Down
27 changes: 21 additions & 6 deletions src/dev-app/checkbox/checkbox-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,29 @@ <h1>mat-checkbox: Basic Example</h1>
</div>

<h1>Pseudo checkboxes</h1>
<mat-pseudo-checkbox></mat-pseudo-checkbox>
<mat-pseudo-checkbox [disabled]="true"></mat-pseudo-checkbox>
<div>
<h2>Full appearance</h2>
<mat-pseudo-checkbox></mat-pseudo-checkbox>
<mat-pseudo-checkbox [disabled]="true"></mat-pseudo-checkbox>

<mat-pseudo-checkbox state="checked"></mat-pseudo-checkbox>
<mat-pseudo-checkbox state="checked" [disabled]="true"></mat-pseudo-checkbox>

<mat-pseudo-checkbox state="indeterminate"></mat-pseudo-checkbox>
<mat-pseudo-checkbox state="indeterminate" [disabled]="true"></mat-pseudo-checkbox>
<div>
<div>
<h2>Minimal appearance</h2>
<mat-pseudo-checkbox appearance="minimal"></mat-pseudo-checkbox>
<mat-pseudo-checkbox appearance="minimal" [disabled]="true"></mat-pseudo-checkbox>

<mat-pseudo-checkbox state="checked"></mat-pseudo-checkbox>
<mat-pseudo-checkbox state="checked" [disabled]="true"></mat-pseudo-checkbox>
<mat-pseudo-checkbox appearance="minimal" state="checked"></mat-pseudo-checkbox>
<mat-pseudo-checkbox appearance="minimal" state="checked" [disabled]="true"></mat-pseudo-checkbox>

<mat-pseudo-checkbox state="indeterminate"></mat-pseudo-checkbox>
<mat-pseudo-checkbox state="indeterminate" [disabled]="true"></mat-pseudo-checkbox>
<mat-pseudo-checkbox appearance="minimal" state="indeterminate"></mat-pseudo-checkbox>
<mat-pseudo-checkbox appearance="minimal" state="indeterminate" [disabled]="true">
</mat-pseudo-checkbox>
<div>

<h1>Nested Checklist</h1>
<mat-checkbox-demo-nested-checklist></mat-checkbox-demo-nested-checklist>
Expand Down
1 change: 1 addition & 0 deletions src/dev-app/select/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 43 additions & 3 deletions src/dev-app/select/select-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
<mat-label>Drink</mat-label>
<mat-select [(ngModel)]="currentDrink" [required]="drinksRequired"
[disabled]="drinksDisabled" #drinkControl="ngModel">
<mat-option>None</mat-option>
<mat-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drink.disabled">
<mat-option [disabled]="drinksOptionsDisabled">None</mat-option>
<mat-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drinksOptionsDisabled">
{{ drink.viewValue }}
</mat-option>
</mat-select>
Expand Down Expand Up @@ -52,6 +52,7 @@
<button mat-button (click)="currentDrink='water-2'">SET VALUE</button>
<button mat-button (click)="drinksRequired=!drinksRequired">TOGGLE REQUIRED</button>
<button mat-button (click)="drinksDisabled=!drinksDisabled">TOGGLE DISABLED</button>
<button mat-button (click)="drinksOptionsDisabled=!drinksOptionsDisabled">TOGGLE DISABLED OPTIONS</button>
<button mat-button (click)="drinkControl.reset()">RESET</button>
</mat-card-content>
</mat-card>
Expand All @@ -64,7 +65,7 @@
<mat-label>Pokemon</mat-label>
<mat-select multiple [(ngModel)]="currentPokemon"
[required]="pokemonRequired" [disabled]="pokemonDisabled" #pokemonControl="ngModel">
<mat-option *ngFor="let creature of pokemon" [value]="creature.value">
<mat-option *ngFor="let creature of pokemon" [value]="creature.value" [disabled]="pokemonOptionsDisabled">
{{ creature.viewValue }}
</mat-option>
</mat-select>
Expand All @@ -82,6 +83,7 @@
<button mat-button (click)="setPokemonValue()">SET VALUE</button>
<button mat-button (click)="pokemonRequired=!pokemonRequired">TOGGLE REQUIRED</button>
<button mat-button (click)="pokemonDisabled=!pokemonDisabled">TOGGLE DISABLED</button>
<button mat-button (click)="pokemonOptionsDisabled=!pokemonOptionsDisabled">TOGGLE DISABLED OPTIONS</button>
<button mat-button (click)="pokemonControl.reset()">RESET</button>
</mat-card-content>
</mat-card>
Expand Down Expand Up @@ -356,3 +358,41 @@ <h4>Error message with errorStateMatcher</h4>
</form>
</mat-card-content>
</mat-card>

<mat-card class="demo-card demo-narrow">
<mat-card-subtitle>Narrow</mat-card-subtitle>
<mat-card-content>
<p class="demo-narrow-sandwich">
<mat-form-field>
<mat-label>Bread</mat-label>
<mat-select [(ngModel)]="sandwichBread"
[hideSingleSelectionIndicator]="sandwichHideSingleSelectionIndicator">
<mat-option *ngFor="let bread of breads" [value]="bread.value">
{{ bread.viewValue }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>Meat</mat-label>
<mat-select [(ngModel)]="sandwichMeat"
[hideSingleSelectionIndicator]="sandwichHideSingleSelectionIndicator">
<mat-option *ngFor="let meat of meats" [value]="meat.value">
{{ meat.viewValue }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>Cheese</mat-label>
<mat-select [(ngModel)]="sandwichCheese"
[hideSingleSelectionIndicator]="sandwichHideSingleSelectionIndicator">
<mat-option *ngFor="let cheese of cheeses" [value]="cheese.value">
{{ cheese.viewValue }}
</mat-option>
</mat-select>
</mat-form-field>
</p>
<mat-checkbox [(ngModel)]="sandwichHideSingleSelectionIndicator">
Hide Single-Selection Indicator
</mat-checkbox>
</mat-card-content>
</mat-card>
9 changes: 9 additions & 0 deletions src/dev-app/select/select-demo.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,12 @@
.demo-card {
margin: 30px 0;
}

.demo-narrow {
max-width: 450px;

.demo-narrow-sandwich {
display: flex;
gap: 16px;
}
}
46 changes: 38 additions & 8 deletions src/dev-app/select/select-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -37,6 +38,7 @@ export class MyErrorStateMatcher implements ErrorStateMatcher {
FormsModule,
MatButtonModule,
MatCardModule,
MatCheckboxModule,
MatIconModule,
MatInputModule,
MatSelectModule,
Expand All @@ -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'};
Expand All @@ -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'},
Expand All @@ -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 = [
Expand Down Expand Up @@ -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();
}
Expand Down
47 changes: 47 additions & 0 deletions src/material/autocomplete/autocomplete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {map, startWith} from 'rxjs/operators';
import {
getMatAutocompleteMissingPanelError,
MatAutocomplete,
MatAutocompleteDefaultOptions,
MatAutocompleteModule,
MatAutocompleteOrigin,
MatAutocompleteSelectedEvent,
Expand Down Expand Up @@ -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 = `
Expand Down Expand Up @@ -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();
}
Expand Down
Loading

0 comments on commit 46cfbe5

Please sign in to comment.