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. if (this._popper) {
  331. setTimeout(() => {
  332. if (this.open) {
  333. this._popper.scheduleUpdate();
  334. }
  335. });
  336. }
  337. }
  338. }
  339.  
  340. /**
  341. @ignore
  342.  
  343. Not supported anymore.
  344. */
  345. get icon() {
  346. return this._icon || '';
  347. }
  348.  
  349. set icon(value) {
  350. this._icon = transform.string(value);
  351. }
  352.  
  353. _setAriaExpandedOnTarget() {
  354. const target = this._getTarget();
  355. if (target) {
  356. const hasPopupAttribute = target.hasAttribute('aria-haspopup');
  357. if (hasPopupAttribute || target.querySelector('[aria-haspopup]') !== null) {
  358. const targetElements = hasPopupAttribute ? [target] : target.querySelectorAll('[aria-haspopup]');
  359. targetElements.forEach((targetElement) => targetElement.setAttribute('aria-expanded', this.open));
  360. }
  361. }
  362. }
  363.  
  364. _onPositioned(event) {
  365. if (this.open) {
  366. // Set arrow placement
  367. this.classList.remove(...ALL_PLACEMENT_CLASSES);
  368. this.classList.add(`${CLASSNAME}--${event.detail.placement}`);
  369. }
  370. }
  371.  
  372. _onAnimate() {
  373. // popper attribute
  374. const popperPlacement = this.getAttribute('x-placement');
  375.  
  376. // popper takes care of setting left, top to 0 on positioning
  377. if (popperPlacement === 'left') {
  378. this.style.left = '8px';
  379. } else if (popperPlacement === 'top') {
  380. this.style.top = '8px';
  381. } else if (popperPlacement === 'right') {
  382. this.style.left = '-8px';
  383. } else if (popperPlacement === 'bottom') {
  384. this.style.top = '-8px';
  385. }
  386. }
  387.  
  388. _insertTypeIcon() {
  389. if (this._elements.icon) {
  390. this._elements.icon.remove();
  391. }
  392.  
  393. let variantValue = this.variant;
  394.  
  395. // Warning icon is same as ERROR icon
  396. if (variantValue === variant.WARNING || variantValue === variant.ERROR) {
  397. variantValue = 'alert';
  398. }
  399.  
  400. // Inject the SVG icon
  401. if (variantValue !== variant.DEFAULT && variantValue !== variant._COACHMARK) {
  402. const iconName = capitalize(variantValue);
  403. this._elements.headerWrapper.insertAdjacentHTML('beforeend', Icon._renderSVG(`spectrum-css-icon-${iconName}Medium`, ['_coral-Dialog-typeIcon', `_coral-UIIcon-${iconName}Medium`]));
  404. this._elements.icon = this.querySelector('._coral-Dialog-typeIcon');
  405. }
  406. }
  407.  
  408. _observeContentZone(name) {
  409. const observer = this[`_${name}Observer`];
  410. if (observer) {
  411. observer.disconnect();
  412. observer.observe(this._elements[name], {
  413. // Catch changes to childList
  414. childList: true,
  415. // Catch changes to textContent
  416. characterData: true,
  417. // Monitor any child node
  418. subtree: true
  419. });
  420. }
  421. }
  422.  
  423. _hideContentZoneIfEmpty(name) {
  424. const contentZone = this._elements[name];
  425. const target = name === 'header' ? this._elements.headerWrapper : contentZone;
  426.  
  427. // If it's empty and has no non-textnode children, hide the header
  428. const hiddenValue = _isEmpty(contentZone);
  429.  
  430. // Only bother if the hidden status has changed
  431. if (hiddenValue !== target.hidden) {
  432. target.hidden = hiddenValue;
  433.  
  434. // Reposition as the height has changed
  435. this.reposition();
  436. }
  437. }
  438.  
  439. _toggleCoachMark(isCoachMark) {
  440. this.classList.toggle('_coral-CoachMarkPopover', isCoachMark);
  441. this._elements.headerWrapper.classList.toggle('_coral-Dialog-header', !isCoachMark);
  442. this._elements.headerWrapper.classList.toggle('_coral-CoachMarkPopover-header', isCoachMark);
  443.  
  444. ['header', 'content', 'footer'].forEach((contentZone, i) => {
  445. const el = this[contentZone];
  446. const type = i === 0 ? 'title' : contentZone;
  447.  
  448. if (el) {
  449. el.classList.toggle(`_coral-Dialog-${type}`, !isCoachMark);
  450. el.classList.toggle(`_coral-CoachMarkPopover-${type}`, isCoachMark);
  451. }
  452. });
  453. }
  454.  
  455. _toggleFlyout() {
  456. // Flyout mode is when there's only content in default variant
  457. const isFlyout = this._variant === variant._COACHMARK ||
  458. this._variant === variant.DEFAULT && _isEmpty(this.header) && _isEmpty(this.footer);
  459.  
  460. this.classList.toggle(`${CLASSNAME}--dialog`, !isFlyout);
  461. this._elements.tip.hidden = isFlyout;
  462. }
  463.  
  464. /** @private */
  465. _handleClick(event) {
  466. if (this.interaction === this.constructor.interaction.OFF) {
  467. // Since we use delegation, just ignore clicks if interaction is off
  468. return;
  469. }
  470.  
  471. const eventTarget = event.target;
  472. const targetEl = this._getTarget();
  473.  
  474. const eventIsWithinTarget = targetEl ? targetEl.contains(eventTarget) : false;
  475.  
  476. if (eventIsWithinTarget) {
  477. // When target is clicked
  478.  
  479. if (!this.open && !targetEl.disabled) {
  480. // Open if we're not already open and target element is not disabled
  481. this.show();
  482.  
  483. this._trackEvent('display', 'coral-popover', event);
  484. } else {
  485. this.hide();
  486.  
  487. this._trackEvent('close', 'coral-popover', event);
  488. }
  489. } else if (this.open && !this.contains(eventTarget)) {
  490. const target = eventTarget.closest('._coral-Overlay');
  491. // Also check if the click element is inside an overlay which target could be inside of this popover
  492. if (target && this.contains(target._getTarget())) {
  493. return;
  494. }
  495.  
  496. // Close if we're open and the click was outside of the target and outside of the popover
  497. this.hide();
  498.  
  499. this._trackEvent('close', 'coral-popover', event);
  500. }
  501. }
  502.  
  503. get _contentZones() {
  504. return {
  505. 'coral-popover-header': 'header',
  506. 'coral-popover-content': 'content',
  507. 'coral-popover-footer': 'footer'
  508. };
  509. }
  510.  
  511. /**
  512. Returns {@link Popover} variants.
  513.  
  514. @return {PopoverVariantEnum}
  515. */
  516. static get variant() {
  517. return variant;
  518. }
  519.  
  520. /**
  521. Returns {@link Popover} close options.
  522.  
  523. @return {PopoverClosableEnum}
  524. */
  525. static get closable() {
  526. return closable;
  527. }
  528.  
  529. /** @ignore */
  530. static get observedAttributes() {
  531. return super.observedAttributes.concat([
  532. 'closable',
  533. 'variant'
  534. ]);
  535. }
  536.  
  537. /** @ignore */
  538. render() {
  539. super.render();
  540.  
  541. this.classList.add(CLASSNAME);
  542.  
  543. // ARIA
  544. if (!this.hasAttribute('role')) {
  545. this.setAttribute('role', 'dialog');
  546. }
  547.  
  548. if (!this.hasAttribute('aria-live')) {
  549. // This helped announcements in certain screen readers
  550. this.setAttribute('aria-live', 'assertive');
  551. }
  552.  
  553. // Default reflected attributes
  554. if (!this._variant) {
  555. this.variant = variant.DEFAULT;
  556. }
  557. if (!this._closable) {
  558. this.closable = closable.OFF;
  559. }
  560.  
  561. // // Fetch the content zones
  562. const header = this._elements.header;
  563. const content = this._elements.content;
  564. const footer = this._elements.footer;
  565.  
  566. // Verify if a content zone is provided
  567. const contentZoneProvided = this.contains(content) && content || this.contains(footer) && footer || this.contains(header) && header;
  568.  
  569. // Remove content zones so we can process children
  570. if (header.parentNode) {
  571. header.remove();
  572. }
  573. if (content.parentNode) {
  574. content.remove();
  575. }
  576. if (footer.parentNode) {
  577. footer.remove();
  578. }
  579.  
  580. // Remove tab captures
  581. Array.prototype.filter.call(this.children, (child) => child.hasAttribute('coral-tabcapture')).forEach((tabCapture) => {
  582. this.removeChild(tabCapture);
  583. });
  584.  
  585. // Support cloneNode
  586. const template = this.querySelectorAll('._coral-Dialog-header, ._coral-Dialog-closeButton, ._coral-Popover-tip');
  587. for (let i = 0 ; i < template.length ; i++) {
  588. template[i].remove();
  589. }
  590.  
  591. // Move everything in the content
  592. if (!contentZoneProvided) {
  593. while (this.firstChild) {
  594. content.appendChild(this.firstChild);
  595. }
  596. }
  597.  
  598. // Insert template
  599. const frag = document.createDocumentFragment();
  600. frag.appendChild(this._elements.headerWrapper);
  601. frag.appendChild(this._elements.closeButton);
  602. frag.appendChild(this._elements.tip);
  603. this.appendChild(frag);
  604.  
  605. // Assign content zones
  606. this.header = header;
  607. this.content = content;
  608. this.footer = footer;
  609. }
  610. });
  611.  
  612. export default Popover;