coral-spectrum/coral-component-buttongroup/src/scripts/ButtonGroup.js
/**
* Copyright 2019 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {BaseComponent} from '../../../coral-base-component';
import {BaseFormField} from '../../../coral-base-formfield';
import {Button} from '../../../coral-component-button';
import {SelectableCollection} from '../../../coral-collection';
import base from '../templates/base';
import {transform, validate, commons} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';
/**
Enumeration for {@link ButtonGroup} selection options.
@typedef {Object} ButtonGroupSelectionModeEnum
@property {String} NONE
None is default, selection of buttons doesn't happen based on click.
@property {String} SINGLE
Single selection mode, button group behaves like radio input elements.
@property {String} MULTIPLE
Multiple selection mode, button group behaves like checkbox input elements.
*/
const selectionMode = {
NONE: 'none',
SINGLE: 'single',
MULTIPLE: 'multiple'
};
/** @const Selector used to recognized an item of the ButtonGroup */
const ITEM_SELECTOR = 'button[is="coral-button"]';
/**
Extracts the value from the item in case no explicit value was provided.
@param {HTMLElement} item
the item whose value will be extracted.
@returns {String} the value that will be submitted for this item.
@private
*/
const itemValueFromDOM = function (item) {
const attr = item.getAttribute('value');
// checking explicitely for null allows to differenciate between non set values and empty strings
return attr !== null ? attr : item.textContent.replace(/\s{2,}/g, ' ').trim();
};
const CLASSNAME = '_coral-ButtonGroup';
/**
@class Coral.ButtonGroup
@classdesc A ButtonGroup component that can be used as a selection form field.
@htmltag coral-buttongroup
@extends {HTMLElement}
@extends {BaseComponent}
@extends {BaseFormField}
*/
const ButtonGroup = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) {
/** @ignore */
constructor() {
super();
// Store template
this._elements = {};
base.call(this._elements);
// Pre-define labellable element
this._labellableElement = this;
// save initial selection (used for reset)
this._initalSelectedValues = [];
// Attach events
this._delegateEvents(commons.extend(this._events, {
'click button[is="coral-button"]': '_onButtonClick',
'capture:focus button[is="coral-button"]': '_onButtonFocus',
'capture:blur button[is="coral-button"]': '_onButtonBlur',
'key:up button[is="coral-button"]': '_onButtonKeyUpLeft',
'key:left button[is="coral-button"]': '_onButtonKeyUpLeft',
'key:down button[is="coral-button"]': '_onButtonKeyDownRight',
'key:right button[is="coral-button"]': '_onButtonKeyDownRight',
'key:home button[is="coral-button"]': '_onButtonKeyHome',
'key:end button[is="coral-button"]': '_onButtonKeyEnd',
'coral-button:_valuechanged button[is="coral-button"]': '_onButtonValueChanged',
'coral-button:_selectedchanged button[is="coral-button"]': '_onButtonSelectedChanged'
}));
// Init the mutation observer but we don't handle the initial items in the constructor
this.items._startHandlingItems(true);
}
/**
The Collection Interface that allows interacting with the items that the component contains.
@type {SelectableCollection}
@readonly
*/
get items() {
// we do lazy initialization of the collection
if (!this._items) {
this._items = new SelectableCollection({
host: this,
itemBaseTagName: 'button',
itemTagName: 'coral-button',
itemSelector: ITEM_SELECTOR,
onlyHandleChildren: true,
onItemAdded: this._onItemAdded,
onItemRemoved: this._onItemRemoved,
onCollectionChange: this._onCollectionChange
});
}
return this._items;
}
/**
Selection mode of Button group
@type {String}
@default ButtonGroupSelectionModeEnum.NONE
@htmlattribute selectionmode
@htmlattributereflected
*/
get selectionMode() {
return this._selectionMode || selectionMode.NONE;
}
set selectionMode(value) {
value = transform.string(value).toLowerCase();
this._selectionMode = validate.enumeration(selectionMode)(value) && value || selectionMode.NONE;
this._reflectAttribute('selectionmode', this._selectionMode);
// update select element if multiple
// this is required while appplying default selection
// if selection mode is single first elem gets selected but for multiple its not
this._elements.nativeSelect.multiple = this._selectionMode === selectionMode.MULTIPLE;
// Sync
if (this._selectionMode === selectionMode.SINGLE) {
this.setAttribute('role', 'radiogroup');
// makes sure the internal options are properly initialized
this._syncItemOptions();
// we make sure the selection is valid by explicitly finding a candidate or making sure just 1 item is
// selected
this._validateSelection();
} else if (this._selectionMode === selectionMode.MULTIPLE) {
this.setAttribute('role', 'group');
// makes sure the internal options are properly initialized
this._syncItemOptions();
} else {
this.setAttribute('role', 'group');
this._removeItemOptions();
}
}
/**
Name used to submit the data in a form.
@type {String}
@default ""
@htmlattribute name
@htmlattributereflected
*/
get name() {
return this._elements.nativeSelect.name;
}
set name(value) {
this._reflectAttribute('name', value);
this._elements.nativeSelect.name = value;
}
/**
This field's current value.
@type {String}
@default ""
@htmlattribute value
*/
get value() {
return this._elements.nativeSelect.value;
}
set value(value) {
if (this.selectionMode === selectionMode.NONE) {
return;
}
// we proceed to select the provided value
this._selectItemByValue([value]);
}
/**
Returns an Array containing the selected buttons.
@type {Array.<HTMLElement>}
@readonly
*/
get selectedItems() {
if (this.selectionMode === selectionMode.MULTIPLE) {
return this.items._getAllSelected();
}
const item = this.selectedItem;
return item ? [item] : [];
}
/**
Returns the first selected button in the Button Group. The value <code>null</code> is returned if no button is
selected.
@type {HTMLElement}
@readonly
*/
get selectedItem() {
return this.selectionMode === selectionMode.MULTIPLE ?
this.items._getFirstSelected() :
this.items._getLastSelected();
}
/**
Current selected values as submitted during form submission.
@type {Array.<String>}
*/
get values() {
const values = [];
// uses the nativeSelect since it holds the truth of what will be submitted with the form
const selectedOptions = this._elements.nativeSelect.querySelectorAll(':checked');
for (let i = 0, selectedOptionsCount = selectedOptions.length ; i < selectedOptionsCount ; i++) {
values.push(selectedOptions[i].value);
}
return values;
}
set values(values) {
if (Array.isArray(values) && this.selectionMode !== selectionMode.NONE) {
// just keeps the first value if selectionMode is not multiple
if (this.selectionMode !== selectionMode.MULTIPLE && values.length > 1) {
values = [values[0]];
}
// we proceed to select the provided values
this._selectItemByValue(values);
}
}
/**
Whether this field is disabled or not.
@type {Boolean}
@default false
@htmlattribute disabled
@htmlattributereflected
*/
get disabled() {
return this._disabled || false;
}
set disabled(value) {
this._disabled = transform.booleanAttr(value);
this._reflectAttribute('disabled', this._disabled);
const isDisabled = this.disabled || this.readOnly;
this._elements.nativeSelect.disabled = isDisabled;
// Also update for all the items the disabled property so it matches the native select.
this.items.getAll().forEach((item) => {
item.disabled = isDisabled;
});
this[isDisabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', isDisabled);
}
/**
Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
@type {Boolean}
@default false
@htmlattribute readonly
@htmlattributereflected
*/
get readOnly() {
return this._readOnly || false;
}
set readOnly(value) {
this._readOnly = transform.booleanAttr(value);
this._reflectAttribute('readonly', this._readOnly);
this._elements.nativeSelect.disabled = this.readOnly || this.disabled;
// Also update for all the items the disabled property so it matches the native select.
this.items.getAll().forEach((item) => {
item.disabled = this.disabled || this.readOnly && !item.hasAttribute('selected');
item[this.readOnly ? 'setAttribute' : 'removeAttribute']('aria-disabled', true);
});
// aria-readonly is not permitted on elements with role="radiogroup" or role="group"
this.removeAttribute('aria-readonly');
}
/**
Whether this field is required or not.
@type {Boolean}
@default false
@htmlattribute required
@htmlattributereflected
*/
get required() {
return this._required || false;
}
set required(value) {
this._required = transform.booleanAttr(value);
this._reflectAttribute('required', this._required);
this._elements.nativeSelect.required = this.required;
// aria-required is permitted on elements with role="radiogroup" but not with role="group"
if (this.selectionMode !== selectionMode.SINGLE) {
this.removeAttribute('aria-required');
}
}
/**
Inherited from {@link BaseFormField#labelledBy}.
*/
get labelledBy() {
return super.labelledBy;
}
set labelledBy(value) {
super.labelledBy = value;
this._elements.nativeSelect.setAttribute('aria-labelledby', this.labelledBy);
}
/**
Inherited from {@link BaseFormField#reset}.
*/
reset() {
// reset the values to the initial values
this.values = this._initalSelectedValues;
}
/** @private */
_onButtonClick(event) {
// uses matchTarget to make sure the buttons is handled and not an internal component
const item = event.matchedTarget;
this._onButtonFocus(event);
if (this.readOnly) {
event.preventDefault();
event.stopImmediatePropagation();
return;
}
if (this.selectionMode === selectionMode.SINGLE) {
// prevent event only if selectionMode is not of type none
event.preventDefault();
// first unselect the other element
const selectedItems = this.items._getAllSelected();
// we deselect the previously selected item
if (selectedItems.length !== 0 && selectedItems[0] !== item) {
this._toggleItemSelection(selectedItems[0], false);
}
// forces the selection on the clicked item
this._toggleItemSelection(item, true);
// if the same button was clicked we do not need to trigger an event
if (selectedItems[0] !== item) {
this.trigger('change');
}
} else if (this.selectionMode === selectionMode.MULTIPLE) {
// prevent event only if selectionMode is not of type none
event.preventDefault();
this._toggleItemSelection(item);
// since we toggle the selection we always trigger a change event
this.trigger('change');
}
}
/** @private */
_onButtonFocus(event) {
const item = event.matchedTarget;
const buttons = this.items.getAll();
const buttonsCount = buttons.length;
let button;
for (let i = 0 ; i < buttonsCount ; i++) {
// stores the reference
button = buttons[i];
button.setAttribute('tabindex', button === item ? 0 : -1);
}
}
/** @private */
_onButtonBlur(event) {
const item = event.matchedTarget;
const buttons = this.items.getAll();
const buttonsCount = buttons.length;
let button;
let tabindex;
const selectedItemsLength = this.selectedItems.length;
const firstSelectable = this.items._getFirstSelectable();
let isSelected = false;
for (let i = 0 ; i < buttonsCount ; i++) {
// stores the reference
button = buttons[i];
isSelected = button.hasAttribute('selected');
if (this.selectionMode === selectionMode.SINGLE) {
// selected item should be tabbable
tabindex = isSelected ? 0 : -1;
} else if (this.selectionMode === selectionMode.MULTIPLE) {
tabindex =
// if no items are selected, first item should be tabbable
!selectedItemsLength && i === 0 ||
// if the element losing focus is selected, it should be tabbable
isSelected && button === item ||
// if the element losing focus is not selected, the last selected item should be tabbable
!item.hasAttribute('selected') &&
button === (this.selectedItems[selectedItemsLength - 1] || firstSelectable) ? 0 : -1;
} else {
// first item should be tabbable
tabindex = button === firstSelectable ? 0 : -1;
}
button.setAttribute('tabindex', tabindex);
}
}
/** @private */
_onButtonKeyUpLeft(event) {
event.preventDefault();
const item = event.matchedTarget;
let button = item.previousElementSibling;
// skip disabled items
while (!button || (button.disabled || button.nodeName !== 'BUTTON')) {
if (!button) {
button = this.items._getLastSelectable();
} else {
button = button.previousElementSibling;
}
}
if (button !== item) {
if (this.selectionMode === selectionMode.SINGLE) {
button.click();
}
this._setFocusToButton(button);
}
}
/** @private */
_onButtonKeyDownRight(event) {
event.preventDefault();
const item = event.matchedTarget;
let button = item.nextElementSibling;
// skip disabled items
while (!button || (button.disabled || button.nodeName !== 'BUTTON')) {
if (!button) {
button = this.items._getFirstSelectable();
} else {
button = button.nextElementSibling;
}
}
if (button !== item) {
if (this.selectionMode === selectionMode.SINGLE) {
button.click();
}
this._setFocusToButton(button);
}
}
/** @private */
_onButtonKeyHome(event) {
event.preventDefault();
const item = event.matchedTarget;
const button = this.items._getFirstSelectable();
if (button !== item) {
if (this.selectionMode === selectionMode.SINGLE) {
button.click();
}
this._setFocusToButton(button);
}
}
/** @private */
_onButtonKeyEnd(event) {
event.preventDefault();
const item = event.matchedTarget;
const button = this.items._getLastSelectable();
if (button !== item) {
if (this.selectionMode === selectionMode.SINGLE) {
button.click();
}
this._setFocusToButton(button);
}
}
/** @private */
_setFocusToButton(button) {
if (button) {
button.focus();
}
}
/** @private */
_onItemAdded(item) {
// Store variant to be able to reset it when item is removed
item._initialVariant = item._initialVariant || item.variant;
// Force action variant
if (!(item.variant === Button.variant.ACTION || item.variant === Button.variant.QUIET_ACTION)) {
item.variant = item.variant === Button.variant.QUIET ? Button.variant.QUIET_ACTION : Button.variant.ACTION;
}
if (this.selectionMode !== selectionMode.NONE) {
if (this.selectionMode === selectionMode.SINGLE) {
item.setAttribute('role', 'radio');
item.setAttribute('tabindex', item.hasAttribute('selected') ? 0 : -1);
} else {
item.setAttribute('role', 'checkbox');
}
item.setAttribute('aria-checked', item.hasAttribute('selected'));
} else {
item.removeAttribute('role');
}
item.disabled = this.disabled || this.readOnly && !item.hasAttribute('selected');
item[this.readOnly ? 'setAttribute' : 'removeAttribute']('aria-disabled', true);
this._addItemOption(item);
// Handle the case where we might have multiple items selected while single selection mode is on
if (this.selectionMode === selectionMode.SINGLE) {
const selectedItems = this.items._getAllSelected();
// The last added item will stay selected
if (selectedItems.length > 1 && item.hasAttribute('selected')) {
item.removeAttribute('selected');
}
}
}
/** @private */
_onItemRemoved(item) {
// Restore variant
item.variant = item._initialVariant;
item._initialVariant = undefined;
item.removeAttribute('role');
if (!item.parentNode) {
// Remove the item from the initial selected values
const index = this._initalSelectedValues.indexOf(item.value);
if (index !== -1) {
this._initalSelectedValues.splice(index, 1);
}
}
// delete option
if (item.option) {
item.option.parentNode.removeChild(item.option);
item.option = undefined;
}
}
/** @private */
_onCollectionChange() {
// we need to make sure that the state of the selectionMode is valid
this._validateSelection();
}
/** @private */
_onButtonSelectedChanged(event) {
event.stopImmediatePropagation();
const button = event.target;
const isSelected = button.hasAttribute('selected');
// when in single mode, we need to make sure the current selection is valid
if (this.selectionMode === selectionMode.SINGLE) {
this._validateSelection(isSelected ? button : null);
} else {
// we simply toggle the selection
this._toggleItemSelection(button, isSelected);
}
}
/** @private */
_onButtonValueChanged(event) {
event.stopImmediatePropagation();
const button = event.target;
// Make sure option is attached before setting the value
if (this.selectionMode !== selectionMode.NONE) {
button.option.value = itemValueFromDOM(button);
}
}
/**
Toggles the selected state of the item. When <code>selected</code> is provided, it is set as the current state. If
the value is ommited, then the selected is toggled.
@param {HTMLElement} item
Item whose selection needs to be updated.
@param {Boolean} [selected]
Whether the item is selected. If it is not provided, then it is toggled.
@private
*/
_toggleItemSelection(item, selected) {
const ariaCheckedAttr = item.getAttribute('aria-checked');
const tabIndexAttr = item.getAttribute('tabindex');
// if selected is provided it is used to enforce the selection, otherwise we toggle the current state
selected = typeof selected !== 'undefined' ? selected : !item.hasAttribute('selected');
// only manipulates the attributes when necessary to avoid unnecessary mutations
if (selected) {
if (!item.hasAttribute('selected')) {
item.setAttribute('selected', '');
}
if (ariaCheckedAttr !== 'true') {
item.setAttribute('aria-checked', true);
}
if (this.selectionMode === selectionMode.SINGLE && tabIndexAttr !== '0') {
item.setAttribute('tabindex', 0);
}
} else if (!selected) {
if (item.hasAttribute('selected')) {
item.removeAttribute('selected');
}
if (this.selectionMode !== selectionMode.NONE) {
if (ariaCheckedAttr !== 'false') {
item.setAttribute('aria-checked', false);
}
if (this.selectionMode === selectionMode.SINGLE && tabIndexAttr !== '-1') {
item.setAttribute('tabindex', -1);
}
} else {
item.removeAttribute('aria-checked');
item.removeAttribute('tabindex');
}
}
// if element.option is present - absent when selection mode changed to none
if (item.option) {
item.option.selected = selected;
}
}
_selectItemByValue(values) {
// queries all the buttons to change their selection state
const buttons = this.items.getAll();
let item;
for (let i = 0, buttonsCount = buttons.length ; i < buttonsCount ; i++) {
// stores the reference
item = buttons[i];
// if the value is inside the new values array it should be selected
this._toggleItemSelection(item, values.indexOf(itemValueFromDOM(item)) !== -1);
}
}
/** @private */
_setInitialValues() {
if (this.selectionMode !== selectionMode.NONE) {
const selectedItems = this.selectedItems;
for (let i = 0, selectedItemsCount = selectedItems.length ; i < selectedItemsCount ; i++) {
// Store _initalSelectedValues for reset
this._initalSelectedValues.push(selectedItems[i].value);
// Same goes for native select
this._addItemOption(selectedItems[i]);
}
}
}
/** @private */
_addItemOption(item) {
if (this.selectionMode === selectionMode.NONE) {
return;
}
// if already attached return
if (item.option) {
return;
}
const option = document.createElement('option');
option.value = itemValueFromDOM(item);
if (item.hasAttribute('selected')) {
option.setAttribute('selected', '');
}
// add it to DOM. In single selectionMode the first item gets selected automatically
item.option = option;
this._elements.nativeSelect.add(option);
// we make sure the options reflect the state of the button
this._toggleItemSelection(item, item.hasAttribute('selected'));
}
/** @private */
_removeItemOptions() {
// Find all buttons and try attaching corresponding option elem
const buttons = this.items.getAll();
let item;
for (let i = 0, buttonsCount = buttons.length ; i < buttonsCount ; i++) {
// stores the reference
item = buttons[i];
item.removeAttribute('role');
item.removeAttribute('aria-checked');
// single we are removing the options, selection must also go away
if (item.hasAttribute('selected')) {
this._toggleItemSelection(item, false);
}
// we clear the related option element
if (item.option) {
item.option.parentNode.removeChild(item.option);
delete item.option;
}
}
}
/** @private */
_syncItemOptions() {
// finds all buttons and try attaching corresponding option elem
const buttons = this.items.getAll();
const buttonsCount = buttons.length;
let i = 0;
let role = null;
if (this.selectionMode === selectionMode.SINGLE) {
role = 'radio';
} else if (this.selectionMode === selectionMode.MULTIPLE) {
role = 'checkbox';
}
let button;
let isSelected = false;
for (i ; i < buttonsCount ; i++) {
// try attaching corresponding input element
this._addItemOption(buttons[i]);
}
// We need to set the right state for the native select AFTER all buttons have been added
// (as we can't disable options while there is only one option attached [at least in FF])
for (i = buttonsCount - 1 ; i >= 0 ; i--) {
button = buttons[i];
isSelected = button.hasAttribute('selected');
button.option.selected = isSelected;
button.setAttribute('aria-checked', isSelected);
if (role) {
button.setAttribute('role', role);
} else {
button.removeAttribute('role');
}
}
}
/** @private */
_validateSelection(item) {
// when selectionMode = single, we need to force a selection
if (this.selectionMode === selectionMode.SINGLE) {
// gets the current selection
const selection = this.items._getAllSelected();
const selectionCount = selection.length;
// if no item is currently selected, we need to find a candidate
if (selectionCount === 0) {
// gets the first candidate for selection
const selectable = this.items._getFirstSelectable();
if (selectable) {
this._toggleItemSelection(selectable, true);
}
}
// more items are selected, so we find a single item and deselect everything else
else if (selectionCount > 1) {
// if no item was provided we force the selection on the first item
item = item || selection[0];
// we make sure the item is selected, this is important to match the options with the selection
this._toggleItemSelection(item, true);
for (let i = 0 ; i < selectionCount ; i++) {
if (selection[i] !== item) {
this._toggleItemSelection(selection[i], false);
}
}
}
}
}
/**
Returns {@link ButtonGroup} selection options.
@return {ButtonGroupSelectionModeEnum}
*/
static get selectionMode() {
return selectionMode;
}
static get _attributePropertyMap() {
return commons.extend(super._attributePropertyMap, {
selectionmode: 'selectionMode'
});
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat([
'selectionmode',
'selectionMode'
]);
}
/** @ignore */
render() {
super.render();
this.classList.add(CLASSNAME);
// Default reflected attributes
if (!this._selectionMode) {
this.selectionMode = selectionMode.NONE;
}
// Create a fragment
const frag = document.createDocumentFragment();
// Render the template
frag.appendChild(this._elements.nativeSelect);
// Clean up
while (this.firstChild) {
const child = this.firstChild;
if (child.nodeType === Node.TEXT_NODE ||
child.getAttribute('handle') !== 'nativeSelect') {
// Add non-template elements to the content
frag.appendChild(child);
} else {
// Remove anything else
this.removeChild(child);
}
}
// Append the fragment to the component
this.appendChild(frag);
// Need to store and set the initially selected values in the native select so that it can reset
this._setInitialValues();
// Call onItemAdded and onCollectionChange on the existing items
this.items._startHandlingItems();
}
});
export default ButtonGroup;