Skip to content

Commit

Permalink
feat(material-experimental/mdc-slide-toggle): switch to non-deprecate…
Browse files Browse the repository at this point in the history
…d styles (#23143)

Switches the MDC-based slide toggle to the non-deprecated MDC styles.

Notable changes:
* New markup which uses a `button` instead of an `input` for the button.
* New icons inside the slide toggle's thumb. Technically we could opt out of them, but I think that they look better and they help with accessibility for color-blind users.
* New theming system that uses a flat list of variables. There is a fallback for IE11, but I opted not to include it for now, because of the upcoming deprecation and the fact that the component is in experimental.
* The component has some slightly different colors and it supports more states (e.g. hover).
* Due to the switch from `input` to `button`, the `required` input is basically a noop now.
  • Loading branch information
crisbeto authored Aug 6, 2021
1 parent 8d39384 commit a4ae3b8
Show file tree
Hide file tree
Showing 15 changed files with 318 additions and 334 deletions.
3 changes: 3 additions & 0 deletions scripts/check-mdc-tests-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ export const config = {
'should re-add margin if label is added asynchronously',
'should properly update margin if label content is projected',

// The MDC slide toggle uses a `button` which isn't able to block form submission.
'should prevent the form from submit when being required',

// TODO: the focus origin functionality has to be implemeted for the MDC slide toggle.
'should not change focus origin if origin not specified'
],
Expand Down
5 changes: 4 additions & 1 deletion src/e2e-app/protractor.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ exports.config = {
{id: 'region', enabled: false},

// Don't require at least one `<h1>` since we don't have any content.
{id: 'page-has-heading-one', enabled: false}
{id: 'page-has-heading-one', enabled: false},

// Axe incorrectly picks up that `aria-required` is not allowed on the MDC slide toggle.
{id: 'aria-allowed-attr', selector: '*:not(.mdc-switch)'}
]
}
],
Expand Down
3 changes: 2 additions & 1 deletion src/material-experimental/mdc-helpers/_focus-indicators.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
// which will clip a square focus indicator so we have to turn it into a circle.
.mat-mdc-checkbox-ripple.mat-mdc-focus-indicator::before,
.mat-radio-ripple.mat-mdc-focus-indicator::before,
.mat-mdc-slider .mat-mdc-focus-indicator::before {
.mat-mdc-slider .mat-mdc-focus-indicator::before,
.mat-mdc-slide-toggle .mat-mdc-focus-indicator::before {
border-radius: 50%;
}

Expand Down
128 changes: 73 additions & 55 deletions src/material-experimental/mdc-slide-toggle/_slide-toggle-theme.scss
Original file line number Diff line number Diff line change
@@ -1,103 +1,121 @@
@use '@material/theme/theme-color' as mdc-theme-color;
@use '@material/switch/deprecated' as mdc-switch with ($deprecated-suffix: '');
@use '@material/form-field' as mdc-form-field;
@use 'sass:map';
@use 'sass:color';
@use '@material/switch/switch-theme' as mdc-switch-theme;
@use '@material/theme/color-palette' as mdc-color-palette;
@use '@material/form-field' as mdc-form-field;
@use '../mdc-helpers/mdc-helpers';
@use '../../material/core/typography/typography';
@use '../../material/core/theming/theming';
@use '../../material/core/theming/palette';

// Generates all color mapping for the properties that only change based on the theme.
@function _get-theme-base-map($is-dark) {
$on-surface: if($is-dark, mdc-color-palette.$grey-100, mdc-color-palette.$grey-800);
$hairline: if($is-dark, mdc-color-palette.$grey-500, mdc-color-palette.$grey-300);
$on-surface-variant: if($is-dark, mdc-color-palette.$grey-200, mdc-color-palette.$grey-700);
$on-surface-state-content: if($is-dark, mdc-color-palette.$grey-50, mdc-color-palette.$grey-900);
$disabled-handle-color: mdc-color-palette.$grey-800;
$selected-icon-color: mdc-color-palette.$grey-100;
$icon-color: if($is-dark, mdc-color-palette.$grey-800, mdc-color-palette.$grey-100);

@return (
disabled-selected-handle-color: $disabled-handle-color,
disabled-unselected-handle-color: $disabled-handle-color,

disabled-selected-track-color: $on-surface,
disabled-unselected-track-color: $on-surface,
unselected-focus-state-layer-color: $on-surface,
unselected-pressed-state-layer-color: $on-surface,
unselected-hover-state-layer-color: $on-surface,

unselected-focus-track-color: $hairline,
unselected-hover-track-color: $hairline,
unselected-pressed-track-color: $hairline,
unselected-track-color: $hairline,

unselected-focus-handle-color: $on-surface-state-content,
unselected-hover-handle-color: $on-surface-state-content,
unselected-pressed-handle-color: $on-surface-state-content,

handle-surface-color: surface,
unselected-handle-color: $on-surface-variant,

selected-icon-color: $selected-icon-color,
disabled-selected-icon-color: $icon-color,
disabled-unselected-icon-color: $icon-color,
unselected-icon-color: $icon-color,
);
}

// Generates the mapping for the properties that change based on the slide toggle color.
@function _get-theme-color-map($color-palette) {
$state-content: color.scale($color-palette, $blackness: 50%);
$inverse: color.scale($color-palette, $lightness: 75%);

@return (
selected-focus-state-layer-color: $color-palette,
selected-handle-color: $color-palette,
selected-hover-state-layer-color: $color-palette,
selected-pressed-state-layer-color: $color-palette,

selected-focus-handle-color: $state-content,
selected-hover-handle-color: $state-content,
selected-pressed-handle-color: $state-content,

selected-focus-track-color: $inverse,
selected-hover-track-color: $inverse,
selected-pressed-track-color: $inverse,
selected-track-color: $inverse,
);
}

@mixin color($config-or-theme) {
$config: theming.get-color-config($config-or-theme);
$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));

