| // Copyright 2018 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import '../cr_radio_button/cr_radio_button.m.js'; |
| import '../shared_vars_css.m.js'; |
| |
| import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js'; |
| |
| import {assert} from '../../js/assert_ts.js'; |
| import {EventTracker} from '../../js/event_tracker.m.js'; |
| import {CrRadioButtonElement} from '../cr_radio_button/cr_radio_button.m.js'; |
| |
| import {getTemplate} from './cr_radio_group.html.js'; |
| |
| function isEnabled(radio: HTMLElement): boolean { |
| return radio.matches(':not([disabled]):not([hidden])') && |
| radio.style.display !== 'none' && radio.style.visibility !== 'hidden'; |
| } |
| |
| export class CrRadioGroupElement extends PolymerElement { |
| static get is() { |
| return 'cr-radio-group'; |
| } |
| |
| static get template() { |
| return getTemplate(); |
| } |
| |
| static get properties() { |
| return { |
| disabled: { |
| type: Boolean, |
| value: false, |
| reflectToAttribute: true, |
| observer: 'update_', |
| }, |
| |
| selected: { |
| type: String, |
| notify: true, |
| observer: 'update_', |
| }, |
| |
| selectableElements: { |
| type: String, |
| value: 'cr-radio-button, cr-card-radio-button, controlled-radio-button', |
| }, |
| |
| selectableRegExp_: { |
| value: Object, |
| computed: 'computeSelectableRegExp_(selectableElements)', |
| }, |
| }; |
| } |
| |
| disabled: boolean; |
| selected: string; |
| selectableElements: string; |
| private selectableRegExp_: RegExp; |
| |
| private buttons_: CrRadioButtonElement[]|null = null; |
| private buttonEventTracker_: EventTracker|null = null; |
| private deltaKeyMap_: Map<string, number>|null = null; |
| private isRtl_: boolean = false; |
| private populateBound_: (() => void)|null = null; |
| |
| override ready() { |
| super.ready(); |
| this.addEventListener( |
| 'keydown', e => this.onKeyDown_(/** @type {!KeyboardEvent} */ (e))); |
| this.addEventListener('click', this.onClick_.bind(this)); |
| |
| if (!this.hasAttribute('role')) { |
| this.setAttribute('role', 'radiogroup'); |
| } |
| this.setAttribute('aria-disabled', 'false'); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| this.isRtl_ = this.matches(':host-context([dir=rtl]) cr-radio-group'); |
| this.deltaKeyMap_ = new Map([ |
| ['ArrowDown', 1], |
| ['ArrowLeft', this.isRtl_ ? 1 : -1], |
| ['ArrowRight', this.isRtl_ ? -1 : 1], |
| ['ArrowUp', -1], |
| ['PageDown', 1], |
| ['PageUp', -1], |
| ]); |
| this.buttonEventTracker_ = new EventTracker(); |
| |
| this.populateBound_ = () => this.populate_(); |
| assert(this.populateBound_); |
| this.shadowRoot!.querySelector('slot')!.addEventListener( |
| 'slotchange', this.populateBound_); |
| |
| this.populate_(); |
| } |
| |
| override disconnectedCallback() { |
| super.disconnectedCallback(); |
| assert(this.populateBound_); |
| this.shadowRoot!.querySelector('slot')!.removeEventListener( |
| 'slotchange', this.populateBound_); |
| assert(this.buttonEventTracker_); |
| this.buttonEventTracker_.removeAll(); |
| } |
| |
| override focus() { |
| if (this.disabled || !this.buttons_) { |
| return; |
| } |
| |
| const radio = |
| this.buttons_.find(radio => this.isButtonEnabledAndSelected_(radio)); |
| if (radio) { |
| radio.focus(); |
| } |
| } |
| |
| private onKeyDown_(event: KeyboardEvent) { |
| if (this.disabled) { |
| return; |
| } |
| |
| if (event.ctrlKey || event.shiftKey || event.metaKey || event.altKey) { |
| return; |
| } |
| |
| const targetElement = event.target as CrRadioButtonElement; |
| if (!this.buttons_ || !this.buttons_.includes(targetElement)) { |
| return; |
| } |
| |
| if (event.key === ' ' || event.key === 'Enter') { |
| event.preventDefault(); |
| this.select_(targetElement); |
| return; |
| } |
| |
| const enabledRadios = this.buttons_.filter(isEnabled); |
| if (enabledRadios.length === 0) { |
| return; |
| } |
| |
| assert(this.deltaKeyMap_); |
| let selectedIndex; |
| const max = enabledRadios.length - 1; |
| if (event.key === 'Home') { |
| selectedIndex = 0; |
| } else if (event.key === 'End') { |
| selectedIndex = max; |
| } else if (this.deltaKeyMap_.has(event.key)) { |
| const delta = this.deltaKeyMap_.get(event.key)!; |
| // If nothing selected, start from the first radio then add |delta|. |
| const lastSelection = enabledRadios.findIndex(radio => radio.checked); |
| selectedIndex = Math.max(0, lastSelection) + delta; |
| // Wrap the selection, if needed. |
| if (selectedIndex > max) { |
| selectedIndex = 0; |
| } else if (selectedIndex < 0) { |
| selectedIndex = max; |
| } |
| } else { |
| return; |
| } |
| |
| const radio = enabledRadios[selectedIndex]!; |
| const name = `${radio.name}`; |
| if (this.selected !== name) { |
| event.preventDefault(); |
| this.selected = name; |
| radio.focus(); |
| } |
| } |
| |
| private computeSelectableRegExp_(): RegExp { |
| const tags = this.selectableElements.split(', ').join('|'); |
| return new RegExp(`^(${tags})$`, 'i'); |
| } |
| |
| private onClick_(event: Event) { |
| const path = event.composedPath(); |
| if (path.some(target => /^a$/i.test((target as HTMLElement).tagName))) { |
| return; |
| } |
| const target = |
| path.find( |
| n => this.selectableRegExp_.test((n as HTMLElement).tagName)) as |
| CrRadioButtonElement; |
| if (target && this.buttons_ && this.buttons_.includes(target)) { |
| this.select_(target); |
| } |
| } |
| |
| private populate_() { |
| const nodes = |
| this.shadowRoot!.querySelector('slot')!.assignedNodes({flatten: true}); |
| this.buttons_ = |
| Array.from(nodes).filter( |
| node => node.nodeType === Node.ELEMENT_NODE && |
| (node as HTMLElement).matches(this.selectableElements)) as |
| CrRadioButtonElement[]; |
| assert(this.buttonEventTracker_); |
| this.buttonEventTracker_.removeAll(); |
| this.buttons_!.forEach(el => { |
| this.buttonEventTracker_!.add( |
| el, 'disabled-changed', () => this.populate_()); |
| this.buttonEventTracker_!.add(el, 'name-changed', () => this.populate_()); |
| }); |
| this.update_(); |
| } |
| |
| private select_(button: CrRadioButtonElement) { |
| if (!isEnabled(button)) { |
| return; |
| } |
| |
| const name = `${button.name}`; |
| if (this.selected !== name) { |
| this.selected = name; |
| } |
| } |
| |
| private isButtonEnabledAndSelected_(button: CrRadioButtonElement): boolean { |
| return !this.disabled && button.checked && isEnabled(button); |
| } |
| |
| private update_() { |
| if (!this.buttons_) { |
| return; |
| } |
| let noneMadeFocusable = true; |
| this.buttons_.forEach(radio => { |
| radio.checked = |
| this.selected !== undefined && `${radio.name}` === `${this.selected}`; |
| const disabled = this.disabled || !isEnabled(radio); |
| const canBeFocused = radio.checked && !disabled; |
| if (canBeFocused) { |
| radio.focusable = true; |
| noneMadeFocusable = false; |
| } else { |
| radio.focusable = false; |
| } |
| radio.setAttribute('aria-disabled', `${disabled}`); |
| }); |
| this.setAttribute('aria-disabled', `${this.disabled}`); |
| if (noneMadeFocusable && !this.disabled) { |
| const radio = this.buttons_.find(isEnabled); |
| if (radio) { |
| radio.focusable = true; |
| } |
| } |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'cr-radio-group': CrRadioGroupElement; |
| } |
| } |
| |
| customElements.define(CrRadioGroupElement.is, CrRadioGroupElement); |