coral-spectrum/coral-component-actionbar/src/scripts/BaseActionBarContainer.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 ActionBarContainerCollection from './ActionBarContainerCollection';
import {Button} from '../../../coral-component-button';
import '../../../coral-component-anchorbutton';
import moreOverlay from '../templates/moreOverlay';
import moreButton from '../templates/moreButton';
import overlayContent from '../templates/overlayContent';
import {commons, transform, i18n} from '../../../coral-utils';
// Matches private Coral classes in class attribute
const REG_EXP = /_coral([^\s]+)/g;
const copyAttributes = (from, to) => {
const excludedAttributes = ['is', 'id', 'variant', 'size'];
for (let i = 0 ; i < from.attributes.length ; i++) {
const attr = from.attributes[i];
if (excludedAttributes.indexOf(attr.nodeName) === -1) {
if (attr.nodeName === 'class') {
// Filter out private Coral classes
to.setAttribute(attr.nodeName, `${to.className} ${attr.nodeValue.replace(REG_EXP, '')}`);
} else {
to.setAttribute(attr.nodeName, attr.nodeValue);
}
}
}
};
/**
@base BaseActionBarContainer
@classdesc The base element for action bar containers
*/
class BaseActionBarContainer extends superClass {
/** @ignore */
constructor() {
super();
// Templates
this._elements = {};
this._itemsInPopover = [];
moreButton.call(this._elements, {i18n});
moreOverlay.call(this._elements, {commons});
overlayContent.call(this._elements, {
items: this._itemsInPopover,
copyAttributes
});
// Return focus to overlay by default
this._elements.overlay.focusOnShow = this._elements.overlay;
const overlayId = this._elements.overlay.id;
const events = {};
events[`global:capture:coral-overlay:beforeopen #${overlayId}`] = '_onOverlayBeforeOpen';
events[`global:capture:coral-overlay:beforeclose #${overlayId}`] = '_onOverlayBeforeClose';
// Keyboard interaction
events[`global:key:down #${overlayId}`] = '_onOverlayKeyDown';
events[`global:key:up #${overlayId}`] = '_onOverlayKeyUp';
// Events
this._delegateEvents(events);
// Init the collection mutation observer
this.items._startHandlingItems(true);
}
/**
Returns the inner overlay to allow customization.
@type {Popover}
@readonly
*/
get overlay() {
return this._elements.overlay;
}
/**
The Collection Interface that allows interacting with the items that the component contains.
@type {ActionBarContainerCollection}
@readonly
*/
get items() {
// Construct the collection on first request:
if (!this._items) {
this._items = new ActionBarContainerCollection({
host: this,
itemTagName: 'coral-actionbar-item',
onItemAdded: this._styleItem
});
}
return this._items;
}
/**
The amount of items that are maximally visible inside the container. Using a value <= 0 will disable this
feature and show as many items as possible.
@type {Number}
@default -1
@htmlattribute threshold
@htmlattributereflected
*/
get threshold() {
return typeof this._threshold === 'number' ? this._threshold : -1;
}
set threshold(value) {
this._threshold = transform.number(value);
this._reflectAttribute('threshold', this._threshold);
}
/**
If there are more ActionBarItems inside the ActionBar than currently can be shown, then a "more" Button with the
following text will be rendered (and some ActionBarItems will be hidden inside of a Popover).
@type {String}
@default ""
@htmlattribute morebuttontext
*/
get moreButtonText() {
return this._moreButtonText || '';
}
set moreButtonText(value) {
this._moreButtonText = transform.string(value);
if (this._elements.moreButton) {
// moreButton might not have been created so far
this._elements.moreButtonLabel.innerHTML = this._moreButtonText;
this._elements.moreButton[this._moreButtonText.trim() === '' ? 'setAttribute' : 'removeAttribute']('title', i18n.get('More'));
}
}
/**
Style item content
*/
_styleItem(item) {
const button = item.querySelector('button[is="coral-button"]') || item.querySelector('a[is="coral-anchorbutton"]');
if (button) {
button.classList.add('_coral-ActionBar-button');
const oldVariant = button.getAttribute('variant');
if (oldVariant === Button.variant.ACTION || oldVariant === Button.variant.QUIET_ACTION) {
return;
}
button.setAttribute('variant', oldVariant === Button.variant.QUIET ? Button.variant.QUIET_ACTION : Button.variant.ACTION);
}
}
/**
Called after popover.open is set to true, but before the transition of the popover is done. Show elements inside
the actionbar, that are hidden due to space problems.
@ignore
*/
_onOverlayBeforeOpen(event) {
// there might be popovers in popover => ignore them
if (event.target !== this._elements.overlay) {
return;
}
this._itemsInPopover = this.items._getAllOffScreen();
if (this._itemsInPopover.length < 1) {
return;
}
// Set focus to first focusable descendant of the overlay by default
this._elements.overlay.focusOnShow = 'on';
this._itemsInPopover.forEach((item) => {
item.style.visibility = '';
// Store the button and popover on the item
item._button = item.querySelector(':scope > button[is="coral-button"]') || item.querySelector(':scope > a[is="coral-anchorbutton"]');
item._popover = item.querySelector('coral-popover');
if (item._popover) {
item._popoverId = item._popover.id;
}
});
// Whether a ButtonList or AnchorList should be rendered
this._itemsInPopover.isButtonList = this._itemsInPopover.every(item => item._button && item._button.tagName === 'BUTTON');
this._itemsInPopover.isAnchorList = this._itemsInPopover.every(item => item._button && item._button.tagName === 'A');
// show the current popover (hidden needed to disable fade time of popover)
this._elements.overlay.hidden = false;
// render popover content
const popover = this._elements.overlay;
popover.content.innerHTML = '';
popover.content.appendChild(overlayContent.call(this._elements, {
items: this._itemsInPopover,
copyAttributes
}));
}
/**
Called after popover.open is set to false, but before the transition of the popover is done.
Make items visible again, that now do fit into the actionbar.
@ignore
*/
_onOverlayBeforeClose(event) {
// there might be popovers in popover => ignore them
if (event.target !== this._elements.overlay) {
return;
}
const focusedItem = document.activeElement.parentNode;
// we need to check if item has 'hasAttribute' because it is not present on the document
const isFocusedItemInsideActionBar = this.parentNode.contains(focusedItem);
const isFocusedItemOffscreen = focusedItem.hasAttribute && focusedItem.hasAttribute('coral-actionbar-offscreen');
if (isFocusedItemInsideActionBar && isFocusedItemOffscreen) {
// if currently an element is focused, that should not be visible (or is no actionbar-item) => select 'more'
// button
this._elements.moreButton.focus();
}
// hide the popover(needed to disable fade time of popover)
this._elements.overlay.hidden = true;
this._elements.overlay.focusOnShow = this._elements.overlay;
// close any popovers, that might be inside the 'more' popover
const childPopovers = this._elements.overlay.getElementsByTagName('coral-popover');
for (let i = 0 ; i < childPopovers.length ; i++) {
childPopovers[i].open = false;
}
// return all elements from popover
this._returnElementsFromPopover();
// clear cached items from popover
this._itemsInPopover = [];
// clear overlay
this._elements.overlay.content.innerHTML = '';
}
_onOverlayKeyDown(event) {
event.preventDefault();
// Focus first item
this._elements.anchorList && this._elements.anchorList._focusFirstItem(event);
this._elements.buttonList && this._elements.buttonList._focusFirstItem(event);
}
_onOverlayKeyUp(event) {
event.preventDefault();
// Focus last item
this._elements.anchorList && this._elements.anchorList._focusLastItem(event);
this._elements.buttonList && this._elements.buttonList._focusLastItem(event);
}
static get _attributePropertyMap() {
return commons.extend(super._attributePropertyMap, {
morebuttontext: 'moreButtonText'
});
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat(['morebuttontext', 'threshold']);
}
/** @ignore */
connectedCallback() {
super.connectedCallback();
const overlay = this._elements.overlay;
// Cannot be open by default when rendered
overlay.removeAttribute('open');
// Restore in DOM
if (overlay._parent) {
overlay._parent.appendChild(overlay);
}
}
/** @ignore */
render() {
super.render();
// Cleanup resize helpers object (cloneNode support)
const resizeHelpers = this.getElementsByTagName('object');
for (let i = 0 ; i < resizeHelpers.length ; ++i) {
const resizeElement = resizeHelpers[i];
if (resizeElement.parentNode === this) {
this.removeChild(resizeElement);
}
}
// Cleanup 'More' button
const more = this.querySelector('[coral-actionbar-more]');
if (more) {
this.removeChild(more);
}
// Cleanup 'More' popover
const popover = this.querySelector('[coral-actionbar-popover]');
if (popover) {
this.removeChild(popover);
}
// Copy more text
this._elements.moreButton.label.textContent = this.moreButtonText;
// Init 'More' popover
this._elements.overlay.target = this._elements.moreButton;
// Create empty frag
const frag = document.createDocumentFragment();
// 'More' button might be moved later in dom when Container is attached to parent
frag.appendChild(this._elements.moreButton);
frag.appendChild(this._elements.overlay);
// Render template
this.appendChild(frag);
// Style the items to match action items
this.items.getAll().forEach(item => this._styleItem(item));
}
/** @ignore */
disconnectedCallback() {
super.disconnectedCallback();
const overlay = this._elements.overlay;
// In case it was moved out don't forget to remove it
if (!this.contains(overlay)) {
overlay._parent = overlay._repositioned ? document.body : this;
overlay.remove();
}
}
};
export default BaseActionBarContainer;