diff --git a/radio/_radio.scss b/radio/_radio.scss new file mode 100644 index 000000000..06228720d --- /dev/null +++ b/radio/_radio.scss @@ -0,0 +1 @@ +@forward './lib/radio-theme' show theme, theme-extension; diff --git a/radio/harness.ts b/radio/harness.ts new file mode 100644 index 000000000..34ddf2291 --- /dev/null +++ b/radio/harness.ts @@ -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 { + override async getInteractiveElement() { + await this.element.updateComplete; + return this.element.renderRoot.querySelector('input') as HTMLInputElement; + } +} diff --git a/radio/lib/_radio-theme.scss b/radio/lib/_radio-theme.scss new file mode 100644 index 000000000..a503967dc --- /dev/null +++ b/radio/lib/_radio-theme.scss @@ -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; + } +} diff --git a/radio/lib/_radio.scss b/radio/lib/_radio.scss new file mode 100644 index 000000000..c4ad49472 --- /dev/null +++ b/radio/lib/_radio.scss @@ -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); +} diff --git a/radio/lib/radio-styles.scss b/radio/lib/radio-styles.scss new file mode 100644 index 000000000..9948db90e --- /dev/null +++ b/radio/lib/radio-styles.scss @@ -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; +} diff --git a/radio/lib/radio.ts b/radio/lib/radio.ts new file mode 100644 index 000000000..1591578b5 --- /dev/null +++ b/radio/lib/radio.ts @@ -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``; + } + + /** @soyTemplate */ + protected renderFocusRing(): TemplateResult { + return html``; + } + + 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` +
+ ${this.renderFocusRing()} + +
+
+
+
+
+ ${this.renderRipple()} +
+
`; + } + + 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, + })); + } +} diff --git a/radio/lib/single-selection-controller.ts b/radio/lib/single-selection-controller.ts new file mode 100644 index 000000000..1a8d14ac6 --- /dev/null +++ b/radio/lib/single-selection-controller.ts @@ -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(); +} + +/** + * 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 `` + * 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; + } +} diff --git a/radio/md-radio_test.ts b/radio/md-radio_test.ts new file mode 100644 index 000000000..6a94c700b --- /dev/null +++ b/radio/md-radio_test.ts @@ -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``; + +const radioGroup = html` + + + + `; + +const radioGroupDisabled = html` + + + `; + +const radioGroupPreSelected = html` + + + + + `; + +const repeatedRadio = (values: string[]) => { + return html`${ + values.map( + (value) => html``)}`; +}; + +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`
`); + 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 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(); + }); + }); +}); diff --git a/radio/radio.ts b/radio/radio.ts new file mode 100644 index 000000000..91943c326 --- /dev/null +++ b/radio/radio.ts @@ -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]; +}