coral-spectrum/coral-component-toast/src/scripts/Toast.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 {Icon} from '../../../coral-component-icon';
- import {Button} from '../../../coral-component-button';
- import base from '../templates/base';
- import {transform, validate, commons} from '../../../coral-utils';
- import {Decorator} from '../../../coral-decorator';
-
- /**
- Enumeration for {@link Toast} variants.
-
- @typedef {Object} ToastVariantEnum
-
- @property {String} DEFAULT
- A neutral toast.
- @property {String} ERROR
- A toast to notify that an error has occurred or to warn the user of something important.
- @property {String} SUCCESS
- A toast to notify the user of a successful operation.
- @property {String} INFO
- A toast to notify the user of non-critical information.
- */
- const variant = {
- DEFAULT: 'default',
- ERROR: 'error',
- SUCCESS: 'success',
- INFO: 'info'
- };
-
- /**
- Enumeration for {@link Toast} placement values.
-
- @typedef {Object} ToastPlacementEnum
-
- @property {String} LEFT
- A toast anchored to the bottom left of screen.
- @property {String} CENTER
- A toast anchored to the bottom center of screen.
- @property {String} RIGHT
- A toast anchored to the bottom right of screen.
- */
- const placement = {
- LEFT: 'left',
- CENTER: 'center',
- RIGHT: 'right'
- };
-
- const CLASSNAME = '_coral-Toast';
-
- // An array of all possible variant
- const ALL_VARIANT_CLASSES = [];
- for (const variantValue in variant) {
- ALL_VARIANT_CLASSES.push(`${CLASSNAME}--${variant[variantValue]}`);
- }
-
- const PRIORITY_QUEUE = [];
-
- const queue = (el) => {
- let priority;
- const type = transform.string(el.getAttribute('variant')).toLowerCase();
-
- if (type === variant.ERROR) {
- priority = el.action ? 1 : 2;
- } else if (type === variant.SUCCESS) {
- priority = el.action ? 3 : 6;
- } else if (type === variant.INFO) {
- priority = el.action ? 4 : 7;
- } else {
- priority = el.action ? 5 : 8;
- }
-
- PRIORITY_QUEUE.push({
- el,
- priority
- });
- };
-
- const unqueue = () => {
- let next = null;
- [1, 2, 3, 4, 5, 6, 7, 8].some((priority) => {
- return PRIORITY_QUEUE.some((item, index) => {
- if (item.priority === priority) {
- next = {
- el: item.el,
- index
- };
-
- return true;
- }
- });
- });
-
- if (next !== null) {
- PRIORITY_QUEUE.splice(next.index, 1);
- next.el.open = true;
- }
- };
-
- // Used to map icon with variant
- const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
-
- // Restriction filter for action button
- const isButton = node => (node.nodeName === 'BUTTON' && node.getAttribute('is') === 'coral-button') ||
- (node.nodeName === 'A' && node.getAttribute('is') === 'coral-anchorbutton');
-
- /**
- @class Coral.Toast
- @classdesc Toasts display brief temporary notifications.
- They are noticeable but do not disrupt the user experience and do not require an action to be taken.
- @htmltag coral-toast
- @extends {HTMLElement}
- @extends {BaseComponent}
- @extends {BaseOverlay}
- */
- const Toast = Decorator(class extends BaseOverlay(BaseComponent(HTMLElement)) {
- /** @ignore */
- constructor() {
- super();
-
- // Debounce wait time in milliseconds
- this._wait = 50;
-
- // Override defaults from Overlay
- this._overlayAnimationTime = this.constructor.FADETIME;
- this._focusOnShow = this.constructor.focusOnShow.OFF;
- this._returnFocus = this.constructor.returnFocus.ON;
-
- // Prepare templates
- this._elements = {
- // Fetch or create the content zone element
- content: this.querySelector('coral-toast-content') || document.createElement('coral-toast-content')
- };
- base.call(this._elements);
-
- this._delegateEvents({
- 'global:resize': '_debounceLayout',
- 'global:key:escape': '_onEscape',
- 'click [coral-close]': '_onCloseClick',
- 'coral-overlay:close': '_onClose'
- });
-
- // Layout any time the DOM changes
- this._observer = new MutationObserver(() => {
- this._debounceLayout();
- });
-
- // Watch for changes
- this._observer.observe(this, {
- childList: true,
- subtree: true
- });
- }
-
- /**
- Whether the Toast will be dismissed automatically after a certain period. The minimum and default value is 5 seconds.
- The dismissible behavior can be disabled by setting the value to <code>0</code>.
- If an actionable toast is set to auto-dismiss, make sure that the action is still accessible elsewhere in the application.
-
- @type {?Number}
- @default 5000
- @htmlattribute autodismiss
- */
- get autoDismiss() {
- return typeof this._autoDismiss === 'number' ? this._autoDismiss : 5000;
- }
-
- set autoDismiss(value) {
- value = transform.number(value);
- if (value !== null) {
- value = Math.abs(value);
-
- // Value can't be set lower than 5secs. 0 is an exception.
- if (value !== 0 && value < 5000) {
- commons._log('warn', 'Coral.Toast: the value for autoDismiss has to be 5 seconds minimum.');
- value = 5000;
- }
-
- this._autoDismiss = value;
- }
- }
-
- /**
- The actionable item marked with <code>[coral-toast-action]</code>.
- Restricted to {@link Button} or {@link AnchorButton} elements.
- Actionable toasts should not have a button with a redundant action. For example “dismiss” would be redundant as all
- toasts already have a close button.
-
- @type {HTMLElement}
- @readonly
- */
- get action() {
- return this._elements.action || this.querySelector('[coral-toast-action]');
- }
-
- set action(el) {
- if (!el) {
- return;
- }
-
- if (isButton(el)) {
- this._elements.action = el;
- el.setAttribute('coral-toast-action', '');
- el.setAttribute('variant', Button.variant._CUSTOM);
- el.classList.add('_coral-Button', '_coral-Button--overBackground', '_coral-Button--quiet');
-
- this._elements.body.appendChild(el);
- } else {
- commons._log('warn', 'Coral.Toast: provided action is not a Coral.Button or Coral.AnchorButton.');
- }
- }
-
- /**
- Inherited from {@link BaseOverlay#open}.
- */
- get open() {
- return super.open;
- }
-
- set open(value) {
- // Opening only if element is queued
- value = transform.booleanAttr(value);
- if (value && !this._queued) {
- this._open = value;
- // Mark it
- this._queued = true;
- // Clear timer
- if (this._dimissTimeout) {
- clearTimeout(this._dimissTimeout);
- }
- // Add it to the queue
- queue(this);
-
- requestAnimationFrame(() => {
- this._reflectAttribute('open', true);
-
- // If not child of document.body, we have to move it there
- this._moveToDocumentBody();
-
- requestAnimationFrame(() => {
- // Start emptying the queue
- if (document.querySelectorAll('coral-toast[open]').length === PRIORITY_QUEUE.length) {
- unqueue();
- }
- });
- });
-
- return;
- }
-
- super.open = value;
-
- // Ensure we're in the DOM
- if (this.open) {
- // Position the element
- this._position();
-
- // Handles what to focus based on focusOnShow
- this._handleFocus();
-
- // Use raf to wait for autoDismiss value to be set
- requestAnimationFrame(() => {
- // Only dismiss if value is different than 0
- if (this.autoDismiss !== 0) {
- this._dimissTimeout = window.setTimeout(() => {
- if (this.open && !this.contains(document.activeElement)) {
- this.open = false;
- }
- }, this.autoDismiss);
- }
- });
- }
- }
-
- /**
- The Toast variant. See {@link ToastVariantEnum}.
-
- @type {String}
- @default ToastVariantEnum.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);
-
- this._renderVariantIcon();
-
- // Remove all variant classes
- this.classList.remove(...ALL_VARIANT_CLASSES);
-
- // Set new variant class
- this.classList.add(`${CLASSNAME}--${this._variant}`);
-
- // Set the role attribute to alert or status depending on
- // the variant so that the element turns into a live region
- this.setAttribute('role', (this.variant === variant.ERROR || this.variant === variant.WARNING || this.variant === variant.SUCCESS || this.variant === variant.INFO) ? 'alert' : 'status');
- this.setAttribute('aria-live', 'polite');
- }
-
- /**
- The Toast content element.
-
- @type {ToastContent}
- @contentzone
- */
- get content() {
- return this._getContentZone(this._elements.content);
- }
-
- set content(value) {
- this._setContentZone('content', value, {
- handle: 'content',
- tagName: 'coral-toast-content',
- insert: function (content) {
- content.classList.add(`${CLASSNAME}-content`);
- // After the header
- this._elements.body.insertBefore(content, this._elements.body.firstChild);
- }
- });
- }
-
- /**
- The Toast placement. See {@link ToastPlacementEnum}.
-
- @type {String}
- @default ToastPlacementEnum.CENTER
- @htmlattribute placement
- */
- get placement() {
- return this._placement || placement.CENTER;
- }
-
- set placement(value) {
- value = transform.string(value).toLowerCase();
- this._placement = validate.enumeration(placement)(value) && value || placement.CENTER;
-
- this._debounceLayout();
- }
-
- _renderVariantIcon() {
- if (this._elements.icon) {
- this._elements.icon.remove();
- }
-
- let variantValue = this.variant;
-
- // Default variant has no icon
- if (variantValue === variant.DEFAULT) {
- return;
- }
-
- // Inject the SVG icon
- const iconName = variantValue === variant.ERROR ? 'Alert' : capitalize(variantValue);
- const icon = Icon._renderSVG(`spectrum-css-icon-${iconName}Medium`, ['_coral-Toast-typeIcon', `_coral-UIIcon-${iconName}Medium`]);
- this.insertAdjacentHTML('afterbegin', icon);
- this._elements.icon = this.querySelector('._coral-Toast-typeIcon');
- }
-
- _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;
- }
- }
-
- _debounceLayout() {
- // Debounce
- if (this._layoutTimeout !== null) {
- clearTimeout(this._layoutTimeout);
- }
-
- this._layoutTimeout = window.setTimeout(() => {
- this._layoutTimeout = null;
- this._position();
- }, this._wait);
- }
-
- _position() {
- if (this.open) {
- requestAnimationFrame(() => {
- if (this.placement === placement.CENTER) {
- this.style.left = `${document.body.clientWidth / 2 - this.clientWidth / 2}px`;
- this.style.right = '';
- } else if (this.placement === placement.LEFT) {
- this.style.left = 0;
- this.style.right = '';
- } else if (this.placement === placement.RIGHT) {
- this.style.left = '';
- this.style.right = 0;
- }
- });
- }
- }
-
- _onEscape(event) {
- if (this.open && this.classList.contains('is-open') && this._isTopOverlay()) {
- event.stopPropagation();
- this.open = false;
- }
- }
-
- _onCloseClick(event) {
- const dismissTarget = event.matchedTarget;
- const dismissValue = dismissTarget.getAttribute('coral-close');
- if (!dismissValue || this.matches(dismissValue)) {
- this.open = false;
- event.stopPropagation();
- }
- }
-
- _onClose() {
- // Unmark it
- this._queued = false;
-
- // Continue emptying the queue
- unqueue();
- }
-
- get _contentZones() {
- return {
- 'coral-toast-content': 'content'
- };
- }
-
- static get _queue() {
- return PRIORITY_QUEUE;
- }
-
- /**
- Returns {@link Toast} placement options.
-
- @return {ToastPlacementEnum}
- */
- static get placement() {
- return placement;
- }
-
- /**
- Returns {@link Toast} variants.
-
- @return {ToastVariantEnum}
- */
- static get variant() {
- return variant;
- }
-
- static get _attributePropertyMap() {
- return commons.extend(super._attributePropertyMap, {
- autodismiss: 'autoDismiss'
- });
- }
-
- /** @ignore */
- static get observedAttributes() {
- return super.observedAttributes.concat([
- 'variant',
- 'placement',
- 'autodismiss'
- ]);
- }
-
- /** @ignore */
- render() {
- super.render();
-
- this.classList.add(CLASSNAME);
-
- // Default reflected attributes
- if (!this._variant) {
- this.variant = variant.DEFAULT;
- }
-
- // Create a fragment
- const fragment = document.createDocumentFragment();
-
- const templateHandleNames = ['body', 'buttons'];
-
- // Render the template
- fragment.appendChild(this._elements.body);
- fragment.appendChild(this._elements.buttons);
-
- const content = this._elements.content;
- if (content.parentNode) {
- content.remove();
- }
-
- const action = this.action;
- if (action) {
- action.remove();
- }
-
- while (this.firstChild) {
- const child = this.firstChild;
- if (child.nodeType === Node.TEXT_NODE ||
- child.nodeType === Node.ELEMENT_NODE && templateHandleNames.indexOf(child.getAttribute('handle')) === -1) {
- // Add non-template elements to the content
- content.appendChild(child);
- } else {
- // Remove anything else
- this.removeChild(child);
- }
- }
-
- // Insert template
- this.appendChild(fragment);
-
- // If default variant, does nothing
- this._renderVariantIcon();
-
- // Assign the content zones
- this.content = this._elements.content;
- this.action = action;
- }
-
- /** @ignore */
- disconnectedCallback() {
- super.disconnectedCallback();
-
- if (this._queued) {
- let el = null;
- PRIORITY_QUEUE.some((item, index) => {
- if (item.el === this) {
- this._queued = false;
- el = index;
- return true;
- }
- });
-
- if (el !== null) {
- PRIORITY_QUEUE.splice(el, 1);
- }
- }
- }
- });
-
- export default Toast;