// Save original values of MDC global variables. We need to save these so we can restore the
// variables to their original values and prevent unintended side effects from using this mixin.
$orig-baseline-theme-color: mdc-switch.$baseline-theme-color;
$orig-toggled-off-thumb-color: mdc-switch.$toggled-off-thumb-color;
$orig-toggled-off-track-color: mdc-switch.$toggled-off-track-color;
$orig-disabled-thumb-color: mdc-switch.$disabled-thumb-color;
$orig-disabled-track-color: mdc-switch.$disabled-track-color;
$is-dark: map.get($config, is-dark);

@include mdc-helpers.mat-using-mdc-theme($config) {
mdc-switch.$baseline-theme-color: primary;
mdc-switch.$toggled-off-thumb-color: mdc-theme-color.prop-value(surface);
mdc-switch.$toggled-off-track-color: mdc-theme-color.prop-value(on-surface);
mdc-switch.$disabled-thumb-color: mdc-theme-color.prop-value(surface);
mdc-switch.$disabled-track-color: mdc-theme-color.prop-value(on-surface);

// MDC's switch doesn't support a `color` property. We add support
// for it by adding a CSS class for accent and warn style.
.mat-mdc-slide-toggle {
@include mdc-form-field.core-styles($query: mdc-helpers.$mat-theme-styles-query);

.mdc-switch__thumb-underlay::after, .mat-ripple-element {
background: mdc-switch.$toggled-off-ripple-color;
}
@include mdc-switch-theme.theme(_get-theme-base-map($is-dark));

&.mat-primary {
@include mdc-switch.without-ripple($query: mdc-helpers.$mat-theme-styles-query);
@include mdc-switch-theme.theme(_get-theme-color-map($primary));
}

&.mat-accent {
mdc-switch.$baseline-theme-color: secondary;
@include mdc-switch.without-ripple($query: mdc-helpers.$mat-theme-styles-query);
@include mdc-switch-theme.theme(_get-theme-color-map($accent));
}

&.mat-warn {
mdc-switch.$baseline-theme-color: error;
@include mdc-switch.without-ripple($query: mdc-helpers.$mat-theme-styles-query);
}
}

// The ripple color matches the palette only when it's checked.
.mat-mdc-slide-toggle-checked {
.mdc-switch__thumb-underlay::after, .mat-ripple-element {
background: $primary;
}

&.mat-accent {
.mdc-switch__thumb-underlay::after, .mat-ripple-element {
background: $accent;
}
}

&.mat-warn {
.mdc-switch__thumb-underlay::after, .mat-ripple-element {
background: $warn;
}
@include mdc-switch-theme.theme(_get-theme-color-map($warn));
}
}
}

