chore: move radio into the new directory

PiperOrigin-RevId: 460536739
This commit is contained in:
Material Web Team 2022-07-12 13:17:59 -07:00 committed by Copybara-Service
parent c7d87ff450
commit 5aafcaafb4
9 changed files with 1539 additions and 0 deletions

1
radio/_radio.scss Normal file
View File

@ -0,0 +1 @@
@forward './lib/radio-theme' show theme, theme-extension;

19
radio/harness.ts Normal file
View File

@ -0,0 +1,19 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {Harness} from '@material/web/testing/harness';
import {Radio} from './lib/radio';
/**
* Test harness for radio.
*/
export class RadioHarness extends Harness<Radio> {
override async getInteractiveElement() {
await this.element.updateComplete;
return this.element.renderRoot.querySelector('input') as HTMLInputElement;
}
}

381
radio/lib/_radio-theme.scss Normal file
View File

@ -0,0 +1,381 @@
//
// 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 '@material/web/ripple/ripple-theme';
@use '@material/web/compat/theme/shadow-dom';
@use '@material/web/sass/theme';
@use '@material/web/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.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.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);
}
@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);
}
}
}
///
/// 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);
}
}
@mixin _disabled-unchecked-stroke-opacity($opacity) {
@include _if-disabled-unchecked {
@include _stroke-opacity($opacity);
}
}
///
/// 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);
}
}
@mixin _disabled-checked-stroke-opacity($opacity) {
@include if-disabled-checked_ {
@include _stroke-opacity($opacity);
}
}
///
/// 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-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;
}
}
@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.
/// @access private
///
@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.
/// @access private
///
@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.
/// @access private
///
@mixin if-disabled-checked_ {
.md3-radio__native-control:disabled {
&:checked + {
@content;
}
}
}
///
/// Sets the ink color for radio. This is wrapped in a mixin
/// that qualifies state such as `_if-enabled`
/// @access private
///
@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`
/// @access private
///
@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() {
@include shadow-dom.host-aware(selector.append(&, '[checked]')) {
@content;
}
}

131
radio/lib/_radio.scss Normal file
View File

@ -0,0 +1,131 @@
//
// 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 '@material/web/focus/lib/focus-ring-theme';
@use '@material/web/motion/animation';
@use './radio-theme';
@mixin static-styles() {
.md3-radio {
display: inline-flex;
position: relative;
cursor: pointer;
will-change: opacity, transform, border-color, color;
justify-content: center;
align-items: center;
}
.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%;
inset: 0;
}
.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);
}
}
}
.md3-radio--disabled {
cursor: default;
pointer-events: none;
}
.md3-radio__native-control:checked {
+ .md3-radio__background {
.md3-radio__inner-circle {
transform: scale(1);
transition: enter(transform), enter(border-color);
}
}
}
.md3-radio__native-control:disabled,
[aria-disabled='true'] .md3-radio__native-control {
+ .md3-radio__background {
cursor: default;
}
}
@include focus-ring-theme.theme(
(
container-outer-padding-vertical: -2px,
container-outer-padding-horizontal: -2px,
)
);
@media (forced-colors: active) {
.md3-radio {
@include radio-theme.high-contrast-styles();
}
}
}
$_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,14 @@
//
// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0
//
@use './radio';
@use './radio-theme';
:host {
@include radio-theme.theme-styles(radio-theme.$light-theme);
@include radio.static-styles();
display: inline-flex;
}

298
radio/lib/radio.ts Normal file
View File

@ -0,0 +1,298 @@
/**
* @license
* Copyright 2018 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Style preference for leading underscores.
// tslint:disable:strip-private-property-underscore
import '@material/web/focus/focus-ring';
import '@material/web/ripple/ripple';
import {ariaProperty as legacyAriaProperty} from '@material/mwc-base/aria-property';
import {ActionElement, BeginPressConfig, EndPressConfig} from '@material/web/actionelement/action-element';
import {ariaProperty} from '@material/web/decorators/aria-property';
import {pointerPress, shouldShowStrongFocus} from '@material/web/focus/strong-focus';
import {MdRipple} from '@material/web/ripple/ripple';
import {html, TemplateResult} from 'lit';
import {property, query, state} from 'lit/decorators';
import {classMap} from 'lit/directives/class-map';
import {ifDefined} from 'lit/directives/if-defined';
import {SingleSelectionController} from './single-selection-controller';
/**
* @fires checked
* @soyCompatible
*/
export class Radio extends ActionElement {
@query('input') protected formElement!: HTMLInputElement;
@query('md-ripple') ripple!: MdRipple;
protected _checked = false;
@state() protected showFocusRing = false;
@property({type: Boolean}) global = false;
@property({type: Boolean, reflect: true})
get checked(): boolean {
return this.getChecked();
}
protected getChecked(): boolean {
return this._checked;
}
/**
* We define our own getter/setter for `checked` because we need to track
* changes to it synchronously.
*
* The order in which the `checked` property is set across radio buttons
* within the same group is very important. However, we can't rely on
* UpdatingElement's `updated` callback to observe these changes (which is
* also what the `@observer` decorator uses), because it batches changes to
* all properties.
*
* Consider:
*
* radio1.disabled = true;
* radio2.checked = true;
* radio1.checked = true;
*
* In this case we'd first see all changes for radio1, and then for radio2,
* and we couldn't tell that radio1 was the most recently checked.
*/
set checked(isChecked: boolean) {
this.setChecked(isChecked);
}
protected setChecked(isChecked: boolean) {
const oldValue = this._checked;
if (isChecked === oldValue) {
return;
}
this._checked = isChecked;
if (this.formElement) {
this.formElement.checked = isChecked;
}
this.selectionController?.update(this);
if (isChecked === false) {
// Remove focus ring when unchecked on other radio programmatically.
// Blur on input since this determines the focus style.
this.formElement?.blur();
}
this.requestUpdate('checked', oldValue);
// useful when unchecks self and wrapping element needs to synchronize
// TODO(b/168543810): Remove triggering event on programmatic API call.
this.dispatchEvent(new Event('checked', {bubbles: true, composed: true}));
}
@property({type: Boolean}) override disabled = false;
@property({type: String}) value = 'on';
@property({type: String}) 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;
protected selectionController?: SingleSelectionController;
/**
* input's tabindex is updated based on checked status.
* Tab navigation will be removed from unchecked radios.
*/
@property({type: Number}) formElementTabIndex = 0;
@state() protected focused = false;
/** @soyPrefixAttribute */
@ariaProperty
@property({attribute: 'aria-label'})
override ariaLabel!: string;
/** @soyPrefixAttribute */
@legacyAriaProperty
@property({attribute: 'aria-labelledby'})
ariaLabelledBy!: string;
/** @soyPrefixAttribute */
@legacyAriaProperty
@property({type: String, attribute: 'aria-describedby'})
ariaDescribedBy!: undefined|string;
protected rippleElement: MdRipple|null = null;
/** @soyTemplate */
protected renderRipple(): TemplateResult|string {
return html`<md-ripple unbounded
.disabled="${this.disabled}"></md-ripple>`;
}
/** @soyTemplate */
protected renderFocusRing(): TemplateResult {
return html`<md-focus-ring .visible="${
this.showFocusRing}"></md-focus-ring>`;
}
get isRippleActive() {
return false;
}
override connectedCallback() {
super.connectedCallback();
// Note that we must defer creating the selection controller until the
// element has connected, because selection controllers are keyed by the
// radio's shadow root. For example, if we're stamping in a lit map
// or repeat, then we'll be constructed before we're added to a root node.
//
// Also note if we aren't using native shadow DOM, we still need a
// SelectionController, because we should update checked status of other
// radios in the group when selection changes. It also simplifies
// implementation and testing to use one in all cases.
//
// eslint-disable-next-line @typescript-eslint/no-use-before-define
this.selectionController = SingleSelectionController.getController(this);
this.selectionController.register(this);
// Radios maybe checked before connected, update selection as soon it is
// connected to DOM. Last checked radio button in the DOM will be selected.
//
// NOTE: If we update selection only after firstUpdate() we might mistakenly
// update checked status before other radios are rendered.
this.selectionController.update(this);
}
override disconnectedCallback() {
// The controller is initialized in connectedCallback, so if we are in
// disconnectedCallback then it must be initialized.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.selectionController!.unregister(this);
this.selectionController = undefined;
}
protected createAdapter() {}
override beginPress({positionEvent}: BeginPressConfig) {
this.ripple.beginPress(positionEvent);
}
override endPress({cancelled}: EndPressConfig) {
this.ripple.endPress();
if (cancelled) {
return;
}
super.endPress({
cancelled,
actionData:
{checked: this.formElement.checked, value: this.formElement.value}
});
}
override click() {
this.formElement.focus();
this.formElement.click();
}
protected handleFocus() {
this.focused = true;
this.showFocusRing = shouldShowStrongFocus();
}
protected handleBlur() {
this.focused = false;
this.showFocusRing = false;
}
protected setFormData(formData: FormData) {
if (this.name && this.checked) {
formData.append(this.name, this.value);
}
}
/**
* @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
tabindex="${this.formElementTabIndex}"
class="md3-radio__native-control"
type="radio"
name="${this.name}"
aria-label="${ifDefined(this.ariaLabel)}"
aria-labelledby="${ifDefined(this.ariaLabelledBy)}"
aria-describedby="${ifDefined(this.ariaDescribedBy)}"
.checked="${this.checked}"
.value="${this.value}"
?disabled="${this.disabled}"
@change="${this.changeHandler}"
@focus="${this.handleFocus}"
@click="${this.handleClick}"
@blur="${this.handleBlur}"
@pointerenter=${this.handlePointerEnter}
@pointerdown=${this.handlePointerDown}
@pointerup=${this.handlePointerUp}
@pointercancel=${this.handlePointerCancel}
@pointerleave=${this.handlePointerLeave}
>
<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">
${this.renderRipple()}
</div>
</div>`;
}
protected handlePointerEnter() {
this.ripple.beginHover();
}
override handlePointerDown(event: PointerEvent) {
super.handlePointerDown(event);
pointerPress();
this.showFocusRing = shouldShowStrongFocus();
}
override handlePointerLeave(e: PointerEvent) {
super.handlePointerLeave(e);
this.ripple.endHover();
}
protected changeHandler() {
if (this.disabled) {
return;
}
this.checked = this.formElement.checked;
this.dispatchEvent(new Event('change', {
bubbles: true,
composed: true,
}));
}
}

View File

@ -0,0 +1,335 @@
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Style preference for leading underscores.
// tslint:disable:strip-private-property-underscore
/**
* Unique symbol for marking roots
*/
const selectionController = Symbol('selection controller');
/**
* Set of checkable elements with added metadata
*/
export class SingleSelectionSet {
selected: CheckableElement|null = null;
ordered: CheckableElement[]|null = null;
readonly set = new Set<CheckableElement>();
}
/**
* Element that is checkable consumed by
* `SingleSelectionController` and `SingleSelectionSet`
*/
export type CheckableElement = HTMLElement&{
name: string;
checked: boolean;
formElementTabIndex?: number;
};
/**
* Controller that provides behavior similar to a native `<input type="radio">`
* group.
*
* Behaviors:
*
* - Selection via key navigation (currently LTR is supported)
* - Deselection of other grouped, checkable controls upon selection
* - Grouping of checkable elements by name
* - Defaults grouping scope to host shadow root
* - Document-wide scoping enabled
* - Land focus only on checked element. Focuses leading element when none
* checked.
*
* Intended Usage:
*
* ```ts
* class MyElement extends HTMLElement {
* private selectionController: SingleSelectionController | null = null;
* name = "";
* global = false;
*
* private _checked = false;
* set checked(checked: boolean) {
* const oldVal = this._checked;
* if (checked === oldVal) return;
*
* this._checked = checked;
*
* if (this.selectionController) {
* this.selectionController.update(this)
* }
* }
*
* get checked() {
* return this._checked;
* }
*
* connectedCallback() {
* this.selectionController = SelectionController.getController(this);
* this.selectionController.register(this);
* this.selectionController.update(this);
* }
*
* disconnectedCallback() {
* this.selectionController!.unregister(this);
* this.selectionController = null;
* }
* }
* ```
*/
export class SingleSelectionController {
private readonly sets: {[name: string]: SingleSelectionSet} = {};
private focusedSet: SingleSelectionSet|null = null;
private mouseIsDown = false;
private updating = false;
/**
* Get a controller for the given element. If no controller exists, one will
* be created. Defaults to getting the controller scoped to the element's root
* node shadow root unless `element.global` is true. Then, it will get a
* `window.document`-scoped controller.
*
* @param element Element from which to get / create a SelectionController. If
* `element.global` is true, it gets a selection controller scoped to
* `window.document`.
*/
static getController(element: HTMLElement|HTMLElement&{global: boolean}) {
const useGlobal =
!('global' in element) || ('global' in element && element.global);
const root = useGlobal ? document as Document &
{[selectionController]?: SingleSelectionController} :
(element as Element).getRootNode() as Node &
{[selectionController]?: SingleSelectionController};
let controller = root[selectionController];
if (controller === undefined) {
controller = new SingleSelectionController(root);
root[selectionController] = controller;
}
return controller;
}
constructor(element: Node) {
element.addEventListener('keydown', (e: Event) => {
this.keyDownHandler(e as KeyboardEvent);
});
element.addEventListener('mousedown', () => {
this.mousedownHandler();
});
element.addEventListener('mouseup', () => {
this.mouseupHandler();
});
}
protected keyDownHandler(e: KeyboardEvent) {
const element = e.target as EventTarget | CheckableElement;
if (!('checked' in element)) {
return;
}
if (!this.has(element)) {
return;
}
if (e.key == 'ArrowRight' || e.key == 'ArrowDown') {
this.selectNext(element);
} else if (e.key == 'ArrowLeft' || e.key == 'ArrowUp') {
this.selectPrevious(element);
}
}
protected mousedownHandler() {
this.mouseIsDown = true;
}
protected mouseupHandler() {
this.mouseIsDown = false;
}
/**
* Whether or not the controller controls the given element.
*
* @param element element to check
*/
has(element: CheckableElement) {
const set = this.getSet(element.name);
return set.set.has(element);
}
/**
* Selects and returns the controlled element previous to the given element in
* document position order. See
* [Node.compareDocumentPosition](https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition).
*
* @param element element relative from which preceding element is fetched
*/
selectPrevious(element: CheckableElement) {
const order = this.getOrdered(element);
const i = order.indexOf(element);
const previous = order[i - 1] || order[order.length - 1];
this.select(previous);
return previous;
}
/**
* Selects and returns the controlled element next to the given element in
* document position order. See
* [Node.compareDocumentPosition](https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition).
*
* @param element element relative from which following element is fetched
*/
selectNext(element: CheckableElement) {
const order = this.getOrdered(element);
const i = order.indexOf(element);
const next = order[i + 1] || order[0];
this.select(next);
return next;
}
select(element: CheckableElement) {
element.click();
}
/**
* Focuses the selected element in the given element's selection set. User's
* mouse selection will override this focus.
*
* @param element Element from which selection set is derived and subsequently
* focused.
* @deprecated update() method now handles focus management by setting
* appropriate tabindex to form element.
*/
focus(element: CheckableElement) {
// Only manage focus state when using keyboard
if (this.mouseIsDown) {
return;
}
const set = this.getSet(element.name);
const currentFocusedSet = this.focusedSet;
this.focusedSet = set;
if (currentFocusedSet != set && set.selected && set.selected != element) {
set.selected.focus();
}
}
/**
* @return Returns true if atleast one radio is selected in the radio group.
*/
isAnySelected(element: CheckableElement): boolean {
const set = this.getSet(element.name);
for (const e of set.set) {
if (e.checked) {
return true;
}
}
return false;
}
/**
* Returns the elements in the given element's selection set in document
* position order.
* [Node.compareDocumentPosition](https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition).
*
* @param element Element from which selection set is derived and subsequently
* ordered.
*/
getOrdered(element: CheckableElement) {
const set = this.getSet(element.name);
if (!set.ordered) {
set.ordered = Array.from(set.set);
set.ordered.sort(
(a, b) =>
a.compareDocumentPosition(b) == Node.DOCUMENT_POSITION_PRECEDING ?
1 :
0);
}
return set.ordered;
}
/**
* Gets the selection set of the given name and creates one if it does not yet
* exist.
*
* @param name Name of set
*/
getSet(name: string): SingleSelectionSet {
if (!this.sets[name]) {
this.sets[name] = new SingleSelectionSet();
}
return this.sets[name];
}
/**
* Register the element in the selection controller.
*
* @param element Element to register. Registers in set of `element.name`.
*/
register(element: CheckableElement) {
// TODO(b/168546148): Remove accessing 'name' via getAttribute() when new
// base class is created without single selection controller. Component
// maybe booted up after it is connected to DOM in which case properties
// (including `name`) are not updated yet.
const name = element.name || element.getAttribute('name') || '';
const set = this.getSet(name);
set.set.add(element);
set.ordered = null;
}
/**
* Unregister the element from selection controller.
*
* @param element Element to register. Registers in set of `element.name`.
*/
unregister(element: CheckableElement) {
const set = this.getSet(element.name);
set.set.delete(element);
set.ordered = null;
if (set.selected == element) {
set.selected = null;
}
}
/**
* Unselects other elements in element's set if element is checked. Noop
* otherwise.
*
* @param element Element from which to calculate selection controller update.
*/
update(element: CheckableElement) {
if (this.updating) {
return;
}
this.updating = true;
const set = this.getSet(element.name);
if (element.checked) {
for (const e of set.set) {
if (e == element) {
continue;
}
e.checked = false;
}
set.selected = element;
}
// When tabbing through land focus on the checked radio in the group.
if (this.isAnySelected(element)) {
for (const e of set.set) {
if (e.formElementTabIndex === undefined) {
break;
}
e.formElementTabIndex = e.checked ? 0 : -1;
}
}
this.updating = false;
}
}

