UNPKG

@adobe/coral-spectrum

Version:

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

907 lines (751 loc) 25.6 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 {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' }; const keyCodes = { UP: 38, DOWN: 40, LEFT: 37, RIGHT: 39, }; /** 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`); // 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); } // Provide the ARIA heading role to coral dialog header // depending on whether the header contains focusable elements if (hasHeader) { if (this.header.querySelector(commons.FOCUSABLE_ELEMENT_SELECTOR) !== null) { this.header.removeAttribute('role'); this.header.removeAttribute('aria-level'); } else if (!this.header.hasAttribute('role')) { this.header.setAttribute('role', 'heading'); this.header.setAttribute('aria-level', '2'); } } // 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); const wrapper = this; const header = this._elements.headerWrapper; // 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; if (header && wrapper) { if (!this.keyboardHandler) { this.keyboardHandler = (event) => { this.dialogKeyboardHandler(event, wrapper, header); }; } // make sure the event is added when fullscreen is false commons.nextFrame(() => { if (!this.fullscreen) { commons.nextFrame(() => this.addKeyboardHandler(header, wrapper)); } }); } } else { // Disables any dragging interaction if (this.dragAction) { this.dragAction.destroy(); } // Remove any previos added keyboard listener commons.nextFrame(() => { if (this.fullscreen) { this.removeKeyboardHandler(header); } }); // Recenter the dialog once it's not movable anymore this.center(); } } addKeyboardHandler(header, wrapper) { if (!header || !this._movable) return; header.setAttribute("tabindex", 0); header.setAttribute("aria-label", i18n.get("Move Dialog")); if (!this.keyboardHandler) { this.keyboardHandler = (event) => { this.dialogKeyboardHandler(event, wrapper, header); }; } header.addEventListener("keydown", this.keyboardHandler); } removeKeyboardHandler(header) { if (header && this.keyboardHandler) { header.removeAttribute("tabindex"); header.removeAttribute("aria-label"); header.removeEventListener("keydown", this.keyboardHandler); } } /** 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; } dialogKeyboardHandler (event, dialog, header) { if (document.activeElement !== header) { return; } let currentTop = parseInt(dialog.style.top) || 0; let currentLeft = parseInt(dialog.style.left) || 0; var step = 10; switch (event.keyCode) { case keyCodes.UP: dialog.style.top = (currentTop - step) + 'px'; break; case keyCodes.DOWN: dialog.style.top = (currentTop + step) + 'px'; break; case keyCodes.LEFT: dialog.style.left = (currentLeft - step) + 'px'; break; case keyCodes.RIGHT: dialog.style.left = (currentLeft + step) + 'px'; break; } } 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", true); // 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;