// Restore original values of MDC global variables.
mdc-switch.$baseline-theme-color: $orig-baseline-theme-color;
mdc-switch.$toggled-off-thumb-color: $orig-toggled-off-thumb-color;
mdc-switch.$toggled-off-track-color: $orig-toggled-off-track-color;
mdc-switch.$disabled-thumb-color: $orig-disabled-thumb-color;
mdc-switch.$disabled-track-color: $orig-disabled-track-color;
}

@mixin typography($config-or-theme) {
$config: typography.private-typography-to-2018-config(
theming.get-typography-config($config-or-theme));
@include mdc-helpers.mat-using-mdc-typography($config) {
@include mdc-switch.without-ripple($query: mdc-helpers.$mat-typography-styles-query);
@include mdc-form-field.core-styles($query: mdc-helpers.$mat-typography-styles-query);
}
}

@mixin density($config-or-theme) {
$density-scale: theming.get-density-config($config-or-theme);
.mat-mdc-slide-toggle .mdc-switch {
@include mdc-switch.density($density-scale, $query: mdc-helpers.$mat-base-styles-query);
.mat-mdc-slide-toggle {
@include mdc-switch-theme.theme(mdc-switch-theme.density($density-scale));
}
}

@mixin theme($theme-or-color-config) {
$theme: theming.private-legacy-get-theme($theme-or-color-config);

@include theming.private-check-duplicate-theme-styles($theme, 'mat-mdc-slide-toggle') {
$color: theming.get-color-config($theme);
$density: theming.get-density-config($theme);
Expand Down
46 changes: 23 additions & 23 deletions src/material-experimental/mdc-slide-toggle/slide-toggle.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {expectToExist} from '../../cdk/testing/private/e2e';


describe('MDC-based slide-toggle', () => {
const getInput = () => element(by.css('#normal-slide-toggle input'));
const getButton = () => element(by.css('#normal-slide-toggle button'));
const getNormalToggle = () => element(by.css('#normal-slide-toggle'));

beforeEach(async () => await browser.get('mdc-slide-toggle'));
Expand All @@ -13,44 +13,44 @@ describe('MDC-based slide-toggle', () => {
});

it('should change the checked state on click', async () => {
const inputEl = getInput();
const buttonEl = getButton();

expect(await inputEl.getAttribute('checked'))
.toBeFalsy('Expect slide-toggle to be unchecked');
expect(await buttonEl.getAttribute('aria-checked'))
.toBe('false', 'Expect slide-toggle to be unchecked');

await getNormalToggle().click();

expect(await inputEl.getAttribute('checked'))
.toBeTruthy('Expect slide-toggle to be checked');
expect(await buttonEl.getAttribute('aria-checked'))
.toBe('true', 'Expect slide-toggle to be checked');
});

it('should change the checked state on click', async () => {
const inputEl = getInput();
const buttonEl = getButton();

expect(await inputEl.getAttribute('checked'))
.toBeFalsy('Expect slide-toggle to be unchecked');
expect(await buttonEl.getAttribute('aria-checked'))
.toBe('false', 'Expect slide-toggle to be unchecked');

await getNormalToggle().click();

expect(await inputEl.getAttribute('checked'))
.toBeTruthy('Expect slide-toggle to be checked');
expect(await buttonEl.getAttribute('aria-checked'))
.toBe('true', 'Expect slide-toggle to be checked');
});

it('should not change the checked state on click when disabled', async () => {
const inputEl = getInput();
const buttonEl = getButton();

expect(await inputEl.getAttribute('checked'))
.toBeFalsy('Expect slide-toggle to be unchecked');
expect(await buttonEl.getAttribute('aria-checked'))
.toBe('false', 'Expect slide-toggle to be unchecked');

await element(by.css('#disabled-slide-toggle')).click();

expect(await inputEl.getAttribute('checked'))
.toBeFalsy('Expect slide-toggle to be unchecked');
expect(await buttonEl.getAttribute('aria-checked'))
.toBe('false', 'Expect slide-toggle to be unchecked');
});

it('should move the thumb on state change', async () => {
const slideToggleEl = getNormalToggle();
const thumbEl = element(by.css('#normal-slide-toggle .mdc-switch__thumb-underlay'));
const thumbEl = element(by.css('#normal-slide-toggle .mdc-switch__handle'));
const previousPosition = await thumbEl.getLocation();

await slideToggleEl.click();
Expand All @@ -61,15 +61,15 @@ describe('MDC-based slide-toggle', () => {
});

it('should toggle the slide-toggle on space key', async () => {
const inputEl = getInput();
const buttonEl = getButton();

expect(await inputEl.getAttribute('checked'))
.toBeFalsy('Expect slide-toggle to be unchecked');
expect(await buttonEl.getAttribute('aria-checked'))
.toBe('false', 'Expect slide-toggle to be unchecked');

await inputEl.sendKeys(Key.SPACE);
await buttonEl.sendKeys(Key.SPACE);

expect(await inputEl.getAttribute('checked'))
.toBeTruthy('Expect slide-toggle to be checked');
expect(await buttonEl.getAttribute('aria-checked'))
.toBe('true', 'Expect slide-toggle to be checked');
});

});
66 changes: 42 additions & 24 deletions src/material-experimental/mdc-slide-toggle/slide-toggle.html
Original file line number Diff line number Diff line change
@@ -1,33 +1,51 @@
<div class="mdc-form-field"
[class.mdc-form-field--align-end]="labelPosition == 'before'">
<div class="mdc-switch mat-mdc-switch" #switch>
<button
class="mdc-switch"
role="switch"
type="button"
[class.mdc-switch--selected]="checked"
[class.mdc-switch--unselected]="!checked"
[tabIndex]="tabIndex"
[disabled]="disabled"
[attr.id]="buttonId"
[attr.name]="name"
[attr.aria-label]="ariaLabel"
[attr.aria-labelledby]="_getAriaLabelledBy()"
[attr.aria-describedby]="ariaDescribedby"
[attr.aria-required]="required"
(click)="_handleClick($event)"
#switch>
<div class="mdc-switch__track"></div>
<div class="mdc-switch__thumb-underlay mat-mdc-focus-indicator">
<div class="mat-mdc-slide-toggle-ripple" mat-ripple
[matRippleTrigger]="switch"
[matRippleDisabled]="disableRipple || disabled"
[matRippleCentered]="true"
[matRippleAnimation]="_rippleAnimation"></div>
<div class="mdc-switch__thumb">
<input #input class="mdc-switch__native-control" type="checkbox"
role="switch"
[id]="inputId"
[required]="required"
[tabIndex]="tabIndex"
[checked]="checked"
[disabled]="disabled"
[attr.name]="name"
[attr.aria-checked]="checked.toString()"
[attr.aria-label]="ariaLabel"
[attr.aria-labelledby]="ariaLabelledby"
[attr.aria-describedby]="ariaDescribedby"
(change)="_onChangeEvent($event)"
(click)="_onInputClick($event)">
<div class="mdc-switch__handle-track">
<div class="mdc-switch__handle">
<div class="mdc-switch__shadow">
<div class="mdc-elevation-overlay"></div>
</div>
<div class="mdc-switch__ripple">
<div class="mat-mdc-slide-toggle-ripple mat-mdc-focus-indicator" mat-ripple
[matRippleTrigger]="switch"
[matRippleDisabled]="disableRipple || disabled"
[matRippleCentered]="true"
[matRippleAnimation]="_rippleAnimation"></div>
</div>
<div class="mdc-switch__icons">
<svg class="mdc-switch__icon mdc-switch__icon--on" viewBox="0 0 24 24">
<path d="M19.69,5.23L8.96,15.96l-4.23-4.23L2.96,13.5l6,6L21.46,7L19.69,5.23z" />
</svg>
<svg class="mdc-switch__icon mdc-switch__icon--off" viewBox="0 0 24 24">
<path d="M20 13H4v-2h16v2z" />
</svg>
</div>
</div>
</div>
</div>
</button>

<label [for]="inputId">
<!--
Clicking on the label will trigger another click event from the button.
Stop propagation here so other listeners further up in the DOM don't execute twice.
-->
<label [for]="buttonId" [attr.id]="_labelId" (click)="$event.stopPropagation()">
<ng-content></ng-content>
</label>
</div>
Loading

0 comments on commit a4ae3b8

Please sign in to comment.