coral-spectrum/coral-component-actionbar/src/scripts/ActionBar.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 {Decorator} from '../../../coral-decorator';
import '../../../coral-component-popover';
import getFirstSelectableWrappedItem from './getFirstSelectableWrappedItem';
import {commons} from '../../../coral-utils';
const CLASSNAME = '_coral-ActionBar';
/**
@class Coral.ActionBar
@classdesc An ActionBar component containing arbitrary items. An item can either be added to the left or the right side
of the bar. All items that do not fit into the bar are hidden but still accessible.
@htmltag coral-actionbar
@extends {HTMLElement}
@extends {BaseComponent}
*/
const ActionBar = Decorator(class extends BaseComponent(HTMLElement) {
/** @ignore */
constructor() {
super();
// Attach events
this._delegateEvents({
'key:up': '_onFocusPreviousItem',
'key:left': '_onFocusPreviousItem',
'key:down': '_onFocusNextItem',
'key:right': '_onFocusNextItem',
'global:resize': '_onResizeWindow'
});
// Prepare templates
this._elements = {
// Fetch or create the content zone elements
primary: this.querySelector('coral-actionbar-primary') || document.createElement('coral-actionbar-primary'),
secondary: this.querySelector('coral-actionbar-secondary') || document.createElement('coral-actionbar-secondary')
};
// Reference on all items
this._items = this.getElementsByTagName('coral-actionbar-item');
// Debounce wait time in milliseconds
this._wait = 50;
// bind this._onLayout so it can be removed again
this._onLayout = this._onLayout.bind(this);
this._debounceOnLayout = this._debounceOnLayout.bind(this);
// use the smart strategy instead of re-rendering every frame
this._recalculateLayoutOnMutation();
}
/**
The primary (left) container of the ActionBar.
@type {ActionBarPrimary}
@contentzone
*/
get primary() {
return this._getContentZone(this._elements.primary);
}
set primary(value) {
this._setContentZone('primary', value, {
handle: 'primary',
tagName: 'coral-actionbar-primary',
insert: function (content) {
// primary has to be before secondary if available
this.insertBefore(content, this.secondary);
}
});
}
/**
The secondary (right) container of the ActionBar.
@type {ActionBarSecondary}
@contentzone
*/
get secondary() {
return this._getContentZone(this._elements.secondary);
}
set secondary(value) {
this._setContentZone('secondary', value, {
handle: 'secondary',
tagName: 'coral-actionbar-secondary',
insert: function (content) {
this.appendChild(content);
}
});
}
/** @ignore */
_recalculateLayoutOnMutation() {
// recalculate layout on dom element size change + on dom mutation
// http://www.backalleycoder.com/2013/03/18/cross-browser-event-based-element-resize-detection/
// relayout any time the dom changes
this._observer = new MutationObserver(() => {
this._debounceOnLayout();
});
// Watch for changes
this._observer.observe(this, {
attributes: true,
childList: true,
characterData: true,
subtree: true
});
}
/** @ignore */
_onFocusPreviousItem(event) {
// stops the page from scrolling
event.preventDefault();
const previousItem = this._getPreviousSelectableWrappedItem(event.target);
if (previousItem !== null) {
previousItem.focus();
}
}
/** @ignore */
_onFocusNextItem(event) {
// stops the page from scrolling
event.preventDefault();
const nextWrappedItem = this._getNextSelectableWrappedItem(event.target);
if (nextWrappedItem !== null) {
nextWrappedItem.focus();
}
}
/** @ignore */
_onResizeWindow() {
// just close all popovers for now when screen is resized
// there might be more popovers, then the 'more' popovers
const popovers = this.getElementsByTagName('coral-popover');
for (let i = 0 ; i < popovers.length ; i++) {
popovers[i].removeAttribute('open');
}
// force a relayout (needed especially if framerate during resize drops e.g.: in FF)
this._debounceOnLayout();
}
/** @ignore */
_onLayout() {
if (!this.primary || !this.primary._elements || !this.primary._elements.overlay ||
!this.secondary || !this.secondary._elements || !this.secondary._elements.overlay) {
// while containers are not cached or no items are rendered do nothing
return;
}
if (this.primary._elements.overlay.open === true || this.secondary._elements.overlay.open === true) {
// while popovers are open do not relayout
return;
}
const ERROR_MARGIN = 78;
const primaryMore = this.primary._elements.moreButton;
const secondaryMore = this.secondary._elements.moreButton;
const leftItems = this.primary.items.getAll();
const rightItems = this.secondary.items.getAll().reverse();
let itemLeft = null;
let itemRight = null;
const widthCache = this._newWidthCache();
const leftMoreButtonWidth = leftItems.length > 0 ? widthCache.getOuterWidth(primaryMore) : 0;
const rightMoreButtonWidth = rightItems.length > 0 ? widthCache.getOuterWidth(secondaryMore) : 0;
// Make it possible to set left/right padding to the containers
const borderWidthLeftContainer = this.primary.offsetWidth - this.primary.getBoundingClientRect().width;
const borderWidthRightContainer = this.secondary.offsetWidth - this.secondary.getBoundingClientRect().width;
const primaryLeftOffset = this.primary.offsetLeft;
const secondaryRightOffset = this.offsetWidth - (this.secondary.offsetLeft + this.secondary.offsetWidth);
let availableWidth = this.offsetWidth - primaryLeftOffset - secondaryRightOffset - leftMoreButtonWidth -
rightMoreButtonWidth - borderWidthLeftContainer - borderWidthRightContainer - ERROR_MARGIN;
let currentUsedWidth = 0;
let leftVisibleItems = 0;
let rightVisibleItems = 0;
let moreButtonLeftVisible = false;
let moreButtonRightVisible = false;
let showItem = false;
let itemWidth = 0;
for (let i = 0 ; i < leftItems.length || i < rightItems.length ; i++) {
itemLeft = i < leftItems.length ? leftItems[i] : null;
itemRight = i < rightItems.length ? rightItems[i] : null;
// first calculate visibility of left item
showItem = false;
if (itemLeft !== null) {
if (itemLeft.hidden || itemLeft.style.display === 'none') {
// item is hidden on purpose (we don't use it for layouting but do also not move offscreen) needed as it
// might already have been moved offscreen before
this._moveToScreen(itemLeft);
} else {
// if item is not hidden on purpose (hiding by actionBar due to space problems does not count) => layout
// element
if (!moreButtonLeftVisible && (this.primary.threshold <= 0 || leftVisibleItems < this.primary.threshold)) {
// if threshold is not reached so far
itemWidth = widthCache.getOuterWidth(itemLeft);
if (currentUsedWidth + itemWidth < availableWidth) {
// if there is still enough space to show another item
showItem = true;
} else if (leftVisibleItems === leftItems.length - 1 &&
currentUsedWidth + itemWidth < availableWidth + leftMoreButtonWidth
) {
// if this is the last item and so far there have been no items hidden => don't show more button
showItem = true;
}
}
if (showItem) {
leftVisibleItems += 1;
currentUsedWidth += itemWidth;
this._moveToScreen(itemLeft);
} else {
this._hideItem(itemLeft);
moreButtonLeftVisible = true;
}
if (leftVisibleItems === leftItems.length) {
// left more button not needed => more free space available
availableWidth += leftMoreButtonWidth;
moreButtonLeftVisible = false;
}
}
}
// then calculate visibility of right item
showItem = false;
if (itemRight !== null) {
if (itemRight.hidden || itemRight.style.display === 'none') {
// item is hidden on purpose (we don't use it for layouting but do also not move offscreen) needed as it
// might already have been moved offscreen before
this._moveToScreen(itemRight);
} else {
// if item is not hidden on purpose (hiding by actionBar due to space problems does not count) => layout
// element
if (!moreButtonRightVisible && (this.secondary.threshold <= 0 || rightVisibleItems < this.secondary.threshold)) {
// if threshold is not reached so far
itemWidth = widthCache.getOuterWidth(itemRight);
if (currentUsedWidth + itemWidth < availableWidth) {
// if there is still enough space to show another item
showItem = true;
} else if (rightVisibleItems === rightItems.length - 1 &&
currentUsedWidth + itemWidth < availableWidth + rightMoreButtonWidth
) {
// if this is the last item and so far there have been no items hidden => don't show more button
showItem = true;
}
}
if (showItem) {
rightVisibleItems += 1;
currentUsedWidth += itemWidth;
this._moveToScreen(itemRight);
} else {
this._hideItem(itemRight);
moreButtonRightVisible = true;
}
if (rightVisibleItems === rightItems.length) {
// left more button not needed => more free space available
availableWidth += rightMoreButtonWidth;
moreButtonRightVisible = false;
}
}
}
}
// Handle tabs
const primarySelectable = this.primary.items._getAllSelectable();
const secondarySelectable = this.secondary.items._getAllSelectable();
for (let i = 0 ; i < this._items.length ; i++) {
this._toggleItemTabbable(this._items[i], false);
}
// LEFT: Show or hide more buttons
if (moreButtonLeftVisible) {
this._moveToScreen(primaryMore, true);
if (primarySelectable.length === 0) {
this._toggleItemTabbable(primaryMore, true);
} else {
this._toggleItemTabbable(primaryMore, false);
this._toggleItemTabbable(primarySelectable[0], true);
}
} else {
this._moveToScreen(primaryMore, false);
this._toggleItemTabbable(primaryMore, false);
this._toggleItemTabbable(primarySelectable[0], true);
}
// RIGHT: Show or hide more buttons
if (moreButtonRightVisible) {
this._moveToScreen(secondaryMore, true);
if (secondarySelectable.length === 0) {
this._toggleItemTabbable(secondaryMore, true);
} else {
this._toggleItemTabbable(secondaryMore, false);
this._toggleItemTabbable(secondarySelectable[0], true);
}
} else {
this._moveToScreen(secondaryMore, false);
this._toggleItemTabbable(secondaryMore, false);
const tabbableItem = this.secondary.items._getAllSelectable()[0];
if (tabbableItem) {
this._toggleItemTabbable(tabbableItem, true);
}
}
// re-calculate layout on element resize
if (!this._resizeListenerAttached) {
commons.addResizeListener(this, this._debounceOnLayout);
commons.addResizeListener(this.primary, this._debounceOnLayout);
commons.addResizeListener(this.secondary, this._debounceOnLayout);
this._resizeListenerAttached = true;
}
}
/** @ignore */
_getNextSelectableWrappedItem(currentItem) {
if (currentItem.parentNode.tagName === 'CORAL-ACTIONBAR-ITEM') {
// currentItem is wrapped
currentItem = currentItem.parentNode;
}
const selectableItems = this._getAllSelectableItems(currentItem);
const length = selectableItems.length;
const index = selectableItems.indexOf(currentItem);
if (index >= 0 && length > index + 1) {
// if there is a next selectable element return it
return getFirstSelectableWrappedItem(selectableItems[index + 1]);
} else {
for (let i = 0 ; i < length ; i++) {
if (selectableItems[i].contains(currentItem) && length > i + 1) {
return getFirstSelectableWrappedItem(selectableItems[i + 1]);
}
}
}
return null;
}
/** @ignore */
_getPreviousSelectableWrappedItem(currentItem) {
if (currentItem.parentNode.tagName === 'CORAL-ACTIONBAR-ITEM') {
// currentItem is wrapped
currentItem = currentItem.parentNode;
}
const selectableItems = this._getAllSelectableItems(currentItem);
const index = selectableItems.indexOf(currentItem);
if (index > 0) {
// if there is a previous selectable element return it
return getFirstSelectableWrappedItem(selectableItems[index - 1]);
} else {
for (let i = 1 ; i < selectableItems.length ; i++) {
if (selectableItems[i].contains(currentItem)) {
return getFirstSelectableWrappedItem(selectableItems[i - 1]);
}
}
}
return null;
}
/** @ignore */
_getAllSelectableItems(currentItem) {
let selectableItems = [];
if (this.primary._elements.overlay.open === true || this.secondary._elements.overlay.open === true) {
// if popover is open only items in popover can be selected
const popoverItems = this.primary._elements.overlay.open === true ? this.primary._itemsInPopover :
this.secondary._itemsInPopover;
let item = null;
for (let i = 0 ; i < popoverItems.length ; i++) {
item = popoverItems[i];
if (!item.hasAttribute('disabled') &&
!item.hasAttribute('hidden') &&
item.style.display !== 'none' &&
getFirstSelectableWrappedItem(item)
) {
selectableItems.push(item);
}
}
} else {
// concat selectable items from left side of the bar and right side of the bar
const leftSelectableItems = this.primary.items._getAllSelectable();
const rightSelectableItems = this.secondary.items._getAllSelectable();
if (currentItem) {
if (this.primary.contains(currentItem)) {
selectableItems = leftSelectableItems;
} else if (this.secondary.contains(currentItem)) {
selectableItems = rightSelectableItems;
}
} else {
selectableItems = leftSelectableItems.concat(rightSelectableItems);
}
}
return selectableItems;
}
/** @ignore */
_newWidthCache() {
return {
_items: [],
_outerWidth: [],
getOuterWidth: function (item) {
let index = this._items.indexOf(item);
if (index < 0) {
// if item was not cached in current frame => cache it
this._items.push(item);
const width = item.offsetWidth;
this._outerWidth.push(width);
index = this._outerWidth.length - 1;
}
return this._outerWidth[index];
}
};
}
/** @ignore */
_forceWebkitRedraw(el) {
const isWebkit = 'WebkitAppearance' in document.documentElement.style;
if (isWebkit && el.style.display !== 'none') {
el.style.display = 'none';
// no need to store this anywhere, the reference would be enough
this._cachedOffsetHeight = el.offsetHeight;
el.style.display = '';
}
}
/** @ignore */
_hideItem(item, hide) {
if (hide === false) {
this._moveToScreen(item);
} else if (!item.hasAttribute('coral-actionbar-offscreen')) {
// actually just move element offscreen to be able to measure the size while calculating the layout
item.setAttribute('coral-actionbar-offscreen', '');
item.style.visibility = 'hidden';
// if I do not force a browser redraw webkit has layouting problems
this._forceWebkitRedraw(item);
}
}
/** @ignore */
_moveToScreen(item, show) {
if (show === false) {
this._hideItem(item);
} else if (item.hasAttribute('coral-actionbar-offscreen')) {
// actually just move element onscreen again (see _hideItem)
item.removeAttribute('coral-actionbar-offscreen');
item.style.visibility = '';
// if I do not force a browser redraw webkit has layouting problems
this._forceWebkitRedraw(item);
}
}
/** @ignore */
_toggleItemTabbable(item, tabbable) {
this._ignoreLayout = true;
// item might be wrapped (for now remove/add tabindex only on the first wrapped item)
item = getFirstSelectableWrappedItem(item);
if (item !== null) {
item.setAttribute('tabindex', tabbable ? 0 : -1);
}
}
/** @ignore */
_debounceOnLayout() {
if (this._ignoreLayout) {
this._ignoreLayout = false;
return;
}
// Debounce
if (this._timeout !== null) {
window.clearTimeout(this._timeout);
}
this._timeout = window.setTimeout(() => {
this._timeout = null;
this._onLayout();
}, this._wait);
}
_moveDirectItemChildren() {
const items = Array.prototype.filter.call(this.children, child => child.nodeName === 'CORAL-ACTIONBAR-ITEM');
const frag = document.createDocumentFragment();
// Move them to the frag
items.forEach((item) => {
frag.appendChild(item);
});
// Add the frag to primary content zone
this._elements.primary.appendChild(frag);
}
get _contentZones() {
return {
'coral-actionbar-primary': 'primary',
'coral-actionbar-secondary': 'secondary'
};
}
/** @ignore */
render() {
super.render();
this.classList.add(CLASSNAME);
// Move direct items into primary content zone
this._moveDirectItemChildren();
// Cleanup resize helpers object (cloneNode support)
const resizeHelpers = this.querySelectorAll('object');
for (let i = 0 ; i < resizeHelpers.length ; ++i) {
const resizeElement = resizeHelpers[i];
if (resizeElement.parentNode === this) {
this.removeChild(resizeElement);
}
}
const primary = this._elements.primary;
const secondary = this._elements.secondary;
if (!primary.hasAttribute('role')) {
primary.setAttribute('role', 'toolbar');
}
if (!secondary.hasAttribute('role')) {
secondary.setAttribute('role', 'toolbar');
}
// we need to know if the content zone was provided to stop the voracious behavior
let primaryProvided = primary.parentNode === this;
// as a way to transition to the new content zones, we need to provide support for the old container tag. we copy
// everything from these containers into the corresponding content zones, including the configurations
const containers = Array.prototype.slice.call(this.getElementsByTagName('coral-actionbar-container'));
let legacyContainer;
let targetContainer;
for (let j = 0, containersCount = containers.length ; j < containersCount ; j++) {
legacyContainer = containers[j];
// move first container content to new primary element
if (j === 0) {
targetContainer = primary;
// overrides the previous configuration as we support older containers
primaryProvided = true;
} else if (j === 1) {
targetContainer = secondary;
}
// it may happen that more than 2 containers were provided, in such case we simply ignore it
if (targetContainer) {
// we need to copy the existing configuration to the new content zone
if (legacyContainer.hasAttribute('threshold')) {
targetContainer.setAttribute('threshold', legacyContainer.getAttribute('threshold'));
}
if (legacyContainer.hasAttribute('morebuttontext')) {
targetContainer.setAttribute('morebuttontext', legacyContainer.getAttribute('morebuttontext'));
}
// @todo: are we copying the more button?
while (legacyContainer.firstChild) {
targetContainer.appendChild(legacyContainer.firstChild);
}
}
this.removeChild(legacyContainer);
}
// to prevent the content zone being voracious, we only move the children if primary was not explicitely provided
if (!primaryProvided) {
while (this.firstChild) {
primary.appendChild(this.firstChild);
}
}
// Call content zone inserts
this.primary = this._elements.primary;
this.secondary = this._elements.secondary;
// force one layout
this._onLayout();
}
});
export default ActionBar;