ExamplesPlaygroundReference Source

coral-spectrum/coral-component-popover/src/scripts/Popover.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 {ExtensibleOverlay, Overlay} from '../../../coral-component-overlay';
  14. import {Icon} from '../../../coral-component-icon';
  15. // Popover relies on Dialog styles partially
  16. import '../../../coral-component-dialog';
  17. import base from '../templates/base';
  18. import {commons, transform, validate, i18n} from '../../../coral-utils';
  19. import {Decorator} from '../../../coral-decorator';
  20.  
  21. const CLASSNAME = '_coral-Popover';
  22.  
  23. const OFFSET = 5;
  24.  
  25. // Used to map icon with variant
  26. const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
  27.  
  28. // If it's empty and has no non-textnode children
  29. const _isEmpty = (el) => !el || el.children.length === 0 && el.textContent.replace(/\s*/g, '') === '';
  30.  
  31. /**
  32. Enumeration for {@link Popover} closable state.
  33.  
  34. @typedef {Object} PopoverClosableEnum
  35.  
  36. @property {String} ON
  37. Show a close button on the popover and close the popover when clicked.
  38. @property {String} OFF
  39. Do not show a close button. Elements with the <code>coral-close</code> attributes will still close the
  40. popover.
  41. */
  42. const closable = {
  43. ON: 'on',
  44. OFF: 'off'
  45. };
  46.  
  47. /**
  48. Enumeration for {@link Popover} variants.
  49.  
  50. @typedef {Object} PopoverVariantEnum
  51.  
  52. @property {String} DEFAULT
  53. A default popover without header icon.
  54. @property {String} ERROR
  55. A popover with an error header and icon, indicating that an error has occurred.
  56. @property {String} WARNING
  57. A popover with a warning header and icon, notifying the user of something important.
  58. @property {String} SUCCESS
  59. A popover with a success header and icon, indicates to the user that an operation was successful.
  60. @property {String} HELP
  61. A popover with a question header and icon, provides the user with help.
  62. @property {String} INFO
  63. A popover with an info header and icon, informs the user of non-critical information.
  64. */
  65. const variant = {
  66. DEFAULT: 'default',
  67. ERROR: 'error',
  68. WARNING: 'warning',
  69. SUCCESS: 'success',
  70. HELP: 'help',
  71. INFO: 'info',
  72. _COACHMARK: '_coachmark'
  73. };
  74.  
  75. // A string of all possible variant classnames
  76. const ALL_VARIANT_CLASSES = [];
  77. for (const variantValue in variant) {
  78. if (variantValue !== 'COACHMARK') {
  79. ALL_VARIANT_CLASSES.push(`_coral-Dialog--${variant[variantValue]}`);
  80. }
  81. }
  82.  
  83. // A string of all possible placement classnames
  84. const placement = Overlay.placement;
  85. const ALL_PLACEMENT_CLASSES = [];
  86. for (const placementKey in placement) {
  87. ALL_PLACEMENT_CLASSES.push(`${CLASSNAME}--${placement[placementKey]}`);
  88. }
  89.  
  90. /**
  91. @class Coral.Popover
  92. @classdesc A Popover component for small overlay content.
  93. @htmltag coral-popover
  94. @extends {Overlay}
  95. */
  96. const Popover = Decorator(class extends ExtensibleOverlay {
  97. /** @ignore */
  98. constructor() {
  99. super();
  100.  
  101. // Prepare templates
  102. this._elements = commons.extend(this._elements, {
  103. // Fetch or create the content zone elements
  104. header: this.querySelector('coral-popover-header') || document.createElement('coral-popover-header'),
  105. content: this.querySelector('coral-popover-content') || document.createElement('coral-popover-content'),
  106. footer: this.querySelector('coral-popover-footer') || document.createElement('coral-popover-footer')
  107. });
  108. base.call(this._elements, {i18n});
  109.  
  110. // Events
  111. this._delegateEvents({
  112. 'global:capture:click': '_handleClick',
  113. 'coral-overlay:positioned': '_onPositioned',
  114. 'coral-overlay:_animate': '_onAnimate',
  115. });
  116.  
  117. // Override defaults from Overlay
  118. this._focusOnShow = this.constructor.focusOnShow.ON;
  119. this._trapFocus = this.constructor.trapFocus.ON;
  120. this._returnFocus = this.constructor.returnFocus.ON;
  121. this._overlayAnimationTime = this.constructor.FADETIME;
  122. this._lengthOffset = OFFSET;
  123.  
  124. // Listen for mutations
  125. ['header', 'footer'].forEach((name) => {
  126. this[`_${name}Observer`] = new MutationObserver(() => {
  127. this._hideContentZoneIfEmpty(name);
  128. this._toggleFlyout();
  129. });
  130.  
  131. // Watch for changes
  132. this._observeContentZone(name);
  133. });
  134. }
  135.  
  136. /**
  137. The popover's content element.
  138.  
  139. @contentzone
  140. @name content
  141. @type {PopoverContent}
  142. */
  143. get content() {
  144. return this._getContentZone(this._elements.content);
  145. }
  146.  
  147. set content(value) {
  148. this._setContentZone('content', value, {
  149. handle: 'content',
  150. tagName: 'coral-popover-content',
  151. insert: function (content) {
  152. content.classList.add('_coral-Dialog-content');
  153. const footer = this.footer;
  154. // The content should always be before footer
  155. this.insertBefore(content, this.contains(footer) && footer || null);
  156. }
  157. });
  158. }
  159.  
  160. /**
  161. The popover's header element.
  162.  
  163. @contentzone
  164. @name header
  165. @type {PopoverHeader}
  166. */
  167. get header() {
  168. return this._getContentZone(this._elements.header);
  169. }
  170.  
  171. set header(value) {
  172. this._setContentZone('header', value, {
  173. handle: 'header',
  174. tagName: 'coral-popover-header',
  175. insert: function (header) {
  176. header.classList.add('_coral-Dialog-title');
  177. this._elements.headerWrapper.insertBefore(header, this._elements.headerWrapper.firstChild);
  178. },
  179. set: function () {
  180. // Stop observing the old header and observe the new one
  181. this._observeContentZone('header');
  182.  
  183. // Check if header needs to be hidden
  184. this._hideContentZoneIfEmpty('header');
  185. }
  186. });
  187. }
  188.  
  189. /**
  190. The popover's footer element.
  191.  
  192. @type {PopoverFooter}
  193. @contentzone
  194. */
  195. get footer() {
  196. return this._getContentZone(this._elements.footer);
  197. }
  198.  
  199. set footer(value) {
  200. this._setContentZone('footer', value, {
  201. handle: 'footer',
  202. tagName: 'coral-popover-footer',
  203. insert: function (footer) {
  204. footer.classList.add('_coral-Dialog-footer');
  205. // The footer should always be after content
  206. this.appendChild(footer);
  207. },
  208. set: function () {
  209. // Stop observing the old header and observe the new one
  210. this._observeContentZone('footer');
  211.  
  212. // Check if header needs to be hidden
  213. this._hideContentZoneIfEmpty('footer');
  214. }
  215. });
  216. }
  217.  
  218. /**
  219. The popover's variant. See {@link PopoverVariantEnum}.
  220.  
  221. @type {String}
  222. @default PopoverVariantEnum.DEFAULT
  223. @htmlattribute variant
  224. @htmlattributereflected
  225. */
  226. get variant() {
  227. return this._variant || variant.DEFAULT;
  228. }
  229.  
  230. set variant(value) {
  231. value = transform.string(value).toLowerCase();
  232. this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
  233. this._reflectAttribute('variant', this._variant);
  234.  
  235. // Insert SVG icon
  236. this._insertTypeIcon();
  237.  
  238. // Remove all variant classes
  239. this.classList.remove(...ALL_VARIANT_CLASSES);
  240.  
  241. // Toggle dialog mode
  242. this._toggleFlyout();
  243.  
  244. if (this._variant === variant._COACHMARK) {
  245. // ARIA
  246. this.setAttribute('role', 'dialog');
  247.  
  248. this._toggleCoachMark(true);
  249. } else {
  250. this._toggleCoachMark(false);
  251.  
  252. if (this._variant === variant.DEFAULT) {
  253. // ARIA
  254. if (!this.hasAttribute('role')) {
  255. this.setAttribute('role', 'dialog');
  256. }
  257. } else {
  258. // Set new variant class
  259. this.classList.add(`_coral-Dialog--${this._variant}`);
  260.  
  261. // ARIA
  262. this.setAttribute('role', 'alertdialog');
  263. }
  264. }
  265. }
  266.  
  267. /**
  268. Whether the popover should have a close button. See {@link PopoverClosableEnum}.
  269.  
  270. @type {String}
  271. @default PopoverClosableEnum.OFF
  272. @htmlattribute closable
  273. @htmlattributereflected
  274. */
  275. get closable() {
  276. return this._closable || closable.OFF;
  277. }
  278.  
  279. set closable(value) {
  280. value = transform.string(value).toLowerCase();
  281. this._closable = validate.enumeration(closable)(value) && value || closable.OFF;
  282. this._reflectAttribute('closable', this._closable);
  283.  
  284. this._elements.closeButton.style.display = this._closable === closable.ON ? 'block' : 'none';
  285. }
  286.  
  287. /**
  288. Inherited from {@link Overlay#target}.
  289. */
  290. get target() {
  291. return super.target;
  292. }
  293.  
  294. set target(value) {
  295. // avoid popper initialization while connecting for first time and not opened.
  296. this._avoidPopperInit = this.open || this._popper ? false : true;
  297.  
  298. super.target = value;
  299.  
  300. // Coach Mark specific
  301. const target = this._getTarget();
  302. if (target && target.tagName === 'CORAL-COACHMARK') {
  303. this.setAttribute('variant', variant._COACHMARK);
  304. }
  305.  
  306. this._setAriaExpandedOnTarget();
  307.  
  308. delete this._avoidPopperInit;
  309. }
  310.  
  311. /**
  312. Inherited from {@link Overlay#open}.
  313. */
  314. get open() {
  315. return super.open;
  316. }
  317.  
  318. set open(value) {
  319. super.open = value;
  320.  
  321. const target = this._getTarget();
  322. if (target) {
  323. const is = target.getAttribute('is');
  324. if (is === 'coral-button' || is === 'coral-anchorbutton') {
  325. target.classList.toggle('is-selected', this.open);
  326. }
  327.  
  328. this._setAriaExpandedOnTarget();
  329. }
  330. }
  331.  
  332. /**
  333. @ignore
  334.  
  335. Not supported anymore.
  336. */
  337. get icon() {
  338. return this._icon || '';
  339. }
  340.  
  341. set icon(value) {
  342. this._icon = transform.string(value);
  343. }
  344.  
  345. _setAriaExpandedOnTarget() {
  346. const target = this._getTarget();
  347. if (target) {
  348. const hasPopupAttribute = target.hasAttribute('aria-haspopup');
  349. if (hasPopupAttribute || target.querySelector('[aria-haspopup]') !== null) {
  350. const targetElements = hasPopupAttribute ? [target] : target.querySelectorAll('[aria-haspopup]');
  351. targetElements.forEach((targetElement) => targetElement.setAttribute('aria-expanded', this.open));
  352. }
  353. }
  354. }
  355.  
  356. _onPositioned(event) {
  357. if (this.open) {
  358. // Set arrow placement
  359. this.classList.remove(...ALL_PLACEMENT_CLASSES);
  360. this.classList.add(`${CLASSNAME}--${event.detail.placement}`);
  361. }
  362. }
  363.  
  364. _onAnimate() {
  365. // popper attribute
  366. const popperPlacement = this.getAttribute('x-placement');
  367.  
  368. // popper takes care of setting left, top to 0 on positioning
  369. if (popperPlacement === 'left') {
  370. this.style.left = '8px';
  371. } else if (popperPlacement === 'top') {
  372. this.style.top = '8px';
  373. } else if (popperPlacement === 'right') {
  374. this.style.left = '-8px';
  375. } else if (popperPlacement === 'bottom') {
  376. this.style.top = '-8px';
  377. }
  378. }
  379.  
  380. _insertTypeIcon() {
  381. if (this._elements.icon) {
  382. this._elements.icon.remove();
  383. }
  384.  
  385. let variantValue = this.variant;
  386.  
  387. // Warning icon is same as ERROR icon
  388. if (variantValue === variant.WARNING || variantValue === variant.ERROR) {
  389. variantValue = 'alert';
  390. }
  391.  
  392. // Inject the SVG icon
  393. if (variantValue !== variant.DEFAULT && variantValue !== variant._COACHMARK) {
  394. const iconName = capitalize(variantValue);
  395. this._elements.headerWrapper.insertAdjacentHTML('beforeend', Icon._renderSVG(`spectrum-css-icon-${iconName}Medium`, ['_coral-Dialog-typeIcon', `_coral-UIIcon-${iconName}Medium`]));
  396. this._elements.icon = this.querySelector('._coral-Dialog-typeIcon');
  397. }
  398. }
  399.  
  400. _observeContentZone(name) {
  401. const observer = this[`_${name}Observer`];
  402. if (observer) {
  403. observer.disconnect();
  404. observer.observe(this._elements[name], {
  405. // Catch changes to childList
  406. childList: true,
  407. // Catch changes to textContent
  408. characterData: true,
  409. // Monitor any child node
  410. subtree: true
  411. });
  412. }
  413. }
  414.  
  415. _hideContentZoneIfEmpty(name) {
  416. const contentZone = this._elements[name];
  417. const target = name === 'header' ? this._elements.headerWrapper : contentZone;
  418.  
  419. // If it's empty and has no non-textnode children, hide the header
  420. const hiddenValue = _isEmpty(contentZone);
  421.  
  422. // Only bother if the hidden status has changed
  423. if (hiddenValue !== target.hidden) {
  424. target.hidden = hiddenValue;
  425.  
  426. // Reposition as the height has changed
  427. this.reposition();
  428. }
  429. }
  430.  
  431. _toggleCoachMark(isCoachMark) {
  432. this.classList.toggle('_coral-CoachMarkPopover', isCoachMark);
  433. this._elements.headerWrapper.classList.toggle('_coral-Dialog-header', !isCoachMark);
  434. this._elements.headerWrapper.classList.toggle('_coral-CoachMarkPopover-header', isCoachMark);
  435.  
  436. ['header', 'content', 'footer'].forEach((contentZone, i) => {
  437. const el = this[contentZone];
  438. const type = i === 0 ? 'title' : contentZone;
  439.  
  440. if (el) {
  441. el.classList.toggle(`_coral-Dialog-${type}`, !isCoachMark);
  442. el.classList.toggle(`_coral-CoachMarkPopover-${type}`, isCoachMark);
  443. }
  444. });
  445. }
  446.  
  447. _toggleFlyout() {
  448. // Flyout mode is when there's only content in default variant
  449. const isFlyout = this._variant === variant._COACHMARK ||
  450. this._variant === variant.DEFAULT && _isEmpty(this.header) && _isEmpty(this.footer);
  451.  
  452. this.classList.toggle(`${CLASSNAME}--dialog`, !isFlyout);
  453. this._elements.tip.hidden = isFlyout;
  454. }
  455.  
  456. /** @private */
  457. _handleClick(event) {
  458. if (this.interaction === this.constructor.interaction.OFF) {
  459. // Since we use delegation, just ignore clicks if interaction is off
  460. return;
  461. }
  462.  
  463. const eventTarget = event.target;
  464. const targetEl = this._getTarget();
  465.  
  466. const eventIsWithinTarget = targetEl ? targetEl.contains(eventTarget) : false;
  467.  
  468. if (eventIsWithinTarget) {
  469. // When target is clicked
  470.  
  471. if (!this.open && !targetEl.disabled) {
  472. // Open if we're not already open and target element is not disabled
  473. this.show();
  474.  
  475. this._trackEvent('display', 'coral-popover', event);
  476. } else {
  477. this.hide();
  478.  
  479. this._trackEvent('close', 'coral-popover', event);
  480. }
  481. } else if (this.open && !this.contains(eventTarget)) {
  482. const target = eventTarget.closest('._coral-Overlay');
  483. // Also check if the click element is inside an overlay which target could be inside of this popover
  484. if (target && this.contains(target._getTarget())) {
  485. return;
  486. }
  487.  
  488. // Close if we're open and the click was outside of the target and outside of the popover
  489. this.hide();
  490.  
  491. this._trackEvent('close', 'coral-popover', event);
  492. }
  493. }
  494.  
  495. get _contentZones() {
  496. return {
  497. 'coral-popover-header': 'header',
  498. 'coral-popover-content': 'content',
  499. 'coral-popover-footer': 'footer'
  500. };
  501. }
  502.  
  503. /**
  504. Returns {@link Popover} variants.
  505.  
  506. @return {PopoverVariantEnum}
  507. */
  508. static get variant() {
  509. return variant;
  510. }
  511.  
  512. /**
  513. Returns {@link Popover} close options.
  514.  
  515. @return {PopoverClosableEnum}
  516. */
  517. static get closable() {
  518. return closable;
  519. }
  520.  
  521. /** @ignore */
  522. static get observedAttributes() {
  523. return super.observedAttributes.concat([
  524. 'closable',
  525. 'variant'
  526. ]);
  527. }
  528.  
  529. /** @ignore */
  530. render() {
  531. super.render();
  532.  
  533. this.classList.add(CLASSNAME);
  534.  
  535. // ARIA
  536. if (!this.hasAttribute('role')) {
  537. this.setAttribute('role', 'dialog');
  538. }
  539.  
  540. if (!this.hasAttribute('aria-live')) {
  541. // This helped announcements in certain screen readers
  542. this.setAttribute('aria-live', 'assertive');
  543. }
  544.  
  545. // Default reflected attributes
  546. if (!this._variant) {
  547. this.variant = variant.DEFAULT;
  548. }
  549. if (!this._closable) {
  550. this.closable = closable.OFF;
  551. }
  552.  
  553. // // Fetch the content zones
  554. const header = this._elements.header;
  555. const content = this._elements.content;
  556. const footer = this._elements.footer;
  557.  
  558. // Verify if a content zone is provided
  559. const contentZoneProvided = this.contains(content) && content || this.contains(footer) && footer || this.contains(header) && header;
  560.  
  561. // Remove content zones so we can process children
  562. if (header.parentNode) {
  563. header.remove();
  564. }
  565. if (content.parentNode) {
  566. content.remove();
  567. }
  568. if (footer.parentNode) {
  569. footer.remove();
  570. }
  571.  
  572. // Remove tab captures
  573. Array.prototype.filter.call(this.children, (child) => child.hasAttribute('coral-tabcapture')).forEach((tabCapture) => {
  574. this.removeChild(tabCapture);
  575. });
  576.  
  577. // Support cloneNode
  578. const template = this.querySelectorAll('._coral-Dialog-header, ._coral-Dialog-closeButton, ._coral-Popover-tip');
  579. for (let i = 0 ; i < template.length ; i++) {
  580. template[i].remove();
  581. }
  582.  
  583. // Move everything in the content
  584. if (!contentZoneProvided) {
  585. while (this.firstChild) {
  586. content.appendChild(this.firstChild);
  587. }
  588. }
  589.  
  590. // Insert template
  591. const frag = document.createDocumentFragment();
  592. frag.appendChild(this._elements.headerWrapper);
  593. frag.appendChild(this._elements.closeButton);
  594. frag.appendChild(this._elements.tip);
  595. this.appendChild(frag);
  596.  
  597. // Assign content zones
  598. this.header = header;
  599. this.content = content;
  600. this.footer = footer;
  601. }
  602. });
  603.  
  604. export default Popover;