UNPKG

@adobe/coral-spectrum

Version:

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

1,307 lines (1,072 loc) 38.2 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 {Icon} from '../../../coral-component-icon'; import {Button} from '../../../coral-component-button'; import {AnchorButton} from '../../../coral-component-anchorbutton'; import {ButtonList, AnchorList} from '../../../coral-component-list'; import {ExtensibleOverlay, Overlay} from '../../../coral-component-overlay'; import {Collection} from '../../../coral-collection'; import QuickActionsItem from './QuickActionsItem'; import '../../../coral-component-popover'; import base from '../templates/base'; import {transform, validate, commons, i18n} from '../../../coral-utils'; import {Decorator} from '../../../coral-decorator'; const BUTTON_FOCUSABLE_SELECTOR = '._coral-QuickActions-item:not([disabled]):not([hidden])'; /** Enumeration for {@link QuickActions} interaction options. @typedef {Object} QuickActionsInteractionEnum @property {String} ON Show when the target is hovered or focused and hide when the mouse is moved out or focus is lost. @property {String} OFF Do not show or hide automatically. */ const interaction = { ON: 'on', OFF: 'off' }; /** Enumeration for {@link QuickActions} anchored overlay target options. @typedef {Object} QuickActionsTargetEnum @property {String} PARENT Use the parent element in the DOM. @property {String} PREVIOUS Use the previous sibling element in the DOM. @property {String} NEXT Use the next sibling element in the DOM. */ const target = { PARENT: '_parent', PREVIOUS: '_prev', NEXT: '_next' }; /** Enumeration for {@link QuickActions} placement options. @typedef {Object} QuickActionsPlacementEnum @property {String} TOP QuickActions inset to the top of the target. @property {String} CENTER QuickActions inset to the center of the target. @property {String} BOTTOM QuickActions inset to the bottom the target. */ const placement = { TOP: 'top', CENTER: 'center', BOTTOM: 'bottom' }; const OFFSET = 10; const CLASSNAME = '_coral-QuickActions'; /** @class Coral.QuickActions @classdesc A QuickActions component is an overlay component that reveals actions when interacting with a container. Hovering the target will display the QuickActions. They can also be launched by pressing the shift + F10 key combination when the target is focused. @htmltag coral-quickactions @extends {Overlay} */ const QuickActions = Decorator(class extends ExtensibleOverlay { /** @ignore */ constructor() { super(); // Override defaults this._overlayAnimationTime = Overlay.FADETIME; this._alignMy = Overlay.align.CENTER_TOP; this._alignAt = Overlay.align.CENTER_TOP; this._lengthOffset = OFFSET; this._inner = true; this._target = target.PREVIOUS; this._placement = placement.TOP; this._focusOnShow = Overlay.focusOnShow.OFF; this._scrollOnFocus = Overlay.scrollOnFocus.OFF; if (!this.id) { this.id = commons.getUID(); } // Flag this._openedBefore = false; // Debounce timer this._timeout = null; // Template base.call(this._elements, {commons, i18n}); const events = { 'global:resize': '_onWindowResize', 'mouseout': '_onMouseOut', // Keyboard interaction 'key:home': '_onHomeKeypress', 'key:end': '_onEndKeypress', 'key:pagedown': '_onButtonKeypressNext', 'key:right': '_onButtonKeypressNext', 'key:down': '_onButtonKeypressNext', 'key:pageup': '_onButtonKeypressPrevious', 'key:left': '_onButtonKeypressPrevious', 'key:up': '_onButtonKeypressPrevious', 'capture:focus': '_onFocus', 'capture:blur': '_onBlur', // Buttons 'click > ._coral-QuickActions-item:not([handle="moreButton"])': '_onButtonClick', 'click > ._coral-QuickActions-item[handle="moreButton"]': '_onMoreButtonClick', //Messenger 'coral-quickactions-item:_messengerconnected': '_onMessengerConnected' }; const overlayId = this._elements.overlay.id; // Overlay events[`global:capture:coral-overlay:beforeopen #${overlayId}`] = '_onOverlayBeforeOpen'; events[`global:capture:coral-overlay:beforeclose #${overlayId}`] = '_onOverlayBeforeClose'; events[`global:capture:coral-overlay:open #${overlayId}`] = '_onOverlayOpen'; events['global:capture:coral-overlay:close'] = '_onOverlayClose'; events[`global:capture:coral-overlay:positioned #${overlayId}`] = '_onOverlayPositioned'; events[`global:capture:coral-overlay:_animate #${overlayId}`] = '_onAnimate'; events[`global:capture:mouseout #${overlayId}`] = '_onMouseOut'; events[`global:capture:click #${overlayId} [coral-list-item]`] = '_onButtonListItemClick'; // Cache bound event handler functions this._onTargetMouseEnter = this._onTargetMouseEnter.bind(this); this._onTargetKeyUp = this._onTargetKeyUp.bind(this); this._onTargetMouseLeave = this._onTargetMouseLeave.bind(this); // Events this._delegateEvents(events); // delegates the item handling to the collection this.items._startHandlingItems(true); } /** Returns the inner overlay to allow customization. @type {Popover} @readonly */ get overlay() { return this._elements.overlay; } /** The Item collection. @type {Collection} @readonly */ get items() { // we do lazy initialization of the collection if (!this._items) { this._items = new Collection({ host: this, itemTagName: 'coral-quickactions-item', onItemRemoved: this._onItemRemoved, onCollectionChange: this._onCollectionChange }); } return this._items; } /** The number of items that are visible in QuickActions (excluding the show more actions button) before a collapse is enforced. A value <= 0 disables this feature and shows as many items as possible. Regardless of this property, the QuickActions will still fit within their target's width. @type {Number} @default 4 @htmlattribute threshold @htmlattributereflected */ get threshold() { return typeof this._threshold === 'number' ? this._threshold : 4; } set threshold(value) { this._threshold = transform.number(value); this._reflectAttribute('threshold', this._threshold); } /** The placement of the QuickActions. The value may be one of 'top', 'center' and 'bottom' and indicates the vertical alignment of the QuickActions relative to their container. See {@link OverlayPlacementEnum}. @type {String} @default OverlayPlacementEnum.TOP @htmlattribute placement */ get placement() { return super.placement; } set placement(value) { value = transform.string(value).toLowerCase(); this._placement = validate.enumeration(placement)(value) && value || placement.TOP; this.reposition(); } /** Whether the QuickActions should show when the target is interacted with. See {@link QuickActionsInteractionEnum}. @type {String} @default QuickActionsInteractionEnum.ON @name interaction @htmlattribute interaction */ get interaction() { return super.interaction; } set interaction(value) { super.interaction = value; if (this.interaction === interaction.ON) { this._addTargetEventListeners(); } else { this._removeTargetEventListeners(); } } /** Inherited from {@link Overlay#target}. */ get target() { return super.target; } set target(value) { // avoid popper initialization while connecting for first time and not opened. this._avoidPopperInit = this.open || this._popper ? false : true; super.target = value; const targetElement = this._getTarget(value); const prevTargetElement = this._previousTarget; const targetHasChanged = targetElement !== prevTargetElement; if (targetElement && targetHasChanged) { // Remove listeners from the previous target if (prevTargetElement) { const previousTarget = this._getTarget(prevTargetElement); if (previousTarget) { this._removeTargetEventListeners(previousTarget); targetElement.removeAttribute('aria-haspopup'); targetElement.removeAttribute('aria-owns'); } } // Set up listeners for the new target this._addTargetEventListeners(); let ariaOwns = targetElement.getAttribute('aria-owns'); ariaOwns = ariaOwns && ariaOwns.length ? `${ariaOwns.trim()} ${this.id}` : this.id; targetElement.setAttribute('aria-owns', ariaOwns); // Mark the target as owning a popup targetElement.setAttribute('aria-haspopup', 'true'); // Cache for use as previous target this._previousTarget = targetElement; } delete this._avoidPopperInit; } get observedMessages() { return { 'coral-quickactions-item:_contentchanged': '_onItemChange', 'coral-quickactions-item:_iconchanged': '_onItemChange', 'coral-quickactions-item:_hrefchanged': '_onItemChange', 'coral-quickactions-item:_typechanged': '_onItemTypeChange' }; } /** Inherited from {@link Overlay#open}. */ get open() { return super.open; } set open(value) { // If opening and stealing focus, on close, focus should be returned // to the element that had focus before QuickActions were opened. if (value && this._focusOnShow !== this.constructor.focusOnShow.OFF) { this.returnFocusTo(document.activeElement); } super.open = value; this._openedOnce = true; // Position once we can read items layout in the next frame window.requestAnimationFrame(() => { if (this.open && !this._openedBefore) { // we iterate over all the items initializing them in the correct order const items = this.items.getAll(); for (let i = 0, itemCount = items.length ; i < itemCount ; i++) { this._attachItem(items[i], i); } this._openedBefore = true; } if (this.open) { this._layout(); } // we toggle "is-selected" on the target to indicate that the over is open const targetElement = this._getTarget(); if (targetElement) { targetElement.classList.toggle('is-selected', this.open); } }); } _getButtonWidth() { if (this.closest('.coral--large')) { // 40px button width + 10px left margin return 50; } else { // 32px button width + 8px left margin return 40; } } /** @ignore */ _getTarget(targetValue) { // Use passed target targetValue = targetValue || this.target; if (targetValue instanceof Node) { // Just return the provided Node return targetValue; } // Dynamically get the target node based on target let newTarget = null; if (typeof targetValue === 'string') { if (targetValue === target.PARENT) { newTarget = this.parentNode; } else { // Delegate to Coral.Overlay for _prev, _next and general selector newTarget = super._getTarget(targetValue); } } return newTarget; } /** @ignore */ _addTargetEventListeners(targetElement) { targetElement = targetElement || this._getTarget(); if (!targetElement) { return; } // Interaction-sensitive listeners if (this.interaction === interaction.ON) { // We do not have to worry about the EventListener being called twice as duplicates are discarded targetElement.addEventListener('mouseenter', this._onTargetMouseEnter); targetElement.addEventListener('keyup', this._onTargetKeyUp); targetElement.addEventListener('keydown', this._onTargetKeyDown); targetElement.addEventListener('mouseleave', this._onTargetMouseLeave); } } /** @ignore */ _removeTargetEventListeners(targetElement) { targetElement = targetElement || this._getTarget(); if (!targetElement) { return; } targetElement.removeEventListener('mouseenter', this._onTargetMouseEnter); targetElement.removeEventListener('keyup', this._onTargetKeyUp); targetElement.removeEventListener('keydown', this._onTargetKeyDown); targetElement.removeEventListener('mouseleave', this._onTargetMouseLeave); } /** Toggles whether or not an item is tabbable. @param {HTMLElement} item The item to process. @param {Boolean} tabbable Whether the item should be marked tabbable. @ignore */ _toggleTabbable(item, tabbable) { if (item) { if (tabbable) { if (item.hasAttribute('tabIndex')) { item.removeAttribute('tabIndex'); } } else { item.setAttribute('tabIndex', '-1'); } } } /** Gets the subsequent or previous focusable neighbour relative to an Item button. @param {HTMLElement} current The current button element from which to find the next selectable neighbour. @param {Boolean} [previous] Whether to look for a previous neighbour rather than a subsequent one. @returns {HTMLElement|undefined} The focusable neighbour. Undefined if no suitable neighbour found. @private */ _getFocusableNeighbour(current, previous) { // we need to convert the result to an array in order to use .indexOf() const focusableButtons = Array.prototype.slice.call(this._getFocusableButtons()); const index = focusableButtons.indexOf(current); if (index >= 0) { if (!previous) { // Pick the next focusable button if (index < focusableButtons.length - 1) { return focusableButtons[index + 1]; } } // Pick the previous focusable button else if (index !== 0) { return focusableButtons[index - 1]; } } } /** Gets the buttons, optionally excluding the more button. @param {Boolean} excludeMore Whether to exclude the more button. @returns {NodeList} The NodeList containing all the buttons. @private */ _getButtons(excludeMore) { let buttonSelector = '._coral-QuickActions-item'; buttonSelector = excludeMore ? `${buttonSelector}:not([handle="moreButton"])` : buttonSelector; return this.querySelectorAll(buttonSelector); } /** An element is focusable if it is visible and not disabled. @returns {NodeList} A NodeList containing the focusable buttons. @private */ _getFocusableButtons() { // since we use the hidden attribute to hide the items, we can rely on this attribute to determine if the button // is hidden, instead of using a more expensive :focusable selector return this.querySelectorAll(BUTTON_FOCUSABLE_SELECTOR); } /** Gets the first focusable button. @returns {HTMLElement|undefined} The first focusable button, undefined if none found. @ignore */ _getFirstFocusableButton() { return this.querySelector(BUTTON_FOCUSABLE_SELECTOR); } /** Gets the last focusable button. @returns {HTMLElement|undefined} The last focusable button, undefined if none found. @ignore */ _getLastFocusableButton() { const focusableButtons = this._getFocusableButtons(); return focusableButtons[focusableButtons.length - 1]; } /** @ignore */ _proxyClick(item) { const event = item.trigger('click'); if (!event.defaultPrevented && this.interaction === interaction.ON) { this._hideAll(); } } /** Gets data from an Item. @param {HTMLElement} item The Item to get the data from. @returns {Object} The Item data. @ignore */ _getItemData(item) { return { htmlContent: item.innerHTML, textContent: item.textContent, // fallback to empty string in case it has no icon icon: item.getAttribute('icon') || '' }; } /** @ignore */ _attachItem(item, index) { // since the button has already been initialized we make sure it is up to date if (item._elements && item._elements.button) { this._updateItem(item); return; } // if the index was not provided, we need to calculate it if (typeof index === 'undefined') { index = Array.prototype.indexOf.call(this.items.getAll(), item); } const itemData = this._getItemData(item); const type = QuickActionsItem.type; let button; if (item.type === type.BUTTON) { button = new Button().set({ icon: itemData.icon, iconsize: Icon.size.SMALL, type: 'button', tracking: 'off' }, true); } else if (item.type === type.ANCHOR) { button = new AnchorButton().set({ icon: itemData.icon, iconsize: Icon.size.SMALL, href: item.href, tracking: 'off' }, true); } button.variant = Button.variant.QUIET_ACTION; button.classList.add('_coral-QuickActions-item'); button.setAttribute('tabindex', '-1'); button.setAttribute('title', itemData.textContent.trim()); button.setAttribute('aria-label', itemData.textContent.trim()); button.setAttribute('role', 'menuitem'); this.insertBefore(button, this.children[index]); // ButtonList Item let buttonListItem; if (item.type === type.BUTTON) { buttonListItem = new ButtonList.Item(); } else if (item.type === type.ANCHOR) { buttonListItem = new AnchorList.Item(); buttonListItem.href = item.href; } const buttonListItemParent = this._elements.buttonList; buttonListItem.tabIndex = -1; buttonListItem.content.innerHTML = itemData.htmlContent; buttonListItem.icon = itemData.icon; buttonListItem.setAttribute('role', 'menuitem'); buttonListItemParent.insertBefore(buttonListItem, buttonListItemParent.children[index]); item._elements.button = button; item._elements.buttonListItem = buttonListItem; buttonListItem._elements.quickActionsItem = item; button._elements.quickActionsItem = item; } /** Layout calculation; collapses QuickActions as necessary. */ _layout() { // Set the width of the QuickActions to match that of the target this._setWidth(); const buttons = this._getButtons(true); if (!buttons.length) { return; } const buttonListItems = this._elements.buttonList.items.getAll(); // Temporarily display the QuickActions so we can do the calculation const display = this.style.display; let temporarilyShown = false; if (!this.open) { this.style.left -= 10000; this.style.top -= 10000; this.style.display = 'block'; temporarilyShown = true; } const totalAvailableWidth = this.offsetWidth; let totalFittingButtons = 0; let widthUsed = 0; const buttonWidth = this._getButtonWidth(); while (totalAvailableWidth > widthUsed) { widthUsed += buttonWidth; if (totalAvailableWidth > widthUsed) { totalFittingButtons++; } } // Remove one to avoid taking full width space totalFittingButtons--; const threshold = this.threshold; const handleThreshold = threshold > 0; const moreButtonsThanThreshold = handleThreshold && buttons.length > threshold; const collapse = buttons.length > totalFittingButtons || moreButtonsThanThreshold; // +1 to account for the more button const collapseToThreshold = collapse && handleThreshold && threshold + 1 < totalFittingButtons; let totalButtons; if (collapse) { if (collapseToThreshold) { totalButtons = threshold + 1; } else { totalButtons = totalFittingButtons; } } else { totalButtons = buttons.length; } // Show all Buttons and ButtonList Items for (let i = 0 ; i < buttons.length ; i++) { this._toggleTabbable(buttons[i], false); buttons[i].hidden = false; if (buttonListItems[i]) { buttonListItems[i].hidden = false; } } this._toggleTabbable(this._elements.moreButton, false); if (collapse) { if (totalButtons > 0) { // Hide the buttons we're collapsing for (let j = totalButtons - 1 ; j < buttons.length ; j++) { buttons[j].hide(); } // Hide the ButtonList items for (let k = 0 ; k < totalButtons - 1 ; k++) { buttonListItems[k].hide(); } // Mark the first button as tabbable this._toggleTabbable(buttons[0], true); } else { this._toggleTabbable(this._elements.moreButton, true); } this._elements.moreButton.show(); } else { // Mark the first button as tabbable this._toggleTabbable(buttons[0], true); this._elements.moreButton.hide(); } this._setWidth(true); // Reset the QuickActions display if (temporarilyShown) { this.style.left += 10000; this.style.top += 10000; this.style.display = display; } // Do a reposition of the overlay this.reposition(); } /** Sets the width of QuickActions from the target. @ignore */ _setWidth(buttonWidthBased) { let width = 0; const targetElement = this._getTarget(); if (targetElement) { const maxWidth = targetElement.offsetWidth; if (buttonWidthBased) { const visibleButtons = this.querySelectorAll('._coral-QuickActions-item:not([hidden])'); const buttonWidth = this._getButtonWidth(); if (visibleButtons.length) { for (let i = 0 ; i < visibleButtons.length && width <= maxWidth ; i++) { width += buttonWidth; } this.style.width = `${width}px`; } } else { this.style.width = `${maxWidth}px`; } } } /** @ignore */ _setButtonListHeight() { // Set height of ButtonList this._elements.buttonList.style.height = ''; // Measure actual height const style = window.getComputedStyle(this._elements.buttonList); const height = parseInt(style.height, 10); const maxHeight = parseInt(style.maxHeight, 10); if (height < maxHeight) { // Make it scrollable this._elements.buttonList.style.height = `${height - 1}px`; } } /** @ignore */ _isInternalToComponent(element) { const targetElement = this._getTarget(); return element && (this.contains(element) || this._elements.overlay.contains(element) || targetElement && targetElement.contains(element)); } /** @ignore */ _onWindowResize() { this._layout(); } _handleEscape(event) { if (typeof this._isTop === 'undefined') { this._isTop = this._isTopOverlay(); } // Debounce if (this._timeout !== null) { window.clearTimeout(this._timeout); } this._timeout = window.setTimeout(() => { if (this._isTop) { super._handleEscape(event); } this._isTop = undefined; }); } /** @ignore */ _onMouseOut(event) { const toElement = event.toElement || event.relatedTarget; // Hide if we mouse leave to any element external to the component and its target if (!this._isInternalToComponent(toElement) && this.interaction === interaction.ON) { this._hideAll(); } } _hideAll() { this.hide(); this._elements.overlay.hide(); } /** @ignore */ _onTargetMouseEnter(event) { const fromElement = event.fromElement || event.relatedTarget; // Open if we aren't already if (!this.open && !this._isInternalToComponent(fromElement)) { this.show(); this._trackEvent('display', 'coral-quickactions', event); } } /** @ignore */ _onTargetKeyUp(event) { const keyCode = event.keyCode; // shift + F10 or ctrl + space (http://www.w3.org/WAI/PF/aria-practices/#popupmenu) if (event.shiftKey && keyCode === 121 || event.ctrlKey && keyCode === 32) { if (!this.open) { if (this.interaction === interaction.ON) { // Launched via keyboard and interaction enabled implies a focus trap and return focus. // Remember the relevant properties and return their values on hide. this._previousTrapFocus = this.trapFocus; this._previousReturnFocus = this.returnFocus; this._previousFocusOnShow = this.focusOnShow; this.trapFocus = this.constructor.trapFocus.ON; this.returnFocus = this.constructor.returnFocus.ON; this.focusOnShow = this.constructor.focusOnShow.ON; } this.show(); } } } _onTargetKeyDown(event) { const keyCode = event.keyCode; // shift + F10 or ctrl + space (http://www.w3.org/WAI/PF/aria-practices/#popupmenu) if (event.shiftKey && keyCode === 121 || event.ctrlKey && keyCode === 32) { // Prevent default context menu show or page scroll behaviour event.preventDefault(); } } /** @ignore */ _onTargetMouseLeave(event) { const toElement = event.toElement || event.relatedTarget; // Do not hide if we entered the quick actions if (!this._isInternalToComponent(toElement)) { this._hideAll(); } } /** @ignore */ _onHomeKeypress(event) { // prevents the page from scrolling event.preventDefault(); const firstFocusableButton = this._getFirstFocusableButton(); // Jump focus to the first focusable button if (firstFocusableButton) { firstFocusableButton.focus(); } } /** @ignore */ _onEndKeypress(event) { // prevents the page from scrolling event.preventDefault(); const lastFocusableButton = this._getLastFocusableButton(); // Jump focus to the last focusable button if (lastFocusableButton) { lastFocusableButton.focus(); } } /** @ignore */ _onButtonKeypressNext(event) { event.preventDefault(); if (document.activeElement === this) { const firstFocusableButton = this._getFirstFocusableButton(); if (firstFocusableButton) { firstFocusableButton.focus(); } } else { // Handle key presses that imply focus of the next focusable button const nextButton = this._getFocusableNeighbour(event.matchedTarget); if (nextButton) { nextButton.focus(); } else if (event.key === 'ArrowDown' && document.activeElement === this._elements.moreButton) { this._elements.moreButton.click(); } } } /** @ignore */ _onButtonKeypressPrevious(event) { event.preventDefault(); if (document.activeElement === this) { const lastFocusableButton = this._getLastFocusableButton(); if (lastFocusableButton) { lastFocusableButton.focus(); } } else { // Handle key presses that imply focus of the previous focusable button const previousButton = this._getFocusableNeighbour(event.matchedTarget, true); if (previousButton) { previousButton.focus(); } } } /** @ignore */ _onButtonClick(event) { event.stopPropagation(); if (this._preventClick) { return; } const button = event.matchedTarget; const item = button._elements.quickActionsItem; this._proxyClick(item); // Prevent double click or alternate selection during animation window.setTimeout(() => { this._preventClick = false; }, this._overlayAnimationTime); this._preventClick = true; this._trackEvent('click', 'coral-quickactions-item', event, item); } _onMoreButtonClick(event) { const button = event.matchedTarget; const item = button._elements.quickActionsItem; this._trackEvent('click', 'coral-quickactions-more', event, item); } _onFocus() { if (this._focusOnShow === this.constructor.focusOnShow.OFF && this._returnFocus !== this.constructor.returnFocus.ON) { const targetElement = this._getTarget(); if (targetElement) { if (!this._previousReturnFocus) { this._previousReturnFocus = this._returnFocus; this.returnFocus = this.constructor.returnFocus.ON; } if (!this._previousElementToFocusWhenHidden) { this._previousElementToFocusWhenHidden = this._elementToFocusWhenHidden; this._elementToFocusWhenHidden = targetElement; } } } } _onBlur() { if (this._focusOnShow === this.constructor.focusOnShow.OFF) { if (this._previousReturnFocus) { this.returnFocus = this._previousReturnFocus; this._previousReturnFocus = undefined; } if (this._previousElementToFocusWhenHidden) { this._elementToFocusWhenHidden = this._previousElementToFocusWhenHidden; this._previousElementToFocusWhenHidden = undefined; } } } /** @ignore */ _onOverlayBeforeOpen(event) { if (event.target === this) { // Reset double-click prevention flag this._preventClick = false; this._layout(); } else if (event.target === this._elements.overlay) { // do not allow internal Overlay events to escape QuickActions event.stopImmediatePropagation(); this._setButtonListHeight(); } } /** @ignore */ _onOverlayBeforeClose(event) { if (event.target === this._elements.overlay) { // do not allow internal Overlay events to escape QuickActions event.stopImmediatePropagation(); } } /** @ignore */ _onOverlayOpen(event) { if (event.target === this._elements.overlay) { // do not allow internal Overlay events to escape QuickActions event.stopImmediatePropagation(); this._elements.moreButton.setAttribute('aria-expanded', 'true'); } } /** @ignore */ _onOverlayClose(event) { if (event.target === this) { this._elements.overlay.open = false; // Return the trapFocus and returnFocus properties to their state before open. // Handles the keyboard launch and interaction enabled case, which implies focus trap and focus return. // Wait a frame as this is called before the 'open' property sync. Otherwise, returnFocus is set prematurely. window.requestAnimationFrame(() => { if (this._previousTrapFocus) { this.trapFocus = this._previousTrapFocus; if (this.trapFocus !== this.constructor.trapFocus.ON) { this.removeAttribute('tabindex'); } this._previousTrapFocus = undefined; } if (this._previousReturnFocus) { this.returnFocus = this._previousReturnFocus; this._previousReturnFocus = undefined; } if (this._previousFocusOnShow) { this.focusOnShow = this._previousFocusOnShow; this._previousFocusOnShow = undefined; } }); } else if (event.target === this._elements.overlay) { // do not allow internal Overlay events to escape QuickActions event.stopImmediatePropagation(); this._elements.moreButton.setAttribute('aria-expanded', 'false'); } } /** @ignore */ _onOverlayPositioned(event) { if (event.target === this._elements.overlay) { // do not allow internal Overlay events to escape QuickActions event.stopImmediatePropagation(); } } _onAnimate(event) { if (event.target === this) { if (this.placement === placement.BOTTOM) { this.style.marginTop = `${-parseFloat(this.lengthOffset) + 8}px`; } else { this.style.marginTop = `${parseFloat(this.lengthOffset) - 8}px`; } } } /** @ignore */ _onButtonListItemClick(event) { // stops propagation so that this event remains internal to the component event.stopImmediatePropagation(); const buttonListItem = event.matchedTarget; if (!buttonListItem) { return; } const item = buttonListItem._elements.quickActionsItem; this._proxyClick(item); this._trackEvent('click', 'coral-quickactions-item', event, item); } /** @ignore */ _onItemRemoved(item) { this._removeItemElements(item); } /** @ignore */ _onCollectionChange(addedNodes) { // Delay the item initialization if the component has not been opened before if (!this._openedBefore) { return; } // we use the items to be able to find out the index of the added item in reference to the whole collection const items = this.items.getAll(); let index; for (let i = 0, addedNodesCount = addedNodes.length ; i < addedNodesCount ; i++) { // we need to know the item's position in relation to the others index = Array.prototype.indexOf.call(items, addedNodes[i]); this._attachItem(addedNodes[i], index); } this._layout(); } /** @ignore */ _onItemChange(event) { // stops propagation so that this event remains internal to the component event.stopImmediatePropagation(); this._updateItem(event.target); } /** @ignore */ _onItemTypeChange(event) { // stops propagation so that this event remains internal to the component event.stopImmediatePropagation(); // delay this execution while opening quickaction to avoid performance delay if(this._openedBefore || this.open) { const item = event.target; this._removeItemElements(item); this._attachItem(item); this._layout(); } } /** @ignore */ _removeItemElements(item) { // Remove the associated Button and ButtonList elements if (item._elements.button) { item._elements.button.remove(); item._elements.button._elements.quickActionsItem = undefined; item._elements.button = undefined; } if (item._elements.buttonListItem) { item._elements.buttonListItem.remove(); item._elements.buttonListItem._elements.quickActionsItem = null; item._elements.buttonListItem = undefined; } } /** @ignore */ _updateItem(item) { const itemData = this._getItemData(item); const type = QuickActionsItem.type; const button = item._elements.button; if (button) { button.icon = itemData.icon; button.setAttribute('title', itemData.textContent.trim()); button.setAttribute('aria-label', itemData.textContent.trim()); button[item.type === type.ANCHOR ? 'setAttribute' : 'removeAttribute']('href', item.href); } const buttonListItem = item._elements.buttonListItem; if (buttonListItem) { buttonListItem.content.innerHTML = itemData.htmlContent; buttonListItem[item.type === type.ANCHOR ? 'setAttribute' : 'removeAttribute']('href', item.href); buttonListItem.icon = itemData.icon; } } // Maps placement CENTER with RIGHT _toggleCenterPlacement(toggle) { if (toggle) { if (this.placement === placement.CENTER) { this._placement = Overlay.placement.RIGHT; this._oldInner = this._inner; this._inner = false; this._oldLengthOffset = this._lengthOffset; this._lengthOffset = '-50%r - 50%p'; } } else if (this._placement === Overlay.placement.RIGHT) { this._placement = placement.CENTER; // Restore this._inner = this._oldInner; this._lengthOffset = this._oldLengthOffset; } } /** @ignore */ reposition(forceReposition) { // Override to support placement.CENTER this._toggleCenterPlacement(true); super.reposition(forceReposition); this._toggleCenterPlacement(false); if (this._openedOnce) { // PopperJS inner property issue https://github.com/FezVrasta/popper.js/issues/400 if (this.placement === placement.BOTTOM) { this.style.marginTop = `-${parseFloat(this.lengthOffset)}px`; } else if (this.placement === placement.TOP) { this.style.marginTop = `${parseFloat(this.lengthOffset)}px`; } else if (this.placement === placement.CENTER) { this.style.marginTop = `${parseFloat(this.lengthOffset) - 4}px`; } } } // Override placement and target /** Returns {@link QuickActions} placement options. @return {QuickActionsPlacementEnum} */ static get placement() { return placement; } /** Returns {@link QuickActions} target options. @return {QuickActionsTargetEnum} */ static get target() { return target; } /** @ignore */ static get observedAttributes() { return super.observedAttributes.concat(['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(); this.classList.add(CLASSNAME); // Define QuickActions as a menu this.setAttribute('role', 'menu'); // Support cloneNode ['moreButton', 'overlay'].forEach((handleName) => { const handle = this.querySelector(`[handle="${handleName}"]`); if (handle) { handle.remove(); } }); // Render template const frag = document.createDocumentFragment(); frag.appendChild(this._elements.moreButton); frag.appendChild(this._elements.overlay); // avoid popper initialisation if popper neither exist nor overlay opened. this._elements.overlay._avoidPopperInit = this._elements.overlay.open || this._elements.overlay._popper ? false : true; // Link target this._elements.overlay.target = this._elements.moreButton; this.appendChild(frag); // set this to false after overlay has been connected to avoid connected callback target setting delete this._elements.overlay._avoidPopperInit; } /** @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 QuickActions;