ExamplesPlaygroundReference Source

coral-spectrum/coral-component-quickactions/src/scripts/QuickActions.js

  1. /**
  2. * Copyright 2019 Adobe. All rights reserved.
  3. * This file is licensed to you under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License. You may obtain a copy
  5. * of the License at http://www.apache.org/licenses/LICENSE-2.0
  6. *
  7. * Unless required by applicable law or agreed to in writing, software distributed under
  8. * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
  9. * OF ANY KIND, either express or implied. See the License for the specific language
  10. * governing permissions and limitations under the License.
  11. */
  12.  
  13. import {Icon} from '../../../coral-component-icon';
  14. import {Button} from '../../../coral-component-button';
  15. import {AnchorButton} from '../../../coral-component-anchorbutton';
  16. import {ButtonList, AnchorList} from '../../../coral-component-list';
  17. import {ExtensibleOverlay, Overlay} from '../../../coral-component-overlay';
  18. import {Collection} from '../../../coral-collection';
  19. import QuickActionsItem from './QuickActionsItem';
  20. import '../../../coral-component-popover';
  21. import base from '../templates/base';
  22. import {transform, validate, commons, i18n} from '../../../coral-utils';
  23. import {Decorator} from '../../../coral-decorator';
  24.  
  25. const BUTTON_FOCUSABLE_SELECTOR = '._coral-QuickActions-item:not([disabled]):not([hidden])';
  26.  
  27. /**
  28. Enumeration for {@link QuickActions} interaction options.
  29.  
  30. @typedef {Object} QuickActionsInteractionEnum
  31.  
  32. @property {String} ON
  33. Show when the target is hovered or focused and hide when the mouse is moved out or focus is lost.
  34. @property {String} OFF
  35. Do not show or hide automatically.
  36. */
  37. const interaction = {
  38. ON: 'on',
  39. OFF: 'off'
  40. };
  41.  
  42. /**
  43. Enumeration for {@link QuickActions} anchored overlay target options.
  44.  
  45. @typedef {Object} QuickActionsTargetEnum
  46.  
  47. @property {String} PARENT
  48. Use the parent element in the DOM.
  49. @property {String} PREVIOUS
  50. Use the previous sibling element in the DOM.
  51. @property {String} NEXT
  52. Use the next sibling element in the DOM.
  53. */
  54. const target = {
  55. PARENT: '_parent',
  56. PREVIOUS: '_prev',
  57. NEXT: '_next'
  58. };
  59.  
  60. /**
  61. Enumeration for {@link QuickActions} placement options.
  62.  
  63. @typedef {Object} QuickActionsPlacementEnum
  64.  
  65. @property {String} TOP
  66. QuickActions inset to the top of the target.
  67. @property {String} CENTER
  68. QuickActions inset to the center of the target.
  69. @property {String} BOTTOM
  70. QuickActions inset to the bottom the target.
  71. */
  72. const placement = {
  73. TOP: 'top',
  74. CENTER: 'center',
  75. BOTTOM: 'bottom'
  76. };
  77.  
  78. const OFFSET = 10;
  79.  
  80. const CLASSNAME = '_coral-QuickActions';
  81.  
  82. /**
  83. @class Coral.QuickActions
  84. @classdesc A QuickActions component is an overlay component that reveals actions when interacting with a container.
  85. Hovering the target will display the QuickActions. They can also be launched by pressing the shift + F10 key combination
  86. when the target is focused.
  87. @htmltag coral-quickactions
  88. @extends {Overlay}
  89. */
  90. const QuickActions = Decorator(class extends ExtensibleOverlay {
  91. /** @ignore */
  92. constructor() {
  93. super();
  94.  
  95. // Override defaults
  96. this._overlayAnimationTime = Overlay.FADETIME;
  97. this._alignMy = Overlay.align.CENTER_TOP;
  98. this._alignAt = Overlay.align.CENTER_TOP;
  99. this._lengthOffset = OFFSET;
  100. this._inner = true;
  101. this._target = target.PREVIOUS;
  102. this._placement = placement.TOP;
  103. this._focusOnShow = Overlay.focusOnShow.OFF;
  104. this._scrollOnFocus = Overlay.scrollOnFocus.OFF;
  105.  
  106. if (!this.id) {
  107. this.id = commons.getUID();
  108. }
  109.  
  110. // Flag
  111. this._openedBefore = false;
  112.  
  113. // Debounce timer
  114. this._timeout = null;
  115.  
  116. // Template
  117. base.call(this._elements, {commons, i18n});
  118.  
  119. const events = {
  120. 'global:resize': '_onWindowResize',
  121. 'mouseout': '_onMouseOut',
  122.  
  123. // Keyboard interaction
  124. 'key:home': '_onHomeKeypress',
  125. 'key:end': '_onEndKeypress',
  126. 'key:pagedown': '_onButtonKeypressNext',
  127. 'key:right': '_onButtonKeypressNext',
  128. 'key:down': '_onButtonKeypressNext',
  129. 'key:pageup': '_onButtonKeypressPrevious',
  130. 'key:left': '_onButtonKeypressPrevious',
  131. 'key:up': '_onButtonKeypressPrevious',
  132.  
  133. 'capture:focus': '_onFocus',
  134. 'capture:blur': '_onBlur',
  135.  
  136. // Buttons
  137. 'click > ._coral-QuickActions-item:not([handle="moreButton"])': '_onButtonClick',
  138. 'click > ._coral-QuickActions-item[handle="moreButton"]': '_onMoreButtonClick',
  139.  
  140. //Messenger
  141. 'coral-quickactions-item:_messengerconnected': '_onMessengerConnected'
  142. };
  143.  
  144. const overlayId = this._elements.overlay.id;
  145.  
  146. // Overlay
  147. events[`global:capture:coral-overlay:beforeopen #${overlayId}`] = '_onOverlayBeforeOpen';
  148. events[`global:capture:coral-overlay:beforeclose #${overlayId}`] = '_onOverlayBeforeClose';
  149. events[`global:capture:coral-overlay:open #${overlayId}`] = '_onOverlayOpen';
  150. events['global:capture:coral-overlay:close'] = '_onOverlayClose';
  151. events[`global:capture:coral-overlay:positioned #${overlayId}`] = '_onOverlayPositioned';
  152. events[`global:capture:coral-overlay:_animate #${overlayId}`] = '_onAnimate';
  153. events[`global:capture:mouseout #${overlayId}`] = '_onMouseOut';
  154. events[`global:capture:click #${overlayId} [coral-list-item]`] = '_onButtonListItemClick';
  155.  
  156. // Cache bound event handler functions
  157. this._onTargetMouseEnter = this._onTargetMouseEnter.bind(this);
  158. this._onTargetKeyUp = this._onTargetKeyUp.bind(this);
  159. this._onTargetMouseLeave = this._onTargetMouseLeave.bind(this);
  160.  
  161. // Events
  162. this._delegateEvents(events);
  163.  
  164. // delegates the item handling to the collection
  165. this.items._startHandlingItems(true);
  166. }
  167.  
  168. /**
  169. Returns the inner overlay to allow customization.
  170.  
  171. @type {Popover}
  172. @readonly
  173. */
  174. get overlay() {
  175. return this._elements.overlay;
  176. }
  177.  
  178. /**
  179. The Item collection.
  180.  
  181. @type {Collection}
  182. @readonly
  183. */
  184. get items() {
  185. // we do lazy initialization of the collection
  186. if (!this._items) {
  187. this._items = new Collection({
  188. host: this,
  189. itemTagName: 'coral-quickactions-item',
  190. onItemRemoved: this._onItemRemoved,
  191. onCollectionChange: this._onCollectionChange
  192. });
  193. }
  194.  
  195. return this._items;
  196. }
  197.  
  198. /**
  199. The number of items that are visible in QuickActions (excluding the show more actions button) before a collapse
  200. is enforced. A value <= 0 disables this feature and shows as many items as possible. Regardless of this
  201. property, the QuickActions will still fit within their target's width.
  202.  
  203. @type {Number}
  204. @default 4
  205. @htmlattribute threshold
  206. @htmlattributereflected
  207. */
  208. get threshold() {
  209. return typeof this._threshold === 'number' ? this._threshold : 4;
  210. }
  211.  
  212. set threshold(value) {
  213. this._threshold = transform.number(value);
  214. this._reflectAttribute('threshold', this._threshold);
  215. }
  216.  
  217. /**
  218. The placement of the QuickActions. The value may be one of 'top', 'center' and 'bottom' and indicates the vertical
  219. alignment of the QuickActions relative to their container.
  220. See {@link OverlayPlacementEnum}.
  221.  
  222. @type {String}
  223. @default OverlayPlacementEnum.TOP
  224. @htmlattribute placement
  225. */
  226. get placement() {
  227. return super.placement;
  228. }
  229.  
  230. set placement(value) {
  231. value = transform.string(value).toLowerCase();
  232. this._placement = validate.enumeration(placement)(value) && value || placement.TOP;
  233.  
  234. this.reposition();
  235. }
  236.  
  237. /**
  238. Whether the QuickActions should show when the target is interacted with. See {@link QuickActionsInteractionEnum}.
  239.  
  240. @type {String}
  241. @default QuickActionsInteractionEnum.ON
  242. @name interaction
  243. @htmlattribute interaction
  244. */
  245. get interaction() {
  246. return super.interaction;
  247. }
  248.  
  249. set interaction(value) {
  250. super.interaction = value;
  251.  
  252. if (this.interaction === interaction.ON) {
  253. this._addTargetEventListeners();
  254. } else {
  255. this._removeTargetEventListeners();
  256. }
  257. }
  258.  
  259. /**
  260. Inherited from {@link Overlay#target}.
  261. */
  262. get target() {
  263. return super.target;
  264. }
  265.  
  266. set target(value) {
  267. // avoid popper initialization while connecting for first time and not opened.
  268. this._avoidPopperInit = this.open || this._popper ? false : true;
  269.  
  270. super.target = value;
  271.  
  272. const targetElement = this._getTarget(value);
  273. const prevTargetElement = this._previousTarget;
  274. const targetHasChanged = targetElement !== prevTargetElement;
  275.  
  276. if (targetElement && targetHasChanged) {
  277. // Remove listeners from the previous target
  278. if (prevTargetElement) {
  279. const previousTarget = this._getTarget(prevTargetElement);
  280. if (previousTarget) {
  281. this._removeTargetEventListeners(previousTarget);
  282. targetElement.removeAttribute('aria-haspopup');
  283. targetElement.removeAttribute('aria-owns');
  284. }
  285. }
  286.  
  287. // Set up listeners for the new target
  288. this._addTargetEventListeners();
  289.  
  290. let ariaOwns = targetElement.getAttribute('aria-owns');
  291. ariaOwns = ariaOwns && ariaOwns.length ? `${ariaOwns.trim()} ${this.id}` : this.id;
  292.  
  293. targetElement.setAttribute('aria-owns', ariaOwns);
  294. // Mark the target as owning a popup
  295. targetElement.setAttribute('aria-haspopup', 'true');
  296.  
  297. // Cache for use as previous target
  298. this._previousTarget = targetElement;
  299. }
  300. delete this._avoidPopperInit;
  301. }
  302.  
  303. get observedMessages() {
  304. return {
  305. 'coral-quickactions-item:_contentchanged': '_onItemChange',
  306. 'coral-quickactions-item:_iconchanged': '_onItemChange',
  307. 'coral-quickactions-item:_hrefchanged': '_onItemChange',
  308. 'coral-quickactions-item:_typechanged': '_onItemTypeChange'
  309. };
  310. }
  311. /**
  312. Inherited from {@link Overlay#open}.
  313. */
  314. get open() {
  315. return super.open;
  316. }
  317.  
  318. set open(value) {
  319. // If opening and stealing focus, on close, focus should be returned
  320. // to the element that had focus before QuickActions were opened.
  321. if (value &&
  322. this._focusOnShow !== this.constructor.focusOnShow.OFF) {
  323. this.returnFocusTo(document.activeElement);
  324. }
  325.  
  326. super.open = value;
  327.  
  328. this._openedOnce = true;
  329.  
  330. // Position once we can read items layout in the next frame
  331. window.requestAnimationFrame(() => {
  332. if (this.open && !this._openedBefore) {
  333. // we iterate over all the items initializing them in the correct order
  334. const items = this.items.getAll();
  335. for (let i = 0, itemCount = items.length ; i < itemCount ; i++) {
  336. this._attachItem(items[i], i);
  337. }
  338.  
  339. this._openedBefore = true;
  340. }
  341.  
  342. if (this.open) {
  343. this._layout();
  344. }
  345.  
  346. // we toggle "is-selected" on the target to indicate that the over is open
  347. const targetElement = this._getTarget();
  348. if (targetElement) {
  349. targetElement.classList.toggle('is-selected', this.open);
  350. }
  351. });
  352. }
  353.  
  354. _getButtonWidth() {
  355. if (this.closest('.coral--large')) {
  356. // 40px button width + 10px left margin
  357. return 50;
  358. } else {
  359. // 32px button width + 8px left margin
  360. return 40;
  361. }
  362. }
  363.  
  364. /** @ignore */
  365. _getTarget(targetValue) {
  366. // Use passed target
  367. targetValue = targetValue || this.target;
  368.  
  369. if (targetValue instanceof Node) {
  370. // Just return the provided Node
  371. return targetValue;
  372. }
  373.  
  374. // Dynamically get the target node based on target
  375. let newTarget = null;
  376. if (typeof targetValue === 'string') {
  377. if (targetValue === target.PARENT) {
  378. newTarget = this.parentNode;
  379. } else {
  380. // Delegate to Coral.Overlay for _prev, _next and general selector
  381. newTarget = super._getTarget(targetValue);
  382. }
  383. }
  384.  
  385. return newTarget;
  386. }
  387.  
  388. /** @ignore */
  389. _addTargetEventListeners(targetElement) {
  390. targetElement = targetElement || this._getTarget();
  391.  
  392. if (!targetElement) {
  393. return;
  394. }
  395.  
  396. // Interaction-sensitive listeners
  397. if (this.interaction === interaction.ON) {
  398. // We do not have to worry about the EventListener being called twice as duplicates are discarded
  399. targetElement.addEventListener('mouseenter', this._onTargetMouseEnter);
  400. targetElement.addEventListener('keyup', this._onTargetKeyUp);
  401. targetElement.addEventListener('keydown', this._onTargetKeyDown);
  402. targetElement.addEventListener('mouseleave', this._onTargetMouseLeave);
  403. }
  404. }
  405.  
  406. /** @ignore */
  407. _removeTargetEventListeners(targetElement) {
  408. targetElement = targetElement || this._getTarget();
  409.  
  410. if (!targetElement) {
  411. return;
  412. }
  413.  
  414. targetElement.removeEventListener('mouseenter', this._onTargetMouseEnter);
  415. targetElement.removeEventListener('keyup', this._onTargetKeyUp);
  416. targetElement.removeEventListener('keydown', this._onTargetKeyDown);
  417. targetElement.removeEventListener('mouseleave', this._onTargetMouseLeave);
  418. }
  419.  
  420. /**
  421. Toggles whether or not an item is tabbable.
  422.  
  423. @param {HTMLElement} item
  424. The item to process.
  425.  
  426. @param {Boolean} tabbable
  427. Whether the item should be marked tabbable.
  428. @ignore
  429. */
  430. _toggleTabbable(item, tabbable) {
  431. if (item) {
  432. if (tabbable) {
  433. if (item.hasAttribute('tabIndex')) {
  434. item.removeAttribute('tabIndex');
  435. }
  436. } else {
  437. item.setAttribute('tabIndex', '-1');
  438. }
  439. }
  440. }
  441.  
  442. /**
  443. Gets the subsequent or previous focusable neighbour relative to an Item button.
  444.  
  445. @param {HTMLElement} current
  446. The current button element from which to find the next selectable neighbour.
  447. @param {Boolean} [previous]
  448. Whether to look for a previous neighbour rather than a subsequent one.
  449.  
  450. @returns {HTMLElement|undefined} The focusable neighbour. Undefined if no suitable neighbour found.
  451.  
  452. @private
  453. */
  454. _getFocusableNeighbour(current, previous) {
  455. // we need to convert the result to an array in order to use .indexOf()
  456. const focusableButtons = Array.prototype.slice.call(this._getFocusableButtons());
  457. const index = focusableButtons.indexOf(current);
  458.  
  459. if (index >= 0) {
  460. if (!previous) {
  461. // Pick the next focusable button
  462. if (index < focusableButtons.length - 1) {
  463. return focusableButtons[index + 1];
  464. }
  465. }
  466. // Pick the previous focusable button
  467. else if (index !== 0) {
  468. return focusableButtons[index - 1];
  469. }
  470. }
  471. }
  472.  
  473. /**
  474. Gets the buttons, optionally excluding the more button.
  475.  
  476. @param {Boolean} excludeMore
  477. Whether to exclude the more button.
  478.  
  479. @returns {NodeList} The NodeList containing all the buttons.
  480.  
  481. @private
  482. */
  483. _getButtons(excludeMore) {
  484. let buttonSelector = '._coral-QuickActions-item';
  485. buttonSelector = excludeMore ? `${buttonSelector}:not([handle="moreButton"])` : buttonSelector;
  486.  
  487. return this.querySelectorAll(buttonSelector);
  488. }
  489.  
  490. /**
  491. An element is focusable if it is visible and not disabled.
  492.  
  493. @returns {NodeList} A NodeList containing the focusable buttons.
  494.  
  495. @private
  496. */
  497. _getFocusableButtons() {
  498. // since we use the hidden attribute to hide the items, we can rely on this attribute to determine if the button
  499. // is hidden, instead of using a more expensive :focusable selector
  500. return this.querySelectorAll(BUTTON_FOCUSABLE_SELECTOR);
  501. }
  502.  
  503. /**
  504. Gets the first focusable button.
  505.  
  506. @returns {HTMLElement|undefined}
  507. The first focusable button, undefined if none found.
  508. @ignore
  509. */
  510. _getFirstFocusableButton() {
  511. return this.querySelector(BUTTON_FOCUSABLE_SELECTOR);
  512. }
  513.  
  514. /**
  515. Gets the last focusable button.
  516.  
  517. @returns {HTMLElement|undefined}
  518. The last focusable button, undefined if none found.
  519. @ignore
  520. */
  521. _getLastFocusableButton() {
  522. const focusableButtons = this._getFocusableButtons();
  523. return focusableButtons[focusableButtons.length - 1];
  524. }
  525.  
  526. /** @ignore */
  527. _proxyClick(item) {
  528. const event = item.trigger('click');
  529.  
  530. if (!event.defaultPrevented && this.interaction === interaction.ON) {
  531. this._hideAll();
  532. }
  533. }
  534.  
  535. /**
  536. Gets data from an Item.
  537.  
  538. @param {HTMLElement} item
  539. The Item to get the data from.
  540. @returns {Object}
  541. The Item data.
  542. @ignore
  543. */
  544. _getItemData(item) {
  545. return {
  546. htmlContent: item.innerHTML,
  547. textContent: item.textContent,
  548. // fallback to empty string in case it has no icon
  549. icon: item.getAttribute('icon') || ''
  550. };
  551. }
  552.  
  553. /** @ignore */
  554. _attachItem(item, index) {
  555. // since the button has already been initialized we make sure it is up to date
  556. if (item._elements && item._elements.button) {
  557. this._updateItem(item);
  558. return;
  559. }
  560.  
  561. // if the index was not provided, we need to calculate it
  562. if (typeof index === 'undefined') {
  563. index = Array.prototype.indexOf.call(this.items.getAll(), item);
  564. }
  565.  
  566. const itemData = this._getItemData(item);
  567. const type = QuickActionsItem.type;
  568.  
  569. let button;
  570. if (item.type === type.BUTTON) {
  571. button = new Button().set({
  572. icon: itemData.icon,
  573. iconsize: Icon.size.SMALL,
  574. type: 'button',
  575. tracking: 'off'
  576. }, true);
  577. } else if (item.type === type.ANCHOR) {
  578. button = new AnchorButton().set({
  579. icon: itemData.icon,
  580. iconsize: Icon.size.SMALL,
  581. href: item.href,
  582. tracking: 'off'
  583. }, true);
  584. }
  585.  
  586. button.variant = Button.variant.QUIET_ACTION;
  587. button.classList.add('_coral-QuickActions-item');
  588. button.setAttribute('tabindex', '-1');
  589. button.setAttribute('title', itemData.textContent.trim());
  590. button.setAttribute('aria-label', itemData.textContent.trim());
  591. button.setAttribute('role', 'menuitem');
  592.  
  593. this.insertBefore(button, this.children[index]);
  594.  
  595. // ButtonList Item
  596. let buttonListItem;
  597. if (item.type === type.BUTTON) {
  598. buttonListItem = new ButtonList.Item();
  599. } else if (item.type === type.ANCHOR) {
  600. buttonListItem = new AnchorList.Item();
  601. buttonListItem.href = item.href;
  602. }
  603.  
  604. const buttonListItemParent = this._elements.buttonList;
  605.  
  606. buttonListItem.tabIndex = -1;
  607. buttonListItem.content.innerHTML = itemData.htmlContent;
  608. buttonListItem.icon = itemData.icon;
  609. buttonListItem.setAttribute('role', 'menuitem');
  610. buttonListItemParent.insertBefore(buttonListItem, buttonListItemParent.children[index]);
  611.  
  612. item._elements.button = button;
  613. item._elements.buttonListItem = buttonListItem;
  614. buttonListItem._elements.quickActionsItem = item;
  615. button._elements.quickActionsItem = item;
  616. }
  617.  
  618. /**
  619. Layout calculation; collapses QuickActions as necessary.
  620. */
  621. _layout() {
  622. // Set the width of the QuickActions to match that of the target
  623. this._setWidth();
  624.  
  625. const buttons = this._getButtons(true);
  626.  
  627. if (!buttons.length) {
  628. return;
  629. }
  630.  
  631. const buttonListItems = this._elements.buttonList.items.getAll();
  632.  
  633. // Temporarily display the QuickActions so we can do the calculation
  634. const display = this.style.display;
  635. let temporarilyShown = false;
  636.  
  637. if (!this.open) {
  638. this.style.left -= 10000;
  639. this.style.top -= 10000;
  640. this.style.display = 'block';
  641. temporarilyShown = true;
  642. }
  643.  
  644. const totalAvailableWidth = this.offsetWidth;
  645.  
  646. let totalFittingButtons = 0;
  647. let widthUsed = 0;
  648. const buttonWidth = this._getButtonWidth();
  649. while (totalAvailableWidth > widthUsed) {
  650. widthUsed += buttonWidth;
  651.  
  652. if (totalAvailableWidth > widthUsed) {
  653. totalFittingButtons++;
  654. }
  655. }
  656.  
  657. // Remove one to avoid taking full width space
  658. totalFittingButtons--;
  659.  
  660. const threshold = this.threshold;
  661. const handleThreshold = threshold > 0;
  662. const moreButtonsThanThreshold = handleThreshold && buttons.length > threshold;
  663. const collapse = buttons.length > totalFittingButtons || moreButtonsThanThreshold;
  664.  
  665. // +1 to account for the more button
  666. const collapseToThreshold = collapse && handleThreshold && threshold + 1 < totalFittingButtons;
  667.  
  668. let totalButtons;
  669. if (collapse) {
  670. if (collapseToThreshold) {
  671. totalButtons = threshold + 1;
  672. } else {
  673. totalButtons = totalFittingButtons;
  674. }
  675. } else {
  676. totalButtons = buttons.length;
  677. }
  678.  
  679. // Show all Buttons and ButtonList Items
  680. for (let i = 0 ; i < buttons.length ; i++) {
  681. this._toggleTabbable(buttons[i], false);
  682. buttons[i].hidden = false;
  683. if (buttonListItems[i]) {
  684. buttonListItems[i].hidden = false;
  685. }
  686. }
  687.  
  688. this._toggleTabbable(this._elements.moreButton, false);
  689.  
  690. if (collapse) {
  691. if (totalButtons > 0) {
  692. // Hide the buttons we're collapsing
  693. for (let j = totalButtons - 1 ; j < buttons.length ; j++) {
  694. buttons[j].hide();
  695. }
  696.  
  697. // Hide the ButtonList items
  698. for (let k = 0 ; k < totalButtons - 1 ; k++) {
  699. buttonListItems[k].hide();
  700. }
  701.  
  702. // Mark the first button as tabbable
  703. this._toggleTabbable(buttons[0], true);
  704. } else {
  705. this._toggleTabbable(this._elements.moreButton, true);
  706. }
  707.  
  708. this._elements.moreButton.show();
  709. } else {
  710. // Mark the first button as tabbable
  711. this._toggleTabbable(buttons[0], true);
  712. this._elements.moreButton.hide();
  713. }
  714.  
  715. this._setWidth(true);
  716.  
  717. // Reset the QuickActions display
  718. if (temporarilyShown) {
  719. this.style.left += 10000;
  720. this.style.top += 10000;
  721. this.style.display = display;
  722. }
  723.  
  724. // Do a reposition of the overlay
  725. this.reposition();
  726. }
  727.  
  728. /**
  729. Sets the width of QuickActions from the target.
  730.  
  731. @ignore
  732. */
  733. _setWidth(buttonWidthBased) {
  734. let width = 0;
  735. const targetElement = this._getTarget();
  736.  
  737. if (targetElement) {
  738. const maxWidth = targetElement.offsetWidth;
  739.  
  740. if (buttonWidthBased) {
  741. const visibleButtons = this.querySelectorAll('._coral-QuickActions-item:not([hidden])');
  742. const buttonWidth = this._getButtonWidth();
  743.  
  744. if (visibleButtons.length) {
  745. for (let i = 0 ; i < visibleButtons.length && width <= maxWidth ; i++) {
  746. width += buttonWidth;
  747. }
  748.  
  749. this.style.width = `${width}px`;
  750. }
  751. } else {
  752. this.style.width = `${maxWidth}px`;
  753. }
  754. }
  755. }
  756.  
  757. /** @ignore */
  758. _setButtonListHeight() {
  759. // Set height of ButtonList
  760. this._elements.buttonList.style.height = '';
  761.  
  762. // Measure actual height
  763. const style = window.getComputedStyle(this._elements.buttonList);
  764. const height = parseInt(style.height, 10);
  765. const maxHeight = parseInt(style.maxHeight, 10);
  766.  
  767. if (height < maxHeight) {
  768. // Make it scrollable
  769. this._elements.buttonList.style.height = `${height - 1}px`;
  770. }
  771. }
  772.  
  773. /** @ignore */
  774. _isInternalToComponent(element) {
  775. const targetElement = this._getTarget();
  776.  
  777. return element && (this.contains(element) || this._elements.overlay.contains(element) || targetElement && targetElement.contains(element));
  778. }
  779.  
  780. /** @ignore */
  781. _onWindowResize() {
  782. this._layout();
  783. }
  784.  
  785. _handleEscape(event) {
  786. if (typeof this._isTop === 'undefined') {
  787. this._isTop = this._isTopOverlay();
  788. }
  789.  
  790. // Debounce
  791. if (this._timeout !== null) {
  792. window.clearTimeout(this._timeout);
  793. }
  794.  
  795. this._timeout = window.setTimeout(() => {
  796. if (this._isTop) {
  797. super._handleEscape(event);
  798. }
  799.  
  800. this._isTop = undefined;
  801. });
  802. }
  803.  
  804. /** @ignore */
  805. _onMouseOut(event) {
  806. const toElement = event.toElement || event.relatedTarget;
  807.  
  808. // Hide if we mouse leave to any element external to the component and its target
  809. if (!this._isInternalToComponent(toElement) && this.interaction === interaction.ON) {
  810. this._hideAll();
  811. }
  812. }
  813.  
  814. _hideAll() {
  815. this.hide();
  816. this._elements.overlay.hide();
  817. }
  818.  
  819. /** @ignore */
  820. _onTargetMouseEnter(event) {
  821. const fromElement = event.fromElement || event.relatedTarget;
  822.  
  823. // Open if we aren't already
  824. if (!this.open && !this._isInternalToComponent(fromElement)) {
  825. this.show();
  826.  
  827. this._trackEvent('display', 'coral-quickactions', event);
  828. }
  829. }
  830.  
  831. /** @ignore */
  832. _onTargetKeyUp(event) {
  833. const keyCode = event.keyCode;
  834.  
  835. // shift + F10 or ctrl + space (http://www.w3.org/WAI/PF/aria-practices/#popupmenu)
  836. if (event.shiftKey && keyCode === 121 || event.ctrlKey && keyCode === 32) {
  837. if (!this.open) {
  838. if (this.interaction === interaction.ON) {
  839. // Launched via keyboard and interaction enabled implies a focus trap and return focus.
  840. // Remember the relevant properties and return their values on hide.
  841. this._previousTrapFocus = this.trapFocus;
  842. this._previousReturnFocus = this.returnFocus;
  843. this._previousFocusOnShow = this.focusOnShow;
  844. this.trapFocus = this.constructor.trapFocus.ON;
  845. this.returnFocus = this.constructor.returnFocus.ON;
  846. this.focusOnShow = this.constructor.focusOnShow.ON;
  847. }
  848.  
  849. this.show();
  850. }
  851. }
  852. }
  853.  
  854. _onTargetKeyDown(event) {
  855. const keyCode = event.keyCode;
  856.  
  857. // shift + F10 or ctrl + space (http://www.w3.org/WAI/PF/aria-practices/#popupmenu)
  858. if (event.shiftKey && keyCode === 121 || event.ctrlKey && keyCode === 32) {
  859. // Prevent default context menu show or page scroll behaviour
  860. event.preventDefault();
  861. }
  862. }
  863.  
  864. /** @ignore */
  865. _onTargetMouseLeave(event) {
  866. const toElement = event.toElement || event.relatedTarget;
  867.  
  868. // Do not hide if we entered the quick actions
  869. if (!this._isInternalToComponent(toElement)) {
  870. this._hideAll();
  871. }
  872. }
  873.  
  874. /** @ignore */
  875. _onHomeKeypress(event) {
  876. // prevents the page from scrolling
  877. event.preventDefault();
  878.  
  879. const firstFocusableButton = this._getFirstFocusableButton();
  880.  
  881. // Jump focus to the first focusable button
  882. if (firstFocusableButton) {
  883. firstFocusableButton.focus();
  884. }
  885. }
  886.  
  887. /** @ignore */
  888. _onEndKeypress(event) {
  889. // prevents the page from scrolling
  890. event.preventDefault();
  891.  
  892. const lastFocusableButton = this._getLastFocusableButton();
  893.  
  894. // Jump focus to the last focusable button
  895. if (lastFocusableButton) {
  896. lastFocusableButton.focus();
  897. }
  898. }
  899.  
  900. /** @ignore */
  901. _onButtonKeypressNext(event) {
  902. event.preventDefault();
  903.  
  904. if (document.activeElement === this) {
  905. const firstFocusableButton = this._getFirstFocusableButton();
  906.  
  907. if (firstFocusableButton) {
  908. firstFocusableButton.focus();
  909. }
  910. } else {
  911. // Handle key presses that imply focus of the next focusable button
  912. const nextButton = this._getFocusableNeighbour(event.matchedTarget);
  913. if (nextButton) {
  914. nextButton.focus();
  915. } else if (event.key === 'ArrowDown' && document.activeElement === this._elements.moreButton) {
  916. this._elements.moreButton.click();
  917. }
  918. }
  919. }
  920.  
  921. /** @ignore */
  922. _onButtonKeypressPrevious(event) {
  923. event.preventDefault();
  924.  
  925. if (document.activeElement === this) {
  926. const lastFocusableButton = this._getLastFocusableButton();
  927.  
  928. if (lastFocusableButton) {
  929. lastFocusableButton.focus();
  930. }
  931. } else {
  932. // Handle key presses that imply focus of the previous focusable button
  933. const previousButton = this._getFocusableNeighbour(event.matchedTarget, true);
  934. if (previousButton) {
  935. previousButton.focus();
  936. }
  937. }
  938. }
  939.  
  940. /** @ignore */
  941. _onButtonClick(event) {
  942. event.stopPropagation();
  943.  
  944. if (this._preventClick) {
  945. return;
  946. }
  947.  
  948. const button = event.matchedTarget;
  949. const item = button._elements.quickActionsItem;
  950. this._proxyClick(item);
  951.  
  952. // Prevent double click or alternate selection during animation
  953. window.setTimeout(() => {
  954. this._preventClick = false;
  955. }, this._overlayAnimationTime);
  956.  
  957. this._preventClick = true;
  958.  
  959. this._trackEvent('click', 'coral-quickactions-item', event, item);
  960. }
  961.  
  962. _onMoreButtonClick(event) {
  963. const button = event.matchedTarget;
  964. const item = button._elements.quickActionsItem;
  965.  
  966. this._trackEvent('click', 'coral-quickactions-more', event, item);
  967. }
  968.  
  969. _onFocus() {
  970. if (this._focusOnShow === this.constructor.focusOnShow.OFF && this._returnFocus !== this.constructor.returnFocus.ON) {
  971. const targetElement = this._getTarget();
  972. if (targetElement) {
  973. if (!this._previousReturnFocus) {
  974. this._previousReturnFocus = this._returnFocus;
  975. this.returnFocus = this.constructor.returnFocus.ON;
  976. }
  977.  
  978. if (!this._previousElementToFocusWhenHidden) {
  979. this._previousElementToFocusWhenHidden = this._elementToFocusWhenHidden;
  980. this._elementToFocusWhenHidden = targetElement;
  981. }
  982. }
  983. }
  984. }
  985.  
  986. _onBlur() {
  987. if (this._focusOnShow === this.constructor.focusOnShow.OFF) {
  988. if (this._previousReturnFocus) {
  989. this.returnFocus = this._previousReturnFocus;
  990. this._previousReturnFocus = undefined;
  991. }
  992.  
  993. if (this._previousElementToFocusWhenHidden) {
  994. this._elementToFocusWhenHidden = this._previousElementToFocusWhenHidden;
  995. this._previousElementToFocusWhenHidden = undefined;
  996. }
  997. }
  998. }
  999.  
  1000. /** @ignore */
  1001. _onOverlayBeforeOpen(event) {
  1002. if (event.target === this) {
  1003. // Reset double-click prevention flag
  1004. this._preventClick = false;
  1005. this._layout();
  1006. } else if (event.target === this._elements.overlay) {
  1007. // do not allow internal Overlay events to escape QuickActions
  1008. event.stopImmediatePropagation();
  1009. this._setButtonListHeight();
  1010. }
  1011. }
  1012.  
  1013. /** @ignore */
  1014. _onOverlayBeforeClose(event) {
  1015. if (event.target === this._elements.overlay) {
  1016. // do not allow internal Overlay events to escape QuickActions
  1017. event.stopImmediatePropagation();
  1018. }
  1019. }
  1020.  
  1021. /** @ignore */
  1022. _onOverlayOpen(event) {
  1023. if (event.target === this._elements.overlay) {
  1024. // do not allow internal Overlay events to escape QuickActions
  1025. event.stopImmediatePropagation();
  1026.  
  1027. this._elements.moreButton.setAttribute('aria-expanded', 'true');
  1028. }
  1029. }
  1030.  
  1031. /** @ignore */
  1032. _onOverlayClose(event) {
  1033. if (event.target === this) {
  1034. this._elements.overlay.open = false;
  1035.  
  1036. // Return the trapFocus and returnFocus properties to their state before open.
  1037. // Handles the keyboard launch and interaction enabled case, which implies focus trap and focus return.
  1038. // Wait a frame as this is called before the 'open' property sync. Otherwise, returnFocus is set prematurely.
  1039. window.requestAnimationFrame(() => {
  1040. if (this._previousTrapFocus) {
  1041. this.trapFocus = this._previousTrapFocus;
  1042. if (this.trapFocus !== this.constructor.trapFocus.ON) {
  1043. this.removeAttribute('tabindex');
  1044. }
  1045. this._previousTrapFocus = undefined;
  1046. }
  1047.  
  1048. if (this._previousReturnFocus) {
  1049. this.returnFocus = this._previousReturnFocus;
  1050. this._previousReturnFocus = undefined;
  1051. }
  1052.  
  1053. if (this._previousFocusOnShow) {
  1054. this.focusOnShow = this._previousFocusOnShow;
  1055. this._previousFocusOnShow = undefined;
  1056. }
  1057. });
  1058. } else if (event.target === this._elements.overlay) {
  1059. // do not allow internal Overlay events to escape QuickActions
  1060. event.stopImmediatePropagation();
  1061. this._elements.moreButton.setAttribute('aria-expanded', 'false');
  1062. }
  1063. }
  1064.  
  1065. /** @ignore */
  1066. _onOverlayPositioned(event) {
  1067. if (event.target === this._elements.overlay) {
  1068. // do not allow internal Overlay events to escape QuickActions
  1069. event.stopImmediatePropagation();
  1070. }
  1071. }
  1072.  
  1073. _onAnimate(event) {
  1074. if (event.target === this) {
  1075. if (this.placement === placement.BOTTOM) {
  1076. this.style.marginTop = `${-parseFloat(this.lengthOffset) + 8}px`;
  1077. } else {
  1078. this.style.marginTop = `${parseFloat(this.lengthOffset) - 8}px`;
  1079. }
  1080. }
  1081. }
  1082.  
  1083. /** @ignore */
  1084. _onButtonListItemClick(event) {
  1085. // stops propagation so that this event remains internal to the component
  1086. event.stopImmediatePropagation();
  1087.  
  1088. const buttonListItem = event.matchedTarget;
  1089.  
  1090. if (!buttonListItem) {
  1091. return;
  1092. }
  1093.  
  1094. const item = buttonListItem._elements.quickActionsItem;
  1095. this._proxyClick(item);
  1096.  
  1097. this._trackEvent('click', 'coral-quickactions-item', event, item);
  1098. }
  1099.  
  1100. /** @ignore */
  1101. _onItemRemoved(item) {
  1102. this._removeItemElements(item);
  1103. }
  1104.  
  1105. /** @ignore */
  1106. _onCollectionChange(addedNodes) {
  1107. // Delay the item initialization if the component has not been opened before
  1108. if (!this._openedBefore) {
  1109. return;
  1110. }
  1111.  
  1112. // we use the items to be able to find out the index of the added item in reference to the whole collection
  1113. const items = this.items.getAll();
  1114. let index;
  1115. for (let i = 0, addedNodesCount = addedNodes.length ; i < addedNodesCount ; i++) {
  1116. // we need to know the item's position in relation to the others
  1117. index = Array.prototype.indexOf.call(items, addedNodes[i]);
  1118. this._attachItem(addedNodes[i], index);
  1119. }
  1120.  
  1121. this._layout();
  1122. }
  1123.  
  1124. /** @ignore */
  1125. _onItemChange(event) {
  1126. // stops propagation so that this event remains internal to the component
  1127. event.stopImmediatePropagation();
  1128.  
  1129. this._updateItem(event.target);
  1130. }
  1131.  
  1132. /** @ignore */
  1133. _onItemTypeChange(event) {
  1134. // stops propagation so that this event remains internal to the component
  1135. event.stopImmediatePropagation();
  1136.  
  1137. // delay this execution while opening quickaction to avoid performance delay
  1138. if(this._openedBefore || this.open) {
  1139. const item = event.target;
  1140. this._removeItemElements(item);
  1141. this._attachItem(item);
  1142. this._layout();
  1143. }
  1144. }
  1145.  
  1146. /** @ignore */
  1147. _removeItemElements(item) {
  1148. // Remove the associated Button and ButtonList elements
  1149. if (item._elements.button) {
  1150. item._elements.button.remove();
  1151. item._elements.button._elements.quickActionsItem = undefined;
  1152. item._elements.button = undefined;
  1153. }
  1154.  
  1155. if (item._elements.buttonListItem) {
  1156. item._elements.buttonListItem.remove();
  1157. item._elements.buttonListItem._elements.quickActionsItem = null;
  1158. item._elements.buttonListItem = undefined;
  1159. }
  1160. }
  1161.  
  1162. /** @ignore */
  1163. _updateItem(item) {
  1164. const itemData = this._getItemData(item);
  1165. const type = QuickActionsItem.type;
  1166.  
  1167. const button = item._elements.button;
  1168. if (button) {
  1169. button.icon = itemData.icon;
  1170. button.setAttribute('title', itemData.textContent.trim());
  1171. button.setAttribute('aria-label', itemData.textContent.trim());
  1172. button[item.type === type.ANCHOR ? 'setAttribute' : 'removeAttribute']('href', item.href);
  1173. }
  1174.  
  1175. const buttonListItem = item._elements.buttonListItem;
  1176. if (buttonListItem) {
  1177. buttonListItem.content.innerHTML = itemData.htmlContent;
  1178. buttonListItem[item.type === type.ANCHOR ? 'setAttribute' : 'removeAttribute']('href', item.href);
  1179. buttonListItem.icon = itemData.icon;
  1180. }
  1181. }
  1182.  
  1183. // Maps placement CENTER with RIGHT
  1184. _toggleCenterPlacement(toggle) {
  1185. if (toggle) {
  1186. if (this.placement === placement.CENTER) {
  1187. this._placement = Overlay.placement.RIGHT;
  1188.  
  1189. this._oldInner = this._inner;
  1190. this._inner = false;
  1191. this._oldLengthOffset = this._lengthOffset;
  1192. this._lengthOffset = '-50%r - 50%p';
  1193. }
  1194. } else if (this._placement === Overlay.placement.RIGHT) {
  1195. this._placement = placement.CENTER;
  1196.  
  1197. // Restore
  1198. this._inner = this._oldInner;
  1199. this._lengthOffset = this._oldLengthOffset;
  1200. }
  1201. }
  1202.  
  1203. /** @ignore */
  1204. reposition(forceReposition) {
  1205. // Override to support placement.CENTER
  1206. this._toggleCenterPlacement(true);
  1207. super.reposition(forceReposition);
  1208. this._toggleCenterPlacement(false);
  1209.  
  1210. if (this._openedOnce) {
  1211. // PopperJS inner property issue https://github.com/FezVrasta/popper.js/issues/400
  1212. if (this.placement === placement.BOTTOM) {
  1213. this.style.marginTop = `-${parseFloat(this.lengthOffset)}px`;
  1214. } else if (this.placement === placement.TOP) {
  1215. this.style.marginTop = `${parseFloat(this.lengthOffset)}px`;
  1216. } else if (this.placement === placement.CENTER) {
  1217. this.style.marginTop = `${parseFloat(this.lengthOffset) - 4}px`;
  1218. }
  1219. }
  1220. }
  1221.  
  1222. // Override placement and target
  1223. /**
  1224. Returns {@link QuickActions} placement options.
  1225.  
  1226. @return {QuickActionsPlacementEnum}
  1227. */
  1228. static get placement() {
  1229. return placement;
  1230. }
  1231.  
  1232. /**
  1233. Returns {@link QuickActions} target options.
  1234.  
  1235. @return {QuickActionsTargetEnum}
  1236. */
  1237. static get target() {
  1238. return target;
  1239. }
  1240.  
  1241. /** @ignore */
  1242. static get observedAttributes() {
  1243. return super.observedAttributes.concat(['threshold']);
  1244. }
  1245.  
  1246. /** @ignore */
  1247. connectedCallback() {
  1248. super.connectedCallback();
  1249.  
  1250. const overlay = this._elements.overlay;
  1251. // Cannot be open by default when rendered
  1252. overlay.removeAttribute('open');
  1253. // Restore in DOM
  1254. if (overlay._parent) {
  1255. overlay._parent.appendChild(overlay);
  1256. }
  1257. }
  1258.  
  1259. /** @ignore */
  1260. render() {
  1261. super.render();
  1262.  
  1263. this.classList.add(CLASSNAME);
  1264.  
  1265. // Define QuickActions as a menu
  1266. this.setAttribute('role', 'menu');
  1267.  
  1268. // Support cloneNode
  1269. ['moreButton', 'overlay'].forEach((handleName) => {
  1270. const handle = this.querySelector(`[handle="${handleName}"]`);
  1271. if (handle) {
  1272. handle.remove();
  1273. }
  1274. });
  1275.  
  1276. // Render template
  1277. const frag = document.createDocumentFragment();
  1278. frag.appendChild(this._elements.moreButton);
  1279. frag.appendChild(this._elements.overlay);
  1280.  
  1281. // avoid popper initialisation if popper neither exist nor overlay opened.
  1282. this._elements.overlay._avoidPopperInit = this._elements.overlay.open || this._elements.overlay._popper ? false : true;
  1283.  
  1284. // Link target
  1285. this._elements.overlay.target = this._elements.moreButton;
  1286.  
  1287. this.appendChild(frag);
  1288.  
  1289. // set this to false after overlay has been connected to avoid connected callback target setting
  1290. delete this._elements.overlay._avoidPopperInit;
  1291. }
  1292.  
  1293. /** @ignore */
  1294. disconnectedCallback() {
  1295. super.disconnectedCallback();
  1296.  
  1297. const overlay = this._elements.overlay;
  1298. // In case it was moved out don't forget to remove it
  1299. if (!this.contains(overlay)) {
  1300. overlay._parent = overlay._repositioned ? document.body : this;
  1301. overlay.remove();
  1302. }
  1303. }
  1304. });
  1305.  
  1306. export default QuickActions;