coral-spectrum/coral-component-quickactions/src/scripts/QuickActions.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 {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;