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) {
super.target = value;
// Coach Mark specific
const target = this._getTarget();
if (target && target.tagName === 'CORAL-COACHMARK') {
this.setAttribute('variant', variant._COACHMARK);
}
this._setAriaExpandedOnTarget();
}
/**
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;