From 3aff08429715f49de9296e5efb7b3ebd4d0d804b Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Wed, 4 Jan 2023 15:00:49 -0800 Subject: [PATCH] fix(radio): update rendering and styles PiperOrigin-RevId: 499587641 --- radio/_radio.scss | 7 +- radio/lib/_radio-theme.scss | 377 ---------------------------- radio/lib/_radio.scss | 220 +++++++++------- radio/lib/forced-colors-styles.scss | 27 ++ radio/lib/radio-styles.scss | 8 +- radio/lib/radio.ts | 92 ++----- radio/radio.ts | 3 +- radio/radio_test.ts | 7 +- 8 files changed, 195 insertions(+), 546 deletions(-) delete mode 100644 radio/lib/_radio-theme.scss create mode 100644 radio/lib/forced-colors-styles.scss diff --git a/radio/_radio.scss b/radio/_radio.scss index 06228720db..108a83440d 100644 --- a/radio/_radio.scss +++ b/radio/_radio.scss @@ -1 +1,6 @@ -@forward './lib/radio-theme' show theme, theme-extension; +// +// Copyright 2022 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@forward './lib/radio' show theme; diff --git a/radio/lib/_radio-theme.scss b/radio/lib/_radio-theme.scss deleted file mode 100644 index c10db9764d..0000000000 --- a/radio/lib/_radio-theme.scss +++ /dev/null @@ -1,377 +0,0 @@ -// -// Copyright 2022 Google LLC -// SPDX-License-Identifier: Apache-2.0 -// - -// stylelint-disable selector-class-pattern -- -// Selector '.md3-*' should only be used in this project. - -@use 'sass:map'; -@use 'sass:selector'; - -@use '../../ripple/ripple'; -@use '../../sass/theme'; -@use '../../tokens'; - -$light-theme: tokens.md-comp-radio-button-values(); -$custom-property-prefix: 'radio'; - -@mixin theme($theme) { - $theme: theme.validate-theme($light-theme, $theme); - @include theme.emit-theme-vars( - theme.create-theme-vars($theme, $custom-property-prefix) - ); -} - -@mixin theme-styles($theme) { - $theme: theme.validate-theme($light-theme, $theme); - // Set touch target manually until tokens provide this information. - $theme: map.set($theme, _touch-target-size, 48px); - $theme: theme.create-theme-vars($theme, $prefix: $custom-property-prefix); - - .md3-radio { - @include _disabled-selected-icon-color( - map.get($theme, disabled-selected-icon-color) - ); - @include _disabled-selected-icon-opacity( - map.get($theme, disabled-selected-icon-opacity) - ); - @include _disabled-unselected-icon-color( - map.get($theme, disabled-unselected-icon-color) - ); - @include _disabled-unselected-icon-opacity( - map.get($theme, disabled-unselected-icon-opacity) - ); - @include _icon-size(map.get($theme, icon-size)); - @include _selected-focus-icon-color( - map.get($theme, selected-focus-icon-color) - ); - @include _selected-hover-icon-color( - map.get($theme, selected-hover-icon-color) - ); - @include _selected-icon-color(map.get($theme, selected-icon-color)); - @include _selected-pressed-icon-color( - map.get($theme, selected-pressed-icon-color) - ); - @include _state-layer-size(map.get($theme, state-layer-size)); - @include _touch-target($size: map.get($theme, state-layer-size)); - @include _unselected-focus-icon-color( - map.get($theme, unselected-focus-icon-color) - ); - @include _unselected-hover-icon-color( - map.get($theme, unselected-hover-icon-color) - ); - @include _unselected-icon-color(map.get($theme, unselected-icon-color)); - @include _unselected-pressed-icon-color( - map.get($theme, unselected-pressed-icon-color) - ); - } - - .md3-radio--touch { - @include _touch-target($size: map.get($theme, _touch-target-size)); - } - - @include ripple.theme( - ( - hover-state-layer-color: - map.get($theme, unselected-hover-state-layer-color), - focus-state-layer-color: - map.get($theme, unselected-focus-state-layer-color), - pressed-state-layer-color: - map.get($theme, unselected-pressed-state-layer-color), - hover-state-layer-opacity: - map.get($theme, unselected-hover-state-layer-opacity), - focus-state-layer-opacity: - map.get($theme, unselected-focus-state-layer-opacity), - pressed-state-layer-opacity: - map.get($theme, unselected-pressed-state-layer-opacity), - ) - ); - - @include _checked-selector() { - @include ripple.theme( - ( - hover-state-layer-color: - map.get($theme, selected-hover-state-layer-color), - focus-state-layer-color: - map.get($theme, selected-focus-state-layer-color), - pressed-state-layer-color: - map.get($theme, selected-pressed-state-layer-color), - hover-state-layer-opacity: - map.get($theme, selected-hover-state-layer-opacity), - focus-state-layer-opacity: - map.get($theme, selected-focus-state-layer-opacity), - pressed-state-layer-opacity: - map.get($theme, selected-pressed-state-layer-opacity), - ) - ); - } -} - -$_theme-extension-keys: ( - touch-target-size: null, -); - -@mixin theme-extension($theme) { - $theme: theme.validate-theme($_theme-extension-keys, $theme); - - .md3-radio { - @include _touch-target(map.get($theme, touch-target-size)); - } -} - -@mixin high-contrast-styles() { - @include _disabled-selected-icon-color(GrayText); - @include _disabled-selected-icon-opacity(1); - @include _disabled-unselected-icon-color(GrayText); - @include _disabled-unselected-icon-opacity(1); - @include _selected-icon-color(CanvasText); - @include _selected-hover-icon-color(CanvasText); - @include _selected-focus-icon-color(CanvasText); - @include _selected-pressed-icon-color(CanvasText); - @include _unselected-icon-color(CanvasText); - @include _unselected-hover-icon-color(CanvasText); - @include _unselected-focus-icon-color(CanvasText); - @include _unselected-pressed-icon-color(CanvasText); -} - -/// -/// Sets the stroke color of a checked, disabled radio button. -/// @param {Color} $color - The desired stroke color. -/// -@mixin disabled-checked-stroke-color($color) { - @include _if-disabled-checked { - @include _stroke-color($color); - } -} - -/// -/// Sets the stroke color of an unchecked, disabled radio button. -/// @param {Color} $color - The desired stroke color. -/// -@mixin disabled-unchecked-stroke-color($color) { - @include _if-disabled-unchecked { - @include _stroke-color($color); - } -} - -/// -/// Sets the ink color of a disabled radio button. -/// @param {Color} $color - The desired ink color -/// -@mixin disabled-ink-color($color) { - @include _if-disabled { - @include _ink-color($color); - } -} - -@mixin _disabled-selected-icon-color($color) { - @include disabled-checked-stroke-color($color); - @include disabled-ink-color($color); -} - -@mixin _disabled-selected-icon-opacity($opacity) { - @include _disabled-checked-stroke-opacity($opacity); - @include _disabled-ink-opacity($opacity); -} - -@mixin _disabled-unselected-icon-color($color) { - @include disabled-unchecked-stroke-color($color); -} - -@mixin _disabled-unselected-icon-opacity($opacity) { - @include _disabled-unchecked-stroke-opacity($opacity); -} - -@mixin _icon-size($size) { - .md3-radio__background { - height: $size; - width: $size; - } -} - -@mixin _selected-hover-icon-color($color) { - @include _if-input-selected { - &:hover + { - @include _stroke-color($color); - @include _ink-color($color); - } - } -} - -@mixin _selected-focus-icon-color($color) { - @include _if-input-selected { - &:focus + { - @include _stroke-color($color); - @include _ink-color($color); - } - } -} - -@mixin _selected-pressed-icon-color($color) { - @include _if-input-selected { - &:active + { - @include _stroke-color($color); - @include _ink-color($color); - } - } -} - -@mixin _selected-icon-color($color) { - @include _if-input-selected { - & + { - @include _stroke-color($color); - @include _ink-color($color); - } - } -} - -@mixin _unselected-hover-icon-color($color) { - @include _if-input-unselected { - &:hover + { - @include _stroke-color($color); - } - } -} - -@mixin _unselected-focus-icon-color($color) { - @include _if-input-unselected { - &:focus + { - @include _stroke-color($color); - } - } -} - -@mixin _unselected-pressed-icon-color($color) { - @include _if-input-unselected { - &:active + { - @include _stroke-color($color); - } - } -} - -@mixin _unselected-icon-color($color) { - @include _if-input-unselected { - & + { - @include _stroke-color($color); - } - } -} - -@mixin _disabled-unchecked-stroke-opacity($opacity) { - @include _if-disabled-unchecked { - @include _stroke-opacity($opacity); - } -} - -@mixin _disabled-checked-stroke-opacity($opacity) { - @include _if-disabled-checked { - @include _stroke-opacity($opacity); - } -} - -@mixin _disabled-ink-opacity($opacity) { - @include _if-disabled { - @include _ink-opacity($opacity); - } -} - -@mixin _touch-target($size) { - block-size: $size; - inline-size: $size; -} - -@mixin _state-layer-size($size) { - .md3-radio__ripple { - block-size: $size; - inline-size: $size; - } -} - -/// -/// Sets the ink color for radio. This is wrapped in a mixin -/// that qualifies state such as `_if-enabled` -/// -@mixin _ink-color($color) { - .md3-radio__background .md3-radio__inner-circle { - background-color: $color; - } -} - -@mixin _ink-opacity($opacity) { - .md3-radio__background .md3-radio__inner-circle { - opacity: $opacity; - } -} - -/// -/// Sets the stroke color for radio. This is wrapped in a mixin -/// that qualifies state such as `_if-enabled` -/// -@mixin _stroke-color($color) { - .md3-radio__background .md3-radio__outer-circle { - border-color: $color; - } -} - -@mixin _stroke-opacity($opacity) { - .md3-radio__background .md3-radio__outer-circle { - opacity: $opacity; - } -} - -@mixin _checked-selector() { - @at-root { - :host([checked]) { - @content; - } - } -} - -@mixin _if-input-unselected { - .md3-radio__native-control:enabled:not(:checked) { - @content; - } -} - -@mixin _if-input-selected { - .md3-radio__native-control:enabled:checked { - @content; - } -} - -/// -/// Helps select the radio background only when its native control is in the -/// disabled state. -/// -@mixin _if-disabled { - .md3-radio__native-control:disabled { - + { - @content; - } - } -} - -/// -/// Helps select the radio background only when its native control is in the -/// disabled & unchecked state. -/// -@mixin _if-disabled-unchecked { - .md3-radio__native-control:disabled { - &:not(:checked) + { - @content; - } - } -} - -/// -/// Helps select the radio background only when its native control is in the -/// disabled & checked state. -/// -@mixin _if-disabled-checked { - .md3-radio__native-control:disabled { - &:checked + { - @content; - } - } -} diff --git a/radio/lib/_radio.scss b/radio/lib/_radio.scss index 8b301b46ab..562c3c4703 100644 --- a/radio/lib/_radio.scss +++ b/radio/lib/_radio.scss @@ -3,133 +3,165 @@ // SPDX-License-Identifier: Apache-2.0 // -// stylelint-disable selector-class-pattern -- -// Selector '.md3-*' should only be used in this project. - +@use 'sass:map'; @use '../../focus/focus-ring'; @use '../../motion/animation'; +@use '../../ripple/ripple'; +@use '../../sass/theme'; +@use '../../tokens'; + +$_md-sys-motion: tokens.md-sys-motion-values(); + +@mixin theme($tokens) { + $tokens: theme.validate-theme(tokens.md-comp-radio-button-values(), $tokens); + $tokens: theme.create-theme-vars($tokens, 'radio'); + + @include theme.emit-theme-vars($tokens); +} -@use './radio-theme'; +@mixin styles() { + $tokens: tokens.md-comp-radio-button-values(); + $tokens: theme.create-theme-vars($tokens, 'radio'); -@mixin static-styles() { :host { - -webkit-tap-highlight-color: transparent; - } + @each $token, $value in $tokens { + --_#{$token}: #{$value}; + } + + @include ripple.theme( + ( + focus-state-layer-color: var(--_unselected-focus-state-layer-color), + focus-state-layer-opacity: var(--_unselected-focus-state-layer-opacity), + hover-state-layer-color: var(--_unselected-hover-state-layer-color), + hover-state-layer-opacity: var(--_unselected-hover-state-layer-opacity), + pressed-state-layer-color: var(--_unselected-pressed-state-layer-color), + pressed-state-layer-opacity: + var(--_unselected-pressed-state-layer-opacity), + ) + ); + + @include focus-ring.theme( + ( + offset-vertical: -2px, + offset-horizontal: -2px, + ) + ); - .md3-radio { display: inline-flex; + height: 48px; position: relative; - cursor: pointer; - will-change: opacity, transform, border-color, color; - justify-content: center; - align-items: center; + vertical-align: top; // Fix extra space when placed inside display: block + width: 48px; + // Remove highlight color for mobile Safari + -webkit-tap-highlight-color: transparent; } - .md3-radio__background { - display: inline-flex; - position: relative; - box-sizing: border-box; - align-items: center; - justify-content: center; + :host([checked]) { + @include ripple.theme( + ( + focus-state-layer-color: var(--_selected-focus-state-layer-color), + focus-state-layer-opacity: var(--_selected-focus-state-layer-opacity), + hover-state-layer-color: var(--_selected-hover-state-layer-color), + hover-state-layer-opacity: var(--_selected-hover-state-layer-opacity), + pressed-state-layer-color: var(--_selected-pressed-state-layer-color), + pressed-state-layer-opacity: + var(--_selected-pressed-state-layer-opacity), + ) + ); } - .md3-radio__outer-circle { - position: absolute; - inset-block-start: 0; - inset-inline-start: 0; - box-sizing: border-box; - block-size: 100%; - inline-size: 100%; - border-width: 2px; - border-style: solid; - border-radius: 50%; - transition: exit(border-color); - } - - .md3-radio__inner-circle { + input, + md-ripple, + md-focus-ring, + .icon { + inset: 0; + margin: auto; position: absolute; - box-sizing: border-box; - block-size: 50%; - inline-size: 50%; - transform: scale(0); - border-radius: 50%; - transition: exit(transform), exit(border-color); } - .md3-radio__ripple { - position: absolute; - display: inline-flex; - z-index: -1; + input { + appearance: none; + outline: none; } - .md3-radio__native-control { - position: absolute; - margin: 0; - padding: 0; + md-ripple { + height: var(--_state-layer-size); + width: var(--_state-layer-size); + } + + .icon { + fill: var(--_unselected-icon-color); + height: var(--_icon-size); + width: var(--_icon-size); + } + + .inner-circle { opacity: 0; - cursor: inherit; - z-index: 1; - block-size: 100%; - inline-size: 100%; - inset: 0; + transition-duration: 150ms, 50ms; // Exit duration for scale and opacity. + transition-property: transform, opacity; + // Exit easing function for scale, linear for opacity. + transition-timing-function: map.get( + $_md-sys-motion, + easing-emphasized-accelerate + ), + linear; + transform: scale(0.6); + transform-origin: center; } - .md3-radio__native-control:checked, - .md3-radio__native-control:disabled { - + .md3-radio__background { - transition: enter(opacity), enter(transform); + :host([checked]) .icon { + fill: var(--_selected-icon-color); + } - .md3-radio__outer-circle { - transition: enter(border-color); - } + :host([checked]) .inner-circle { + opacity: 1; + // Enter duration for scale and opacity. + transition-duration: 350ms, 50ms; + // Enter easing function for scale, linear for opacity. + transition-timing-function: map.get( + $_md-sys-motion, + easing-emphasized-decelerate + ), + linear; + transform: scale(1); + } - .md3-radio__inner-circle { - transition: enter(transform), enter(border-color); - } - } + // Don't animate when disabled + :host([disabled]) .inner-circle { + transition-duration: 0s; } - .md3-radio--disabled { - cursor: default; - pointer-events: none; + :host(:hover) .icon { + fill: var(--_unselected-hover-icon-color); } - .md3-radio__native-control:checked { - + .md3-radio__background { - .md3-radio__inner-circle { - transform: scale(1); - transition: enter(transform), enter(border-color); - } - } + :host(:focus-within) .icon { + fill: var(--_unselected-focus-icon-color); } - .md3-radio__native-control:disabled, - [aria-disabled='true'] .md3-radio__native-control { - + .md3-radio__background { - cursor: default; - } + :host(:active) .icon { + fill: var(--_unselected-pressed-icon-color); } - @include focus-ring.theme( - ( - offset-vertical: -2px, - offset-horizontal: -2px, - ) - ); + :host([disabled]) .icon { + fill: var(--_disabled-unselected-icon-color); + opacity: var(--_disabled-unselected-icon-opacity); + } - @media (forced-colors: active) { - .md3-radio { - @include radio-theme.high-contrast-styles(); - } + :host([checked]:hover) .icon { + fill: var(--_selected-hover-icon-color); } -} -$_transition-duration: 120ms; + :host([checked]:focus-within) .icon { + fill: var(--_selected-focus-icon-color); + } -@function enter($name) { - @return animation.deceleration($name, $_transition-duration); -} + :host([checked]:active) .icon { + fill: var(--_selected-pressed-icon-color); + } -@function exit($name) { - @return animation.sharp($name, $_transition-duration); + :host([checked][disabled]) .icon { + fill: var(--_disabled-selected-icon-color); + opacity: var(--_disabled-selected-icon-opacity); + } } diff --git a/radio/lib/forced-colors-styles.scss b/radio/lib/forced-colors-styles.scss new file mode 100644 index 0000000000..99d963e8be --- /dev/null +++ b/radio/lib/forced-colors-styles.scss @@ -0,0 +1,27 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@use './radio'; + +@media (forced-colors: active) { + :host { + @include radio.theme( + ( + disabled-selected-icon-color: GrayText, + disabled-selected-icon-opacity: 1, + disabled-unselected-icon-color: GrayText, + disabled-unselected-icon-opacity: 1, + selected-icon-color: CanvasText, + selected-hover-icon-color: CanvasText, + selected-focus-icon-color: CanvasText, + selected-pressed-icon-color: CanvasText, + unselected-icon-color: CanvasText, + unselected-hover-icon-color: CanvasText, + unselected-focus-icon-color: CanvasText, + unselected-pressed-icon-color: CanvasText, + ) + ); + } +} diff --git a/radio/lib/radio-styles.scss b/radio/lib/radio-styles.scss index 9948db90e8..b226c3c75c 100644 --- a/radio/lib/radio-styles.scss +++ b/radio/lib/radio-styles.scss @@ -4,11 +4,5 @@ // @use './radio'; -@use './radio-theme'; -:host { - @include radio-theme.theme-styles(radio-theme.$light-theme); - @include radio.static-styles(); - - display: inline-flex; -} +@include radio.styles; diff --git a/radio/lib/radio.ts b/radio/lib/radio.ts index 49356d77f0..d727626ef0 100644 --- a/radio/lib/radio.ts +++ b/radio/lib/radio.ts @@ -4,15 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -// Style preference for leading underscores. -// tslint:disable:strip-private-property-underscore - import '../../focus/focus-ring.js'; import '../../ripple/ripple.js'; -import {html, LitElement, nothing, PropertyValues, TemplateResult} from 'lit'; +import {html, LitElement, nothing, TemplateResult} from 'lit'; import {property, query, queryAsync, state} from 'lit/decorators.js'; -import {classMap} from 'lit/directives/class-map.js'; import {when} from 'lit/directives/when.js'; import {dispatchActivationClick, isActivationClick, redispatchEvent} from '../../controller/events.js'; @@ -28,7 +24,6 @@ const CHECKED = Symbol('checked'); /** * @fires checked - * @soyCompatible */ export class Radio extends LitElement { static override shadowRootOptions: @@ -53,7 +48,7 @@ export class Radio extends LitElement { [CHECKED] = false; - @property({type: Boolean}) disabled = false; + @property({type: Boolean, reflect: true}) disabled = false; /** * The element value to use in form submission when checked. @@ -65,13 +60,6 @@ export class Radio extends LitElement { */ @property({type: String, reflect: true}) name = ''; - /** - * Touch target extends beyond visual boundary of a component by default. - * Set to `true` to remove touch target added to the component. - * @see https://material.io/design/usability/accessibility.html - */ - @property({type: Boolean}) reducedTouchTarget = false; - @ariaProperty // tslint:disable-line:no-new-decorators @property({attribute: 'data-aria-label', noAccessor: true}) override ariaLabel!: string; @@ -83,7 +71,6 @@ export class Radio extends LitElement { return this.closest('form'); } - @state() private focused = false; @query('input') private readonly input!: HTMLInputElement|null; @queryAsync('md-ripple') private readonly ripple!: Promise; private readonly selectionController = new SingleSelectionController(this); @@ -111,64 +98,39 @@ export class Radio extends LitElement { this.input?.focus(); } - override updated(changedProperties: PropertyValues) { - if (changedProperties.has('checked') && this.input) { - this.input.checked = this.checked; - if (!this.checked) { - // Remove focus ring when unchecked on other radio programmatically. - // Blur on input since this determines the focus style. - this.input.blur(); - } - } - } - - /** - * @soyTemplate - * @soyAttributes radioAttributes: input - * @soyClasses radioClasses: .md3-radio - */ protected override render(): TemplateResult { - /** @classMap */ - const classes = { - 'md3-radio--touch': !this.reducedTouchTarget, - 'md3-ripple-upgraded--background-focused': this.focused, - 'md3-radio--disabled': this.disabled, - }; - return html` -
- ${this.renderFocusRing()} - -
-
-
-
-
- ${when(this.showRipple, this.renderRipple)} -
-
`; + ${when(this.showRipple, this.renderRipple)} + ${this.renderFocusRing()} + + + + + + + + + + `; } private handleBlur() { - this.focused = false; this.showFocusRing = false; } private handleFocus() { - this.focused = true; this.showFocusRing = shouldShowStrongFocus(); } @@ -182,7 +144,7 @@ export class Radio extends LitElement { redispatchEvent(this, event); } - private handlePointerDown(event: PointerEvent) { + private handlePointerDown() { pointerPress(); this.showFocusRing = shouldShowStrongFocus(); } diff --git a/radio/radio.ts b/radio/radio.ts index 7906d603be..61cbf34894 100644 --- a/radio/radio.ts +++ b/radio/radio.ts @@ -6,6 +6,7 @@ import {customElement} from 'lit/decorators.js'; +import {styles as forcedColorsStyles} from './lib/forced-colors-styles.css.js'; import {Radio} from './lib/radio.js'; import {styles} from './lib/radio-styles.css.js'; @@ -18,5 +19,5 @@ declare global { /** @soyCompatible */ @customElement('md-radio') export class MdRadio extends Radio { - static override styles = [styles]; + static override styles = [styles, forcedColorsStyles]; } diff --git a/radio/radio_test.ts b/radio/radio_test.ts index 995658a572..52be9a2cf5 100644 --- a/radio/radio_test.ts +++ b/radio/radio_test.ts @@ -8,6 +8,7 @@ import {html} from 'lit'; import {MdFocusRing} from '../focus/focus-ring.js'; import {Environment} from '../testing/environment.js'; +import {createTokenTests} from '../testing/tokens.js'; import {RadioHarness} from './harness.js'; import {MdRadio} from './radio.js'; @@ -32,7 +33,7 @@ const radioGroupPreSelected = html` `; -describe('md-radio', () => { +describe('', () => { const env = new Environment(); // Note, this would be better in the harness, but waiting in the test setup @@ -53,6 +54,10 @@ describe('md-radio', () => { return {harnesses, root}; } + describe('.styles', () => { + createTokenTests(MdRadio.styles); + }); + describe('basic', () => { it('initializes as an md-radio', async () => { const {harnesses} = await setupTest();