All Downloads are FREE. Search and download functionalities are using the official Maven repository.

package.src.vaadin-radio-group-mixin.js Maven / Gradle / Ivy

There is a newer version: 24.4.9
Show newest version
/**
 * @license
 * Copyright (c) 2017 - 2024 Vaadin Ltd.
 * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
 */
import { DisabledMixin } from '@vaadin/a11y-base/src/disabled-mixin.js';
import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js';
import { KeyboardMixin } from '@vaadin/a11y-base/src/keyboard-mixin.js';
import { SlotObserver } from '@vaadin/component-base/src/slot-observer.js';
import { TooltipController } from '@vaadin/component-base/src/tooltip-controller.js';
import { generateUniqueId } from '@vaadin/component-base/src/unique-id-utils.js';
import { FieldMixin } from '@vaadin/field-base/src/field-mixin.js';

/**
 * A mixin providing common radio-group functionality.
 *
 * @polymerMixin
 * @mixes DisabledMixin
 * @mixes FieldMixin
 * @mixes FocusMixin
 * @mixes KeyboardMixin
 */
export const RadioGroupMixin = (superclass) =>
  class RadioGroupMixinClass extends FieldMixin(FocusMixin(DisabledMixin(KeyboardMixin(superclass)))) {
    static get properties() {
      return {
        /**
         * The value of the radio group.
         *
         * @type {string}
         */
        value: {
          type: String,
          notify: true,
          value: '',
          observer: '__valueChanged',
        },

        /**
         * When present, the user cannot modify the value of the radio group.
         * The property works similarly to the `disabled` property.
         * While the `disabled` property disables all radio buttons inside the group,
         * the `readonly` property disables only unchecked ones.
         *
         * @type {boolean}
         */
        readonly: {
          type: Boolean,
          value: false,
          reflectToAttribute: true,
          observer: '__readonlyChanged',
        },

        /**
         * @type {string}
         * @private
         */
        _fieldName: {
          type: String,
        },
      };
    }

    constructor() {
      super();

      this.__registerRadioButton = this.__registerRadioButton.bind(this);
      this.__unregisterRadioButton = this.__unregisterRadioButton.bind(this);
      this.__onRadioButtonCheckedChange = this.__onRadioButtonCheckedChange.bind(this);

      this._tooltipController = new TooltipController(this);
      this._tooltipController.addEventListener('tooltip-changed', (event) => {
        const tooltip = event.detail.node;
        if (tooltip && tooltip.isConnected) {
          // Tooltip element has been added to the DOM
          const inputs = this.__radioButtons.map((radio) => radio.inputElement);
          this._tooltipController.setAriaTarget(inputs);
        } else {
          // Tooltip element is no longer connected
          this._tooltipController.setAriaTarget([]);
        }
      });
    }

    /**
     * A collection of the group's radio buttons.
     *
     * @return {!Array}
     * @private
     */
    get __radioButtons() {
      return this.__filterRadioButtons([...this.children]);
    }

    /**
     * A currently selected radio button.
     *
     * @return {!RadioButton | undefined}
     * @private
     */
    get __selectedRadioButton() {
      return this.__radioButtons.find((radioButton) => radioButton.checked);
    }

    /**
     * @return {boolean}
     * @private
     */
    get isHorizontalRTL() {
      return this.__isRTL && this._theme !== 'vertical';
    }

    /** @protected */
    ready() {
      super.ready();

      this.ariaTarget = this;

      // See https://github.com/vaadin/vaadin-web-components/issues/94
      this.setAttribute('role', 'radiogroup');

      this._fieldName = `${this.localName}-${generateUniqueId()}`;

      const slot = this.shadowRoot.querySelector('slot:not([name])');
      this._observer = new SlotObserver(slot, ({ addedNodes, removedNodes }) => {
        // Registers the added radio buttons in the reverse order
        // in order for the group to take the value of the most recent button.
        this.__filterRadioButtons(addedNodes).reverse().forEach(this.__registerRadioButton);

        // Unregisters the removed radio buttons.
        this.__filterRadioButtons(removedNodes).forEach(this.__unregisterRadioButton);

        const inputs = this.__radioButtons.map((radio) => radio.inputElement);
        this._tooltipController.setAriaTarget(inputs);
      });

      this.addController(this._tooltipController);
    }

    /**
     * @param {!Array} nodes
     * @return {!Array}
     * @private
     */
    __filterRadioButtons(nodes) {
      return nodes.filter((node) => node.nodeType === Node.ELEMENT_NODE && node.localName === 'vaadin-radio-button');
    }

    /**
     * Override method inherited from `KeyboardMixin`
     * to implement the custom keyboard navigation as a replacement for the native one
     * in order for the navigation to work the same way across different browsers.
     *
     * @param {!KeyboardEvent} event
     * @override
     * @protected
     */
    _onKeyDown(event) {
      super._onKeyDown(event);

      const radioButton = event
        .composedPath()
        .find((node) => node.nodeType === Node.ELEMENT_NODE && node.localName === 'vaadin-radio-button');

      if (['ArrowLeft', 'ArrowUp'].includes(event.key)) {
        event.preventDefault();
        this.__selectNextRadioButton(radioButton);
      }

      if (['ArrowRight', 'ArrowDown'].includes(event.key)) {
        event.preventDefault();
        this.__selectPrevRadioButton(radioButton);
      }
    }

    /**
     * Override an observer from `FieldMixin`.
     *
     * @param {boolean} invalid
     * @protected
     * @override
     */
    _invalidChanged(invalid) {
      super._invalidChanged(invalid);

      if (invalid) {
        this.setAttribute('aria-invalid', 'true');
      } else {
        this.removeAttribute('aria-invalid');
      }
    }

    /**
     * @param {number} index
     * @private
     */
    __selectNextRadioButton(radioButton) {
      const index = this.__radioButtons.indexOf(radioButton);

      this.__selectIncRadioButton(index, this.isHorizontalRTL ? 1 : -1);
    }

    /**
     * @param {number} index
     * @private
     */
    __selectPrevRadioButton(radioButton) {
      const index = this.__radioButtons.indexOf(radioButton);

      this.__selectIncRadioButton(index, this.isHorizontalRTL ? -1 : 1);
    }

    /**
     * @param {number} index
     * @param {number} step
     * @private
     */
    __selectIncRadioButton(index, step) {
      const newIndex = (this.__radioButtons.length + index + step) % this.__radioButtons.length;
      const newRadioButton = this.__radioButtons[newIndex];

      if (newRadioButton.disabled) {
        this.__selectIncRadioButton(newIndex, step);
      } else {
        newRadioButton.focusElement.focus();
        newRadioButton.focusElement.click();
      }
    }

    /**
     * Registers the radio button after adding it to the group.
     *
     * @param {!RadioButton} radioButton
     * @private
     */
    __registerRadioButton(radioButton) {
      radioButton.name = this._fieldName;
      radioButton.addEventListener('checked-changed', this.__onRadioButtonCheckedChange);

      if (this.disabled || this.readonly) {
        radioButton.disabled = true;
      }

      if (radioButton.checked) {
        this.__selectRadioButton(radioButton);
      }
    }

    /**
     * Unregisters the radio button before removing it from the group.
     *
     * @param {!RadioButton} radioButton
     * @private
     */
    __unregisterRadioButton(radioButton) {
      radioButton.removeEventListener('checked-changed', this.__onRadioButtonCheckedChange);

      if (radioButton.value === this.value) {
        this.__selectRadioButton(null);
      }
    }

    /**
     * @param {!CustomEvent} event
     * @private
     */
    __onRadioButtonCheckedChange(event) {
      if (event.target.checked) {
        this.__selectRadioButton(event.target);
      }
    }

    /**
     * Whenever the user sets a non-empty value,
     * the method tries to select the radio button with that value
     * showing a warning if no radio button was found with the given value.
     * If the new value is empty, the method deselects the currently selected radio button.
     * At last, the method toggles the `has-value` attribute considering the new value.
     *
     * @param {string | null | undefined} newValue
     * @param {string | null | undefined} oldValue
     * @private
     */
    __valueChanged(newValue, oldValue) {
      if (oldValue === undefined && newValue === '') {
        return;
      }

      if (newValue) {
        const newSelectedRadioButton = this.__radioButtons.find((radioButton) => {
          return radioButton.value === newValue;
        });

        if (newSelectedRadioButton) {
          this.__selectRadioButton(newSelectedRadioButton);
          this.toggleAttribute('has-value', true);
        } else {
          console.warn(`The radio button with the value "${newValue}" was not found.`);
        }
      } else {
        this.__selectRadioButton(null);
        this.removeAttribute('has-value');
      }

      if (oldValue !== undefined) {
        this.validate();
      }
    }

    /**
     * Whenever `readonly` property changes on the group element,
     * the method updates the `disabled` property for the radio buttons.
     *
     * @param {boolean} newValue
     * @param {boolean} oldValue
     * @private
     */
    __readonlyChanged(newValue, oldValue) {
      // Prevent updating the `disabled` property for the radio buttons at initialization.
      // Otherwise, the group's radio buttons may end up enabled regardless
      // an intentionally added `disabled` attribute on some of them.
      if (!newValue && oldValue === undefined) {
        return;
      }

      if (oldValue !== newValue) {
        this.__updateRadioButtonsDisabledProperty();
      }
    }

    /**
     * Override method inherited from `DisabledMixin`
     * to update the `disabled` property for the radio buttons
     * whenever the property changes on the group element.
     *
     * @param {boolean} newValue
     * @param {boolean} oldValue
     * @override
     * @protected
     */
    _disabledChanged(newValue, oldValue) {
      super._disabledChanged(newValue, oldValue);

      // Prevent updating the `disabled` property for the radio buttons at initialization.
      // Otherwise, the group's radio buttons may end up enabled regardless
      // an intentionally added `disabled` attribute on some of them.
      if (!newValue && oldValue === undefined) {
        return;
      }

      if (oldValue !== newValue) {
        this.__updateRadioButtonsDisabledProperty();
      }
    }

    /**
     * Override method inherited from `FocusMixin`
     * to prevent removing the `focused` attribute
     * when focus moves between radio buttons inside the group.
     *
     * @param {!FocusEvent} event
     * @return {boolean}
     * @protected
     */
    _shouldRemoveFocus(event) {
      return !this.contains(event.relatedTarget);
    }

    /**
     * Override method inherited from `FocusMixin`
     * to run validation when the group loses focus.
     *
     * @param {boolean} focused
     * @override
     * @protected
     */
    _setFocused(focused) {
      super._setFocused(focused);

      // Do not validate when focusout is caused by document
      // losing focus, which happens on browser tab switch.
      if (!focused && document.hasFocus()) {
        this.validate();
      }
    }

    /**
     * @param {RadioButton} radioButton
     * @private
     */
    __selectRadioButton(radioButton) {
      if (radioButton) {
        this.value = radioButton.value;
      } else {
        this.value = '';
      }

      this.__radioButtons.forEach((button) => {
        button.checked = button === radioButton;
      });

      if (this.readonly) {
        this.__updateRadioButtonsDisabledProperty();
      }
    }

    /**
     * If the group is read-only, the method disables the unchecked radio buttons.
     * Otherwise, the method propagates the group's `disabled` property to the radio buttons.
     *
     * @private
     */
    __updateRadioButtonsDisabledProperty() {
      this.__radioButtons.forEach((button) => {
        if (this.readonly) {
          // The native radio button doesn't support the `readonly` attribute
          // so the state can be only imitated, by disabling unchecked radio buttons.
          button.disabled = button !== this.__selectedRadioButton;
        } else {
          button.disabled = this.disabled;
        }
      });
    }
  };




© 2015 - 2024 Weber Informatics LLC | Privacy Policy