UNPKG

@adobe/coral-spectrum

Version:

Coral Spectrum is a JavaScript library of Web Components following Spectrum design patterns.

625 lines (529 loc) 21.3 kB
/** * 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;