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) ? '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;