coral-spectrum/coral-component-dialog/src/scripts/Dialog.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 {BaseComponent} from '../../../coral-base-component';
import {BaseOverlay} from '../../../coral-base-overlay';
import {DragAction} from '../../../coral-dragaction';
import {Icon} from '../../../coral-component-icon';
import '../../../coral-component-button';
import base from '../templates/base';
import {commons, transform, validate, i18n} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';
/**
Enumeration for {@link Dialog} closable options.
@typedef {Object} DialogClosableEnum
@property {String} ON
Show a close button on the dialog and close the dialog when clicked.
@property {String} OFF
Do not show a close button. Elements with the <code>coral-close</code> attribute will still close the dialog.
*/
const closable = {
ON: 'on',
OFF: 'off'
};
/**
Enumeration for {@link Dialog} keyboard interaction options.
@typedef {Object} DialogInteractionEnum
@property {String} ON
Keyboard interaction is enabled.
@property {String} OFF
Keyboard interaction is disabled.
*/
const interaction = {
ON: 'on',
OFF: 'off'
};
/**
Enumeration for {@link Dialog} variants.
@typedef {Object} DialogVariantEnum
@property {String} DEFAULT
A default dialog without header icon.
@property {String} ERROR
A dialog with an error header and icon, indicating that an error has occurred.
@property {String} WARNING
A dialog with a warning header and icon, notifying the user of something important.
@property {String} SUCCESS
A dialog with a success header and icon, indicates to the user that an operation was successful.
@property {String} HELP
A dialog with a question header and icon, provides the user with help.
@property {String} INFO
A dialog 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'
};
/**
Enumeration for {@link Dialog} backdrops.
@typedef {Object} DialogBackdropEnum
@property {String} NONE
No backdrop.
@property {String} MODAL
A backdrop that hides the dialog when clicked.
@property {String} STATIC
A backdrop that does not hide the dialog when clicked.
*/
const backdrop = {
NONE: 'none',
MODAL: 'modal',
STATIC: 'static'
};
// Used to map icon with variant
const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
// The dialog's base classname
const CLASSNAME = '_coral-Dialog';
// Modifier classnames
const FULLSCREEN_CLASSNAME = `${CLASSNAME}--fullscreenTakeover`;
// A string of all possible variant classnames
const ALL_VARIANT_CLASSES = [];
for (const variantValue in variant) {
ALL_VARIANT_CLASSES.push(`${CLASSNAME}--${variant[variantValue]}`);
}
/**
@class Coral.Dialog
@classdesc A Dialog component that supports various use cases with custom content. The Dialog can be given a size by
using the special attribute <code>[coral-dialog-size]</code> as selector.
@htmltag coral-dialog
@extends {HTMLElement}
@extends {BaseComponent}
@extends {BaseOverlay}
*/
const Dialog = Decorator(class extends BaseOverlay(BaseComponent(HTMLElement)) {
/** @ignore */
constructor() {
super();
// Prepare templates
this._elements = commons.extend(this._elements, {
// Fetch or create the content zone elements
header: this.querySelector('coral-dialog-header') || document.createElement('coral-dialog-header'),
content: this.querySelector('coral-dialog-content') || document.createElement('coral-dialog-content'),
footer: this.querySelector('coral-dialog-footer') || document.createElement('coral-dialog-footer')
});
base.call(this._elements, {i18n});
// Events
this._delegateEvents({
'coral-overlay:open': '_handleOpen',
'click [coral-close]': '_handleCloseClick',
// Since we cover the backdrop with ourself for positioning purposes, this is implemented as a click listener
// instead of using backdropClickedCallback
'click': '_handleClick',
// Handle resize events
'global:resize': 'center',
'global:key:escape': '_handleEscape'
});
// Override defaults from Overlay
this._trapFocus = this.constructor.trapFocus.ON;
this._returnFocus = this.constructor.returnFocus.ON;
this._overlayAnimationTime = this.constructor.FADETIME;
// Listen for mutations
this._headerObserver = new MutationObserver(this._hideHeaderIfEmpty.bind(this));
// Watch for changes to the header element's children
this._observeHeader();
}
/**
Whether keyboard interaction is enabled. See {@link DialogInteractionEnum}.
@type {DialogInteractionEnum}
@default DialogInteractionEnum.ON
*/
get interaction() {
return this._interaction || interaction.ON;
}
set interaction(value) {
value = transform.string(value).toLowerCase();
this._interaction = validate.enumeration(interaction)(value) && value || interaction.ON;
}
/**
The dialog header element.
@type {DialogHeader}
@contentzone
*/
get header() {
return this._getContentZone(this._elements.header);
}
set header(value) {
this._setContentZone('header', value, {
handle: 'header',
tagName: 'coral-dialog-header',
insert: function (header) {
header.classList.add(`${CLASSNAME}-title`);
// Providing the ARIA attributes to coral dialog header
header.setAttribute('role', 'heading');
header.setAttribute('aria-level', '2');
// Position the header between the drag zone and the type icon
this._elements.headerWrapper.insertBefore(header, this._elements.dragZone.nextElementSibling);
},
set: function () {
// Stop observing the old header and observe the new one
this._observeHeader();
// Check if header needs to be hidden
this._hideHeaderIfEmpty();
}
});
}
/**
The dialog content element.
@type {DialogContent}
@contentzone
*/
get content() {
return this._getContentZone(this._elements.content);
}
set content(value) {
this._setContentZone('content', value, {
handle: 'content',
tagName: 'coral-dialog-content',
insert: function (content) {
content.classList.add(`${CLASSNAME}-content`);
const footer = this.footer;
// The content should always be before footer
this._elements.wrapper.insertBefore(content, this.contains(footer) && footer || null);
}
});
}
/**
The dialog footer element.
@type {DialogFooter}
@contentzone
*/
get footer() {
return this._getContentZone(this._elements.footer);
}
set footer(value) {
this._setContentZone('footer', value, {
handle: 'footer',
tagName: 'coral-dialog-footer',
insert: function (footer) {
footer.classList.add(`${CLASSNAME}-footer`);
// The footer should always be after content
this._elements.wrapper.appendChild(footer);
}
});
}
/**
The backdrop configuration for this dialog. See {@link DialogBackdropEnum}.
@type {String}
@default DialogBackdropEnum.MODAL
@htmlattribute backdrop
*/
get backdrop() {
return this._backdrop || backdrop.MODAL;
}
set backdrop(value) {
value = transform.string(value).toLowerCase();
this._backdrop = validate.enumeration(backdrop)(value) && value || backdrop.MODAL;
const showBackdrop = this._backdrop !== backdrop.NONE;
this._elements.wrapper.classList.toggle(`${CLASSNAME}--noBackdrop`, !showBackdrop);
// We're visible now, so hide or show the modal accordingly
if (this.open && showBackdrop) {
this._showBackdrop();
}
}
/**
The dialog's variant. See {@link DialogVariantEnum}.
@type {String}
@default DialogVariantEnum.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._elements.wrapper.classList.remove(...ALL_VARIANT_CLASSES);
if (this._variant === variant.DEFAULT) {
// ARIA
this.setAttribute('role', 'dialog');
} else {
// Set new variant class
this._elements.wrapper.classList.add(`${CLASSNAME}--${this._variant}`);
// ARIA
this.setAttribute('role', 'alertdialog');
}
const hasHeader = this.header && this.header.textContent !== '';
// If the dialog has a header and is not otherwise labelled,
if (hasHeader && !(this.hasAttribute('aria-labelledby') || this.hasAttribute('aria-label'))) {
this.header.id = this.header.id || commons.getUID();
// label the dialog with a reference to the header
this.setAttribute('aria-labelledby', this.header.id);
}
// If the dialog has no content, or the content is empty, do nothing further.
if (!this.content || this.content.textContent === '') {
return;
}
// If the dialog has a content,
this.content.id = this.content.id || commons.getUID();
// In an alertdialog with a content region, if the alertdialog is not otherwise described.
if (this._variant !== variant.DEFAULT) {
// with no header,
if (!hasHeader) {
// label the alertdialog with a reference to the content
this.setAttribute('aria-labelledby', this.content.id);
}
// otherwise, if the alertdialog is not otherwise described,
else if (!this.hasAttribute('aria-describedby')) {
// ensure that the alertdialog is described by the content.
this.setAttribute('aria-describedby', this.content.id);
}
} else if (this.getAttribute('aria-labelledby') === this.content.id) {
this.removeAttribute('aria-labelledby');
}
}
/**
Whether the dialog should be displayed full screen (without borders or margin).
@type {Boolean}
@default false
@htmlattribute fullscreen
@htmlattributereflected
*/
get fullscreen() {
return this._fullscreen || false;
}
set fullscreen(value) {
this._fullscreen = transform.booleanAttr(value);
this._reflectAttribute('fullscreen', this._fullscreen);
if (this._fullscreen) {
// Full screen and movable are not compatible
this.movable = false;
this._elements.wrapper.classList.add(FULLSCREEN_CLASSNAME);
} else {
this._elements.wrapper.classList.remove(FULLSCREEN_CLASSNAME);
}
}
/**
Inherited from {@link BaseOverlay#open}.
*/
get open() {
return super.open;
}
set open(value) {
super.open = value;
// Ensure we're in the DOM
if (this.open) {
// If not child of document.body, we have to move it there
this._moveToDocumentBody();
// Show the backdrop, if necessary
if (this.backdrop !== backdrop.NONE) {
this._showBackdrop();
}
}
// Support animation
requestAnimationFrame(() => {
// Support wrapped dialog
this._elements.wrapper.classList.toggle('is-open', this.open);
// Handles what to focus based on focusOnShow
if (this.open) {
commons.transitionEnd(this._elements.wrapper, () => {
this._handleFocus();
this._elements.closeButton.tabIndex = 0;
this._elements.closeButton.removeAttribute('coral-tabcapture');
});
} else {
this._elements.closeButton.tabIndex = -1;
this._elements.closeButton.setAttribute('coral-tabcapture', '');
}
});
}
/**
The dialog's icon.
@type {String}
@default ""
@htmlattribute icon
*/
get icon() {
return this._elements.icon;
}
set icon(value) {
this._elements.icon = value;
}
/**
Whether the dialog should have a close button. See {@link DialogClosableEnum}.
@type {String}
@default DialogClosableEnum.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.wrapper.classList.toggle(`${CLASSNAME}--dismissible`, this._closable === closable.ON);
}
/**
Whether the dialog can moved around by dragging the title.
@type {Boolean}
@default false
@htmlattribute movable
@htmlattributereflected
*/
get movable() {
return this._movable || false;
}
set movable(value) {
this._movable = transform.booleanAttr(value);
this._reflectAttribute('movable', this._movable);
// Movable and fullscreen are not compatible
if (this._movable) {
this.fullscreen = false;
}
if (this._movable) {
const dragAction = new DragAction(this);
dragAction.handle = this._elements.headerWrapper;
} else {
// Disables any dragging interaction
if (this.dragAction) {
this.dragAction.destroy();
}
// Recenter the dialog once it's not movable anymore
this.center();
}
}
/**
Inherited from {@link BaseComponent#trackingElement}.
*/
get trackingElement() {
return typeof this._trackingElement === 'undefined' ?
(this.header && this.header.textContent && this.header.textContent.replace(/\s{2,}/g, ' ').trim() || '') :
this._trackingElement;
}
set trackingElement(value) {
super.trackingElement = value;
}
/** @ignore */
_observeHeader() {
if (this._headerObserver) {
this._headerObserver.disconnect();
if (this._elements.header) {
this._headerObserver.observe(this._elements.header, {
// Catch changes to childList
childList: true,
// Catch changes to textContent
characterData: true,
// Monitor any child node
subtree: true
});
}
}
}
/**
Hide the header wrapper if the header content zone is empty.
@ignore
*/
_hideHeaderIfEmpty() {
const header = this._elements.header;
if (header) {
const headerWrapper = this._elements.headerWrapper;
// If it's empty and has no non-textnode children, hide the header
const hiddenValue = header.children.length === 0 && header.textContent.replace(/\s*/g, '') === '';
// Only bother if the hidden status has changed
if (hiddenValue !== headerWrapper.hidden) {
headerWrapper.hidden = hiddenValue;
}
this.variant = this.variant;
}
}
_handleOpen(event) {
this._trackEvent('display', 'coral-dialog', event);
}
/** @ignore */
_handleEscape(event) {
// When escape is pressed, hide ourselves
if (this.interaction === interaction.ON && this.open && this._isTopOverlay()) {
event.stopPropagation();
this.open = false;
}
}
/**
@ignore
@todo maybe this should be base or something
*/
_handleCloseClick(event) {
const dismissTarget = event.matchedTarget;
const dismissValue = dismissTarget.getAttribute('coral-close');
if (!dismissValue || this.matches(dismissValue)) {
this.open = false;
event.stopPropagation();
this._trackEvent('close', 'coral-dialog', event);
}
}
_handleClick(event) {
// When we're modal, we close when our outer area (over the backdrop) is clicked
if (event.target === this && this.backdrop === backdrop.MODAL && this._isTopOverlay()) {
this.open = false;
this._trackEvent('close', 'coral-dialog', event);
}
}
/** @ignore */
_moveToDocumentBody() {
// Not in the DOM
if (!document.body.contains(this)) {
document.body.appendChild(this);
}
// In the DOM but not a direct child of body
else if (this.parentNode !== document.body) {
this._ignoreConnectedCallback = true;
this._repositioned = true;
document.body.appendChild(this);
this._ignoreConnectedCallback = false;
}
}
_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) {
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._elements.headerWrapper.querySelector('._coral-Dialog-typeIcon');
}
}
/** @ignore */
backdropClickedCallback() {
// When we're modal, we close when the backdrop is clicked
if (this.backdrop === backdrop.MODAL && this._isTopOverlay()) {
this.open = false;
}
}
/**
Centers the dialog in the middle of the screen.
@returns {Dialog} this, chainable.
*/
center() {
// We're already centered in fullscreen mode
if (this.fullscreen) {
return;
}
// If moved we reset the position
this.style.top = '';
this.style.left = '';
return this;
}
get _contentZones() {
return {
'coral-dialog-header': 'header',
'coral-dialog-content': 'content',
'coral-dialog-footer': 'footer'
};
}
/**
Returns {@link Dialog} variants.
@return {DialogVariantEnum}
*/
static get variant() {
return variant;
}
/**
Returns {@link Dialog} backdrops.
@return {DialogBackdropEnum}
*/
static get backdrop() {
return backdrop;
}
/**
Returns {@link Dialog} close options.
@return {DialogClosableEnum}
*/
static get closable() {
return closable;
}
/**
Returns {@link Dialog} interaction options.
@return {DialogInteractionEnum}
*/
static get interaction() {
return interaction;
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat([
'interaction',
'backdrop',
'variant',
'fullscreen',
'icon',
'closable',
'movable'
]);
}
/** @ignore */
render() {
super.render();
this.classList.add(`${CLASSNAME}-wrapper`);
this.setAttribute("aria-modal", "dialog");
// Default reflected attributes
if (!this._variant) {
this.variant = variant.DEFAULT;
}
if (!this._backdrop) {
this.backdrop = backdrop.MODAL;
}
if (!this._closable) {
this.closable = closable.OFF;
}
if (!this._interaction) {
this.interaction = interaction.ON;
}
// 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;
// Verify if the internal wrapper exists
let wrapper = this.querySelector(`.${CLASSNAME}`);
// Case where the dialog was rendered already - cloneNode support
if (wrapper) {
// Remove tab captures
Array.prototype.filter.call(this.children, (child) => child.hasAttribute('coral-tabcapture')).forEach((tabCapture) => {
this.removeChild(tabCapture);
});
// Assign internal elements
this._elements.headerWrapper = this.querySelector('._coral-Dialog-header');
this._elements.closeButton = this.querySelector('._coral-Dialog-closeButton');
this._elements.wrapper = wrapper;
}
// Case where the dialog needs to be rendered
else {
// Create default wrapper
wrapper = this._elements.wrapper;
// Create default header wrapper
const headerWrapper = this._elements.headerWrapper;
// Case where the dialog needs to be rendered and content zones are provided
if (contentZoneProvided) {
// Check if user wrapper is provided
if (contentZoneProvided.parentNode === this) {
// Content zone target defaults to default wrapper if no user wrapper element is provided
this._elements.wrapper = wrapper;
} else {
// Content zone target defaults to user wrapper element if provided
this._elements.wrapper = contentZoneProvided.parentNode;
}
// Move everything in the wrapper
while (this.firstChild) {
wrapper.appendChild(this.firstChild);
}
// Add the dialog header before the content
if(this._elements.wrapper.contains(content)) {
this._elements.wrapper.insertBefore(headerWrapper, content);
} else {
// try adding in next frame
// so that content is a child of dialog wrapper
commons.nextFrame(() => {
this._elements.wrapper.insertBefore(headerWrapper, content);
});
}
}
// Case where the dialog needs to be rendered and content zones need to be created
else {
// Default content zone target is wrapper
this._elements.wrapper = wrapper;
// Move everything in the "content" content zone
while (this.firstChild) {
content.appendChild(this.firstChild);
}
// Add the content zones in the wrapper
wrapper.appendChild(headerWrapper);
wrapper.appendChild(content);
wrapper.appendChild(footer);
}
// Add the wrapper to the dialog
this.appendChild(wrapper);
}
// Only the wrapper gets the dialog class
this._elements.wrapper.classList.add(CLASSNAME);
// Mark the dialog with a public attribute for sizing
this._elements.wrapper.setAttribute('coral-dialog-size', '');
// Close button should stay under the dialog
this._elements.wrapper.appendChild(this._elements.closeButton);
// Copy styles over to new wrapper
if (this._elements.wrapper.parentNode !== this) {
const contentWrapper = this.querySelector('[handle="wrapper"]');
Array.prototype.forEach.call(contentWrapper.classList, style => this._elements.wrapper.classList.add(style));
contentWrapper.removeAttribute('class');
}
// Assign content zones
this.header = header;
this.footer = footer;
this.content = content;
}
});
export default Dialog;