coral-spectrum/coral-component-popover/src/scripts/Popover.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 {ExtensibleOverlay, Overlay} from '../../../coral-component-overlay';
- import {Icon} from '../../../coral-component-icon';
- // Popover relies on Dialog styles partially
- import '../../../coral-component-dialog';
- import base from '../templates/base';
- import {commons, transform, validate, i18n} from '../../../coral-utils';
- import {Decorator} from '../../../coral-decorator';
-
- const CLASSNAME = '_coral-Popover';
-
- const OFFSET = 5;
-
- // Used to map icon with variant
- const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
-
- // If it's empty and has no non-textnode children
- const _isEmpty = (el) => !el || el.children.length === 0 && el.textContent.replace(/\s*/g, '') === '';
-
- /**
- Enumeration for {@link Popover} closable state.
-
- @typedef {Object} PopoverClosableEnum
-
- @property {String} ON
- Show a close button on the popover and close the popover when clicked.
- @property {String} OFF
- Do not show a close button. Elements with the <code>coral-close</code> attributes will still close the
- popover.
- */
- const closable = {
- ON: 'on',
- OFF: 'off'
- };
-
- /**
- Enumeration for {@link Popover} variants.
-
- @typedef {Object} PopoverVariantEnum
-
- @property {String} DEFAULT
- A default popover without header icon.
- @property {String} ERROR
- A popover with an error header and icon, indicating that an error has occurred.
- @property {String} WARNING
- A popover with a warning header and icon, notifying the user of something important.
- @property {String} SUCCESS
- A popover with a success header and icon, indicates to the user that an operation was successful.
- @property {String} HELP
- A popover with a question header and icon, provides the user with help.
- @property {String} INFO
- A popover with an info header and icon, informs the user of non-critical information.
- */
- const variant = {
- DEFAULT: 'default',
- ERROR: 'error',
- WARNING: 'warning',
- SUCCESS: 'success',
- HELP: 'help',
- INFO: 'info',
- _COACHMARK: '_coachmark'
- };
-
- // A string of all possible variant classnames
- const ALL_VARIANT_CLASSES = [];
- for (const variantValue in variant) {
- if (variantValue !== 'COACHMARK') {
- ALL_VARIANT_CLASSES.push(`_coral-Dialog--${variant[variantValue]}`);
- }
- }
-
- // A string of all possible placement classnames
- const placement = Overlay.placement;
- const ALL_PLACEMENT_CLASSES = [];
- for (const placementKey in placement) {
- ALL_PLACEMENT_CLASSES.push(`${CLASSNAME}--${placement[placementKey]}`);
- }
-
- /**
- @class Coral.Popover
- @classdesc A Popover component for small overlay content.
- @htmltag coral-popover
- @extends {Overlay}
- */
- const Popover = Decorator(class extends ExtensibleOverlay {
- /** @ignore */
- constructor() {
- super();
-
- // Prepare templates
- this._elements = commons.extend(this._elements, {
- // Fetch or create the content zone elements
- header: this.querySelector('coral-popover-header') || document.createElement('coral-popover-header'),
- content: this.querySelector('coral-popover-content') || document.createElement('coral-popover-content'),
- footer: this.querySelector('coral-popover-footer') || document.createElement('coral-popover-footer')
- });
- base.call(this._elements, {i18n});
-
- // Events
- this._delegateEvents({
- 'global:capture:click': '_handleClick',
- 'coral-overlay:positioned': '_onPositioned',
- 'coral-overlay:_animate': '_onAnimate',
- });
-
- // Override defaults from Overlay
- this._focusOnShow = this.constructor.focusOnShow.ON;
- this._trapFocus = this.constructor.trapFocus.ON;
- this._returnFocus = this.constructor.returnFocus.ON;
- this._overlayAnimationTime = this.constructor.FADETIME;
- this._lengthOffset = OFFSET;
-
- // Listen for mutations
- ['header', 'footer'].forEach((name) => {
- this[`_${name}Observer`] = new MutationObserver(() => {
- this._hideContentZoneIfEmpty(name);
- this._toggleFlyout();
- });
-
- // Watch for changes
- this._observeContentZone(name);
- });
- }
-
- /**
- The popover's content element.
-
- @contentzone
- @name content
- @type {PopoverContent}
- */
- get content() {
- return this._getContentZone(this._elements.content);
- }
-
- set content(value) {
- this._setContentZone('content', value, {
- handle: 'content',
- tagName: 'coral-popover-content',
- insert: function (content) {
- content.classList.add('_coral-Dialog-content');
- const footer = this.footer;
- // The content should always be before footer
- this.insertBefore(content, this.contains(footer) && footer || null);
- }
- });
- }
-
- /**
- The popover's header element.
-
- @contentzone
- @name header
- @type {PopoverHeader}
- */
- get header() {
- return this._getContentZone(this._elements.header);
- }
-
- set header(value) {
- this._setContentZone('header', value, {
- handle: 'header',
- tagName: 'coral-popover-header',
- insert: function (header) {
- header.classList.add('_coral-Dialog-title');
- this._elements.headerWrapper.insertBefore(header, this._elements.headerWrapper.firstChild);
- },
- set: function () {
- // Stop observing the old header and observe the new one
- this._observeContentZone('header');
-
- // Check if header needs to be hidden
- this._hideContentZoneIfEmpty('header');
- }
- });
- }
-
- /**
- The popover's footer element.
-
- @type {PopoverFooter}
- @contentzone
- */
- get footer() {
- return this._getContentZone(this._elements.footer);
- }
-
- set footer(value) {
- this._setContentZone('footer', value, {
- handle: 'footer',
- tagName: 'coral-popover-footer',
- insert: function (footer) {
- footer.classList.add('_coral-Dialog-footer');
- // The footer should always be after content
- this.appendChild(footer);
- },
- set: function () {
- // Stop observing the old header and observe the new one
- this._observeContentZone('footer');
-
- // Check if header needs to be hidden
- this._hideContentZoneIfEmpty('footer');
- }
- });
- }
-
- /**
- The popover's variant. See {@link PopoverVariantEnum}.
-
- @type {String}
- @default PopoverVariantEnum.DEFAULT
- @htmlattribute variant
- @htmlattributereflected
- */
- get variant() {
- return this._variant || variant.DEFAULT;
- }
-
- set variant(value) {
- value = transform.string(value).toLowerCase();
- this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
- this._reflectAttribute('variant', this._variant);
-
- // Insert SVG icon
- this._insertTypeIcon();
-
- // Remove all variant classes
- this.classList.remove(...ALL_VARIANT_CLASSES);
-
- // Toggle dialog mode
- this._toggleFlyout();
-
- if (this._variant === variant._COACHMARK) {
- // ARIA
- this.setAttribute('role', 'dialog');
-
- this._toggleCoachMark(true);
- } else {
- this._toggleCoachMark(false);
-
- if (this._variant === variant.DEFAULT) {
- // ARIA
- if (!this.hasAttribute('role')) {
- this.setAttribute('role', 'dialog');
- }
- } else {
- // Set new variant class
- this.classList.add(`_coral-Dialog--${this._variant}`);
-
- // ARIA
- this.setAttribute('role', 'alertdialog');
- }
- }
- }
-
- /**
- Whether the popover should have a close button. See {@link PopoverClosableEnum}.
-
- @type {String}
- @default PopoverClosableEnum.OFF
- @htmlattribute closable
- @htmlattributereflected
- */
- get closable() {
- return this._closable || closable.OFF;
- }
-
- set closable(value) {
- value = transform.string(value).toLowerCase();
- this._closable = validate.enumeration(closable)(value) && value || closable.OFF;
- this._reflectAttribute('closable', this._closable);
-
- this._elements.closeButton.style.display = this._closable === closable.ON ? 'block' : 'none';
- }
-
- /**
- 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;
-
- // Coach Mark specific
- const target = this._getTarget();
- if (target && target.tagName === 'CORAL-COACHMARK') {
- this.setAttribute('variant', variant._COACHMARK);
- }
-
- this._setAriaExpandedOnTarget();
-
- delete this._avoidPopperInit;
- }
-
- /**
- Inherited from {@link Overlay#open}.
- */
- get open() {
- return super.open;
- }
-
- set open(value) {
- super.open = value;
-
- const target = this._getTarget();
- if (target) {
- const is = target.getAttribute('is');
- if (is === 'coral-button' || is === 'coral-anchorbutton') {
- target.classList.toggle('is-selected', this.open);
- }
-
- this._setAriaExpandedOnTarget();
- }
- }
-
- /**
- @ignore
-
- Not supported anymore.
- */
- get icon() {
- return this._icon || '';
- }
-
- set icon(value) {
- this._icon = transform.string(value);
- }
-
- _setAriaExpandedOnTarget() {
- const target = this._getTarget();
- if (target) {
- const hasPopupAttribute = target.hasAttribute('aria-haspopup');
- if (hasPopupAttribute || target.querySelector('[aria-haspopup]') !== null) {
- const targetElements = hasPopupAttribute ? [target] : target.querySelectorAll('[aria-haspopup]');
- targetElements.forEach((targetElement) => targetElement.setAttribute('aria-expanded', this.open));
- }
- }
- }
-
- _onPositioned(event) {
- if (this.open) {
- // Set arrow placement
- this.classList.remove(...ALL_PLACEMENT_CLASSES);
- this.classList.add(`${CLASSNAME}--${event.detail.placement}`);
- }
- }
-
- _onAnimate() {
- // popper attribute
- const popperPlacement = this.getAttribute('x-placement');
-
- // popper takes care of setting left, top to 0 on positioning
- if (popperPlacement === 'left') {
- this.style.left = '8px';
- } else if (popperPlacement === 'top') {
- this.style.top = '8px';
- } else if (popperPlacement === 'right') {
- this.style.left = '-8px';
- } else if (popperPlacement === 'bottom') {
- this.style.top = '-8px';
- }
- }
-
- _insertTypeIcon() {
- if (this._elements.icon) {
- this._elements.icon.remove();
- }
-
- let variantValue = this.variant;
-
- // Warning icon is same as ERROR icon
- if (variantValue === variant.WARNING || variantValue === variant.ERROR) {
- variantValue = 'alert';
- }
-
- // Inject the SVG icon
- if (variantValue !== variant.DEFAULT && variantValue !== variant._COACHMARK) {
- const iconName = capitalize(variantValue);
- this._elements.headerWrapper.insertAdjacentHTML('beforeend', Icon._renderSVG(`spectrum-css-icon-${iconName}Medium`, ['_coral-Dialog-typeIcon', `_coral-UIIcon-${iconName}Medium`]));
- this._elements.icon = this.querySelector('._coral-Dialog-typeIcon');
- }
- }
-
- _observeContentZone(name) {
- const observer = this[`_${name}Observer`];
- if (observer) {
- observer.disconnect();
- observer.observe(this._elements[name], {
- // Catch changes to childList
- childList: true,
- // Catch changes to textContent
- characterData: true,
- // Monitor any child node
- subtree: true
- });
- }
- }
-
- _hideContentZoneIfEmpty(name) {
- const contentZone = this._elements[name];
- const target = name === 'header' ? this._elements.headerWrapper : contentZone;
-
- // If it's empty and has no non-textnode children, hide the header
- const hiddenValue = _isEmpty(contentZone);
-
- // Only bother if the hidden status has changed
- if (hiddenValue !== target.hidden) {
- target.hidden = hiddenValue;
-
- // Reposition as the height has changed
- this.reposition();
- }
- }
-
- _toggleCoachMark(isCoachMark) {
- this.classList.toggle('_coral-CoachMarkPopover', isCoachMark);
- this._elements.headerWrapper.classList.toggle('_coral-Dialog-header', !isCoachMark);
- this._elements.headerWrapper.classList.toggle('_coral-CoachMarkPopover-header', isCoachMark);
-
- ['header', 'content', 'footer'].forEach((contentZone, i) => {
- const el = this[contentZone];
- const type = i === 0 ? 'title' : contentZone;
-
- if (el) {
- el.classList.toggle(`_coral-Dialog-${type}`, !isCoachMark);
- el.classList.toggle(`_coral-CoachMarkPopover-${type}`, isCoachMark);
- }
- });
- }
-
- _toggleFlyout() {
- // Flyout mode is when there's only content in default variant
- const isFlyout = this._variant === variant._COACHMARK ||
- this._variant === variant.DEFAULT && _isEmpty(this.header) && _isEmpty(this.footer);
-
- this.classList.toggle(`${CLASSNAME}--dialog`, !isFlyout);
- this._elements.tip.hidden = isFlyout;
- }
-
- /** @private */
- _handleClick(event) {
- if (this.interaction === this.constructor.interaction.OFF) {
- // Since we use delegation, just ignore clicks if interaction is off
- return;
- }
-
- const eventTarget = event.target;
- const targetEl = this._getTarget();
-
- const eventIsWithinTarget = targetEl ? targetEl.contains(eventTarget) : false;
-
- if (eventIsWithinTarget) {
- // When target is clicked
-
- if (!this.open && !targetEl.disabled) {
- // Open if we're not already open and target element is not disabled
- this.show();
-
- this._trackEvent('display', 'coral-popover', event);
- } else {
- this.hide();
-
- this._trackEvent('close', 'coral-popover', event);
- }
- } else if (this.open && !this.contains(eventTarget)) {
- const target = eventTarget.closest('._coral-Overlay');
- // Also check if the click element is inside an overlay which target could be inside of this popover
- if (target && this.contains(target._getTarget())) {
- return;
- }
-
- // Close if we're open and the click was outside of the target and outside of the popover
- this.hide();
-
- this._trackEvent('close', 'coral-popover', event);
- }
- }
-
- get _contentZones() {
- return {
- 'coral-popover-header': 'header',
- 'coral-popover-content': 'content',
- 'coral-popover-footer': 'footer'
- };
- }
-
- /**
- Returns {@link Popover} variants.
-
- @return {PopoverVariantEnum}
- */
- static get variant() {
- return variant;
- }
-
- /**
- Returns {@link Popover} close options.
-
- @return {PopoverClosableEnum}
- */
- static get closable() {
- return closable;
- }
-
- /** @ignore */
- static get observedAttributes() {
- return super.observedAttributes.concat([
- 'closable',
- 'variant'
- ]);
- }
-
- /** @ignore */
- render() {
- super.render();
-
- this.classList.add(CLASSNAME);
-
- // ARIA
- if (!this.hasAttribute('role')) {
- this.setAttribute('role', 'dialog');
- }
-
- if (!this.hasAttribute('aria-live')) {
- // This helped announcements in certain screen readers
- this.setAttribute('aria-live', 'assertive');
- }
-
- // Default reflected attributes
- if (!this._variant) {
- this.variant = variant.DEFAULT;
- }
- if (!this._closable) {
- this.closable = closable.OFF;
- }
-
- // // Fetch the content zones
- const header = this._elements.header;
- const content = this._elements.content;
- const footer = this._elements.footer;
-
- // Verify if a content zone is provided
- const contentZoneProvided = this.contains(content) && content || this.contains(footer) && footer || this.contains(header) && header;
-
- // Remove content zones so we can process children
- if (header.parentNode) {
- header.remove();
- }
- if (content.parentNode) {
- content.remove();
- }
- if (footer.parentNode) {
- footer.remove();
- }
-
- // Remove tab captures
- Array.prototype.filter.call(this.children, (child) => child.hasAttribute('coral-tabcapture')).forEach((tabCapture) => {
- this.removeChild(tabCapture);
- });
-
- // Support cloneNode
- const template = this.querySelectorAll('._coral-Dialog-header, ._coral-Dialog-closeButton, ._coral-Popover-tip');
- for (let i = 0 ; i < template.length ; i++) {
- template[i].remove();
- }
-
- // Move everything in the content
- if (!contentZoneProvided) {
- while (this.firstChild) {
- content.appendChild(this.firstChild);
- }
- }
-
- // Insert template
- const frag = document.createDocumentFragment();
- frag.appendChild(this._elements.headerWrapper);
- frag.appendChild(this._elements.closeButton);
- frag.appendChild(this._elements.tip);
- this.appendChild(frag);
-
- // Assign content zones
- this.header = header;
- this.content = content;
- this.footer = footer;
- }
- });
-
- export default Popover;