fix(radio): update rendering and styles

PiperOrigin-RevId: 499587641
This commit is contained in:
Elizabeth Mitchell 2023-01-04 15:00:49 -08:00 committed by Copybara-Service
parent b0e87c538a
commit 3aff084297
8 changed files with 207 additions and 558 deletions

View File

@ -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;

View File

@ -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;
}
}
}

View File

@ -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';
@use './radio-theme';
$_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);
}
@mixin styles() {
$tokens: tokens.md-comp-radio-button-values();
$tokens: theme.create-theme-vars($tokens, 'radio');
@mixin static-styles() {
:host {
@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,
)
);
display: inline-flex;
height: 48px;
position: relative;
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 {
display: inline-flex;
position: relative;
cursor: pointer;
will-change: opacity, transform, border-color, color;
justify-content: center;
align-items: 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__background {
display: inline-flex;
position: relative;
box-sizing: border-box;
align-items: center;
justify-content: center;
}
.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 {
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;
}
.md3-radio__native-control {
position: absolute;
margin: 0;
padding: 0;
opacity: 0;
cursor: inherit;
z-index: 1;
block-size: 100%;
inline-size: 100%;
input,
md-ripple,
md-focus-ring,
.icon {
inset: 0;
margin: auto;
position: absolute;
}
.md3-radio__native-control:checked,
.md3-radio__native-control:disabled {
+ .md3-radio__background {
transition: enter(opacity), enter(transform);
.md3-radio__outer-circle {
transition: enter(border-color);
}
.md3-radio__inner-circle {
transition: enter(transform), enter(border-color);
}
}
input {
appearance: none;
outline: none;
}
.md3-radio--disabled {
cursor: default;
pointer-events: none;
md-ripple {
height: var(--_state-layer-size);
width: var(--_state-layer-size);
}
.md3-radio__native-control:checked {
+ .md3-radio__background {
.md3-radio__inner-circle {
transform: scale(1);
transition: enter(transform), enter(border-color);
}
}
.icon {
fill: var(--_unselected-icon-color);
height: var(--_icon-size);
width: var(--_icon-size);
}
.md3-radio__native-control:disabled,
[aria-disabled='true'] .md3-radio__native-control {
+ .md3-radio__background {
cursor: default;
}
.inner-circle {
opacity: 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;
}
@include focus-ring.theme(
(
offset-vertical: -2px,
offset-horizontal: -2px,
)
);
:host([checked]) .icon {
fill: var(--_selected-icon-color);
}
@media (forced-colors: active) {
.md3-radio {
@include radio-theme.high-contrast-styles();
}
: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);
}
// Don't animate when disabled
:host([disabled]) .inner-circle {
transition-duration: 0s;
}
:host(:hover) .icon {
fill: var(--_unselected-hover-icon-color);
}
:host(:focus-within) .icon {
fill: var(--_unselected-focus-icon-color);
}
:host(:active) .icon {
fill: var(--_unselected-pressed-icon-color);
}
:host([disabled]) .icon {
fill: var(--_disabled-unselected-icon-color);
opacity: var(--_disabled-unselected-icon-opacity);
}
:host([checked]:hover) .icon {
fill: var(--_selected-hover-icon-color);
}
:host([checked]:focus-within) .icon {
fill: var(--_selected-focus-icon-color);
}
:host([checked]:active) .icon {
fill: var(--_selected-pressed-icon-color);
}
:host([checked][disabled]) .icon {
fill: var(--_disabled-selected-icon-color);
opacity: var(--_disabled-selected-icon-opacity);
}
}
$_transition-duration: 120ms;
@function enter($name) {
@return animation.deceleration($name, $_transition-duration);
}
@function exit($name) {
@return animation.sharp($name, $_transition-duration);
}

View File

@ -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,
)
);
}
}

View File

@ -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;

View File

@ -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<MdRipple|null>;
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`
<div class="md3-radio ${classMap(classes)}">
${this.renderFocusRing()}
<input
class="md3-radio__native-control"
type="radio"
name="${this.name}"
aria-label="${this.ariaLabel || nothing}"
.checked="${this.checked}"
.value="${this.value}"
?disabled="${this.disabled}"
@change="${this.handleChange}"
@focus="${this.handleFocus}"
@blur="${this.handleBlur}"
@pointerdown=${this.handlePointerDown}
${ripple(this.getRipple)}
>
<div class="md3-radio__background">
<div class="md3-radio__outer-circle"></div>
<div class="md3-radio__inner-circle"></div>
</div>
<div class="md3-radio__ripple">
${when(this.showRipple, this.renderRipple)}
</div>
</div>`;
${when(this.showRipple, this.renderRipple)}
${this.renderFocusRing()}
<svg class="icon" viewBox="0 0 20 20">
<mask id="cutout">
<rect width="100%" height="100%" fill="white" />
<circle cx="10" cy="10" r="8" fill="black" />
</mask>
<circle cx="10" cy="10" r="10" mask="url(#cutout)" />
<circle cx="10" cy="10" r="5" class="inner-circle" />
</svg>
<input
type="radio"
name=${this.name}
aria-label=${this.ariaLabel || nothing}
.checked=${this.checked}
.value=${this.value}
?disabled=${this.disabled}
@change=${this.handleChange}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
@pointerdown=${this.handlePointerDown}
${ripple(this.getRipple)}
>
`;
}
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();
}

View File

@ -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];
}

View File

@ -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`
<md-radio id="b1" name="b"></md-radio>
`;
describe('md-radio', () => {
describe('<md-radio>', () => {
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();