UNPKG

@adobe/coral-spectrum

Version:

Coral Spectrum is a JavaScript library of Web Components following Spectrum design patterns.

605 lines (500 loc) 17.3 kB
/** * 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;