338
radio/md-radio_test.ts Normal file
View File

@ -0,0 +1,338 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Style preference for leading underscores.
// tslint:disable:strip-private-property-underscore
import {fixture, TestFixture} from '@material/web/compat/testing/helpers'; // TODO(b/235474830): remove the use of fixtures
import {MdFocusRing} from '@material/web/focus/focus-ring';
import {KEY} from 'google3/third_party/javascript/material_components_web/dom/keyboard';
import {html} from 'lit';
import {RadioHarness} from './harness';
import {MdRadio} from './radio';
const defaultRadio = html`<md-radio></md-radio>`;
const radioGroup = html`
<md-radio id="a1" name="a"></md-radio>
<md-radio id="a2" name="a"></md-radio>
<md-radio id="b1" name="b"></md-radio>
`;
const radioGroupDisabled = html`
<md-radio id="a1" name="a" disabled></md-radio>
<md-radio id="a2" name="a" disabled checked></md-radio>
`;
const radioGroupPreSelected = html`
<md-radio id="a1" name="a"></md-radio>
<md-radio id="a2" name="a" checked></md-radio>
<md-radio id="a3" name="a"></md-radio>
<md-radio id="b1" name="b"></md-radio>
`;
const repeatedRadio = (values: string[]) => {
return html`${
values.map(
(value) => html`<md-radio value=${value} name="a"></md-radio>`)}`;
};
describe('md-radio', () => {
let fixt: TestFixture;
let element: MdRadio;
let harness: RadioHarness;
afterEach(() => {
fixt.remove();
});
describe('basic', () => {
it('initializes as an md-radio', async () => {
fixt = await fixture(defaultRadio);
element = fixt.root.querySelector('md-radio')!;
expect(element).toBeInstanceOf(MdRadio);
});
it('clicking a radio should select it', async () => {
fixt = await fixture(radioGroup);
element = fixt.root.querySelectorAll('md-radio')[1];
harness = new RadioHarness(element);
await harness.clickWithMouse();
expect(element.checked).toBeTrue();
});
it('clicking a radio should unselect other radio which is already selected',
async () => {
fixt = await fixture(radioGroupPreSelected);
const a2 = fixt.root.querySelectorAll('md-radio')[1];
expect(a2.checked).toBeTrue();
const a3 = fixt.root.querySelectorAll('md-radio')[2];
harness = new RadioHarness(a3);
await harness.clickWithMouse();
expect(a3.checked).toBeTrue();
expect(a2.checked).toBeFalse();
});
it('disabled radio should not be selected when clicked', async () => {
fixt = await fixture(radioGroupDisabled);
const a1 = fixt.root.querySelectorAll('md-radio')[0];
expect(a1.checked).toBeFalse();
const a2 = fixt.root.querySelectorAll('md-radio')[1];
expect(a2.checked).toBeTrue();
await (new RadioHarness(a1)).clickWithMouse();
expect(a1.checked).toBeFalse();
await (new RadioHarness(a1)).clickWithMouse();
expect(a2.checked).toBeTrue();
});
});
describe('events', () => {
it('Should trigger change event when a radio is selected', async () => {
fixt = await fixture(radioGroupPreSelected);
const changeHandler = jasmine.createSpy('changeHandler');
fixt.root.addEventListener('change', changeHandler);
const a3 = fixt.root.querySelectorAll('md-radio')[2];
harness = new RadioHarness(a3);
await harness.clickWithMouse();
expect(a3.checked).toBeTrue();
expect(changeHandler).toHaveBeenCalledTimes(1);
expect(changeHandler).toHaveBeenCalledWith(jasmine.any(Event));
});
});
describe('navigation', () => {
it('Using arrow right should select the next radio button', async () => {
fixt = await fixture(radioGroupPreSelected);
const a2 = fixt.root.querySelectorAll('md-radio')[1];
expect(a2.checked).toBeTrue();
const eventRight =
new KeyboardEvent('keydown', {key: KEY.ARROW_RIGHT, bubbles: true});
a2.dispatchEvent(eventRight);
const a3 = fixt.root.querySelectorAll('md-radio')[2];
expect(a3.checked).toBeTrue();
expect(a2.checked).toBeFalse();
});
it('Using arrow right on the last radio should select the first radio in that group',
async () => {
fixt = await fixture(radioGroupPreSelected);
const a2 = fixt.root.querySelectorAll('md-radio')[1];
expect(a2.checked).toBeTrue();
const eventRight = new KeyboardEvent(
'keydown', {key: KEY.ARROW_RIGHT, bubbles: true});
a2.dispatchEvent(eventRight);
const a3 = fixt.root.querySelectorAll('md-radio')[2];
a3.dispatchEvent(eventRight);
expect(a3.checked).toBeFalse();
const a1 = fixt.root.querySelectorAll('md-radio')[0];
expect(a1.checked).toBeTrue();
const b1 = fixt.root.querySelectorAll('md-radio')[3];
expect(b1.checked).toBeFalse();
});
});
describe('manages selection groups', () => {
it('synchronously', async () => {
fixt = await fixture(radioGroup);
const [a1, a2, b1] = [...fixt.root.querySelectorAll('md-radio')];
expect(a1.checked).toBeFalse();
expect(a2.checked).toBeFalse();
expect(b1.checked).toBeFalse();
a2.checked = true;
a1.checked = true;
expect(a1.checked).toBeTrue();
expect(a2.checked).toBeFalse();
expect(b1.checked).toBeFalse();
a2.checked = true;
a1.checked = true;
a2.checked = true;
expect(a1.checked).toBeFalse();
expect(a2.checked).toBeTrue();
expect(b1.checked).toBeFalse();
a1.checked = true;
expect(a1.checked).toBeTrue();
expect(a2.checked).toBeFalse();
expect(b1.checked).toBeFalse();
b1.checked = true;
expect(a1.checked).toBeTrue();
expect(a2.checked).toBeFalse();
expect(b1.checked).toBeTrue();
a1.checked = false;
b1.checked = false;
expect(a1.checked).toBeFalse();
expect(a2.checked).toBeFalse();
expect(b1.checked).toBeFalse();
});
it('after updates settle', async () => {
fixt = await fixture(radioGroup);
const radios = [...fixt.root.querySelectorAll('md-radio')];
const [a1, a2, b1] = radios;
const allUpdatesComplete = () =>
Promise.all(radios.map((radio) => radio.updateComplete));
await allUpdatesComplete();
expect(a1.checked).toBeFalse();
expect(a2.checked).toBeFalse();
expect(b1.checked).toBeFalse();
a2.checked = true;
a1.checked = true;
await allUpdatesComplete();
expect(a1.checked).toBeTrue();
expect(a2.checked).toBeFalse();
expect(b1.checked).toBeFalse();
a2.checked = true;
a1.checked = true;
a2.checked = true;
await allUpdatesComplete();
expect(a1.checked).toBeFalse();
expect(a2.checked).toBeTrue();
expect(b1.checked).toBeFalse();
a1.checked = true;
expect(a1.checked).toBeTrue();
expect(a2.checked).toBeFalse();
expect(b1.checked).toBeFalse();
b1.checked = true;
await allUpdatesComplete();
expect(a1.checked).toBeTrue();
expect(a2.checked).toBeFalse();
expect(b1.checked).toBeTrue();
a1.checked = false;
b1.checked = false;
await allUpdatesComplete();
expect(a1.checked).toBeFalse();
expect(a2.checked).toBeFalse();
expect(b1.checked).toBeFalse();
});
it('when checked before connected', async () => {
fixt = await fixture(html`<main></main>`);
const container = fixt.root.querySelector('main')!;
const r1 = document.createElement('md-radio');
r1.name = 'a';
const r2 = document.createElement('md-radio');
r2.name = 'a';
const r3 = document.createElement('md-radio');
r3.name = 'a';
// r1 and r2 should both be checked, because even though they have the
// same name, they aren't yet connected to a root. Groups are scoped to
// roots, and we can't know which root a radio belongs to until it is
// connected to one. This matches native <input type="radio"> behavior.
r1.checked = true;
r2.checked = true;
expect(r1.checked).toBeTrue();
expect(r2.checked).toBeTrue();
expect(r3.checked).toBeFalse();
// Connecting r1 shouldn't change anything, since it's the only one in the
// group.
container.appendChild(r1);
expect(r1.checked).toBeTrue();
expect(r2.checked).toBeTrue();
expect(r3.checked).toBeFalse();
// Appending r2 should disable r1, because when a new checked radio is
// connected, it wins (this matches native input behavior).
container.appendChild(r2);
expect(r1.checked).toBeFalse();
expect(r2.checked).toBeTrue();
expect(r3.checked).toBeFalse();
// Appending r3 shouldn't change anything, because it's not checked.
container.appendChild(r3);
expect(r1.checked).toBeFalse();
expect(r2.checked).toBeTrue();
expect(r3.checked).toBeFalse();
// Checking r3 should uncheck r2 because it's now in the same group.
r3.checked = true;
expect(r1.checked).toBeFalse();
expect(r2.checked).toBeFalse();
expect(r3.checked).toBeTrue();
});
it('in a lit repeat', async () => {
const values = ['a1', 'a2'];
fixt = await fixture(repeatedRadio(values));
const [a1, a2] = fixt.root.querySelectorAll('md-radio');
expect(a1.checked).toBeFalse();
expect(a2.checked).toBeFalse();
expect(a1.value).toEqual(values[0]);
expect(a2.value).toEqual(values[1]);
a1.checked = true;
expect(a1.checked).toBeTrue();
expect(a2.checked).toBeFalse();
a2.checked = true;
expect(a1.checked).toBeFalse();
expect(a2.checked).toBeTrue();
a2.checked = false;
expect(a1.checked).toBeFalse();
expect(a2.checked).toBeFalse();
});
});
describe('focus ring', () => {
let focusRing: MdFocusRing;
beforeEach(async () => {
fixt = await fixture(defaultRadio);
element = fixt.root.querySelector('md-radio')!;
focusRing = element.shadowRoot!.querySelector('md-focus-ring')!;
harness = new RadioHarness(element);
});
it('hidden on non-keyboard focus', async () => {
await harness.clickWithMouse();
expect(focusRing.visible).toBeFalse();
});
it('visible on keyboard focus and hides on blur', async () => {
await harness.focusWithKeyboard();
expect(focusRing.visible).toBeTrue();
await harness.blur();
expect(focusRing.visible).toBeFalse();
});
it('hidden after pointer interaction', async () => {
await harness.focusWithKeyboard();
expect(focusRing.visible).toBeTrue();
await harness.clickWithMouse();
expect(focusRing.visible).toBeFalse();
});
});
});

22
radio/radio.ts Normal file
View File

@ -0,0 +1,22 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {customElement} from 'lit/decorators';
import {Radio} from './lib/radio';
import {styles} from './lib/radio-styles.css';
declare global {
interface HTMLElementTagNameMap {
'md-radio': MdRadio;
}
}
/** @soyCompatible */
@customElement('md-radio')
export class MdRadio extends Radio {
static override styles = [styles];
}