ExamplesPlaygroundReference Source

coral-spectrum/coral-component-toast/src/scripts/Toast.js

  1. /**
  2. * Copyright 2019 Adobe. All rights reserved.
  3. * This file is licensed to you under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License. You may obtain a copy
  5. * of the License at http://www.apache.org/licenses/LICENSE-2.0
  6. *
  7. * Unless required by applicable law or agreed to in writing, software distributed under
  8. * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
  9. * OF ANY KIND, either express or implied. See the License for the specific language
  10. * governing permissions and limitations under the License.
  11. */
  12.  
  13. import {BaseComponent} from '../../../coral-base-component';
  14. import {BaseOverlay} from '../../../coral-base-overlay';
  15. import {Icon} from '../../../coral-component-icon';
  16. import {Button} from '../../../coral-component-button';
  17. import base from '../templates/base';
  18. import {transform, validate, commons} from '../../../coral-utils';
  19. import {Decorator} from '../../../coral-decorator';
  20.  
  21. /**
  22. Enumeration for {@link Toast} variants.
  23.  
  24. @typedef {Object} ToastVariantEnum
  25.  
  26. @property {String} DEFAULT
  27. A neutral toast.
  28. @property {String} ERROR
  29. A toast to notify that an error has occurred or to warn the user of something important.
  30. @property {String} SUCCESS
  31. A toast to notify the user of a successful operation.
  32. @property {String} INFO
  33. A toast to notify the user of non-critical information.
  34. */
  35. const variant = {
  36. DEFAULT: 'default',
  37. ERROR: 'error',
  38. SUCCESS: 'success',
  39. INFO: 'info'
  40. };
  41.  
  42. /**
  43. Enumeration for {@link Toast} placement values.
  44.  
  45. @typedef {Object} ToastPlacementEnum
  46.  
  47. @property {String} LEFT
  48. A toast anchored to the bottom left of screen.
  49. @property {String} CENTER
  50. A toast anchored to the bottom center of screen.
  51. @property {String} RIGHT
  52. A toast anchored to the bottom right of screen.
  53. */
  54. const placement = {
  55. LEFT: 'left',
  56. CENTER: 'center',
  57. RIGHT: 'right'
  58. };
  59.  
  60. const CLASSNAME = '_coral-Toast';
  61.  
  62. // An array of all possible variant
  63. const ALL_VARIANT_CLASSES = [];
  64. for (const variantValue in variant) {
  65. ALL_VARIANT_CLASSES.push(`${CLASSNAME}--${variant[variantValue]}`);
  66. }
  67.  
  68. const PRIORITY_QUEUE = [];
  69.  
  70. const queue = (el) => {
  71. let priority;
  72. const type = transform.string(el.getAttribute('variant')).toLowerCase();
  73.  
  74. if (type === variant.ERROR) {
  75. priority = el.action ? 1 : 2;
  76. } else if (type === variant.SUCCESS) {
  77. priority = el.action ? 3 : 6;
  78. } else if (type === variant.INFO) {
  79. priority = el.action ? 4 : 7;
  80. } else {
  81. priority = el.action ? 5 : 8;
  82. }
  83.  
  84. PRIORITY_QUEUE.push({
  85. el,
  86. priority
  87. });
  88. };
  89.  
  90. const unqueue = () => {
  91. let next = null;
  92. [1, 2, 3, 4, 5, 6, 7, 8].some((priority) => {
  93. return PRIORITY_QUEUE.some((item, index) => {
  94. if (item.priority === priority) {
  95. next = {
  96. el: item.el,
  97. index
  98. };
  99.  
  100. return true;
  101. }
  102. });
  103. });
  104.  
  105. if (next !== null) {
  106. PRIORITY_QUEUE.splice(next.index, 1);
  107. next.el.open = true;
  108. }
  109. };
  110.  
  111. // Used to map icon with variant
  112. const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
  113.  
  114. // Restriction filter for action button
  115. const isButton = node => (node.nodeName === 'BUTTON' && node.getAttribute('is') === 'coral-button') ||
  116. (node.nodeName === 'A' && node.getAttribute('is') === 'coral-anchorbutton');
  117.  
  118. /**
  119. @class Coral.Toast
  120. @classdesc Toasts display brief temporary notifications.
  121. They are noticeable but do not disrupt the user experience and do not require an action to be taken.
  122. @htmltag coral-toast
  123. @extends {HTMLElement}
  124. @extends {BaseComponent}
  125. @extends {BaseOverlay}
  126. */
  127. const Toast = Decorator(class extends BaseOverlay(BaseComponent(HTMLElement)) {
  128. /** @ignore */
  129. constructor() {
  130. super();
  131.  
  132. // Debounce wait time in milliseconds
  133. this._wait = 50;
  134.  
  135. // Override defaults from Overlay
  136. this._overlayAnimationTime = this.constructor.FADETIME;
  137. this._focusOnShow = this.constructor.focusOnShow.OFF;
  138. this._returnFocus = this.constructor.returnFocus.ON;
  139.  
  140. // Prepare templates
  141. this._elements = {
  142. // Fetch or create the content zone element
  143. content: this.querySelector('coral-toast-content') || document.createElement('coral-toast-content')
  144. };
  145. base.call(this._elements);
  146.  
  147. this._delegateEvents({
  148. 'global:resize': '_debounceLayout',
  149. 'global:key:escape': '_onEscape',
  150. 'click [coral-close]': '_onCloseClick',
  151. 'coral-overlay:close': '_onClose'
  152. });
  153.  
  154. // Layout any time the DOM changes
  155. this._observer = new MutationObserver(() => {
  156. this._debounceLayout();
  157. });
  158.  
  159. // Watch for changes
  160. this._observer.observe(this, {
  161. childList: true,
  162. subtree: true
  163. });
  164. }
  165.  
  166. /**
  167. Whether the Toast will be dismissed automatically after a certain period. The minimum and default value is 5 seconds.
  168. The dismissible behavior can be disabled by setting the value to <code>0</code>.
  169. If an actionable toast is set to auto-dismiss, make sure that the action is still accessible elsewhere in the application.
  170.  
  171. @type {?Number}
  172. @default 5000
  173. @htmlattribute autodismiss
  174. */
  175. get autoDismiss() {
  176. return typeof this._autoDismiss === 'number' ? this._autoDismiss : 5000;
  177. }
  178.  
  179. set autoDismiss(value) {
  180. value = transform.number(value);
  181. if (value !== null) {
  182. value = Math.abs(value);
  183.  
  184. // Value can't be set lower than 5secs. 0 is an exception.
  185. if (value !== 0 && value < 5000) {
  186. commons._log('warn', 'Coral.Toast: the value for autoDismiss has to be 5 seconds minimum.');
  187. value = 5000;
  188. }
  189.  
  190. this._autoDismiss = value;
  191. }
  192. }
  193.  
  194. /**
  195. The actionable item marked with <code>[coral-toast-action]</code>.
  196. Restricted to {@link Button} or {@link AnchorButton} elements.
  197. Actionable toasts should not have a button with a redundant action. For example “dismiss” would be redundant as all
  198. toasts already have a close button.
  199.  
  200. @type {HTMLElement}
  201. @readonly
  202. */
  203. get action() {
  204. return this._elements.action || this.querySelector('[coral-toast-action]');
  205. }
  206.  
  207. set action(el) {
  208. if (!el) {
  209. return;
  210. }
  211.  
  212. if (isButton(el)) {
  213. this._elements.action = el;
  214. el.setAttribute('coral-toast-action', '');
  215. el.setAttribute('variant', Button.variant._CUSTOM);
  216. el.classList.add('_coral-Button', '_coral-Button--overBackground', '_coral-Button--quiet');
  217.  
  218. this._elements.body.appendChild(el);
  219. } else {
  220. commons._log('warn', 'Coral.Toast: provided action is not a Coral.Button or Coral.AnchorButton.');
  221. }
  222. }
  223.  
  224. /**
  225. Inherited from {@link BaseOverlay#open}.
  226. */
  227. get open() {
  228. return super.open;
  229. }
  230.  
  231. set open(value) {
  232. // Opening only if element is queued
  233. value = transform.booleanAttr(value);
  234. if (value && !this._queued) {
  235. this._open = value;
  236. // Mark it
  237. this._queued = true;
  238. // Clear timer
  239. if (this._dimissTimeout) {
  240. clearTimeout(this._dimissTimeout);
  241. }
  242. // Add it to the queue
  243. queue(this);
  244.  
  245. requestAnimationFrame(() => {
  246. this._reflectAttribute('open', true);
  247.  
  248. // If not child of document.body, we have to move it there
  249. this._moveToDocumentBody();
  250.  
  251. requestAnimationFrame(() => {
  252. // Start emptying the queue
  253. if (document.querySelectorAll('coral-toast[open]').length === PRIORITY_QUEUE.length) {
  254. unqueue();
  255. }
  256. });
  257. });
  258.  
  259. return;
  260. }
  261.  
  262. super.open = value;
  263.  
  264. // Ensure we're in the DOM
  265. if (this.open) {
  266. // Position the element
  267. this._position();
  268.  
  269. // Handles what to focus based on focusOnShow
  270. this._handleFocus();
  271.  
  272. // Use raf to wait for autoDismiss value to be set
  273. requestAnimationFrame(() => {
  274. // Only dismiss if value is different than 0
  275. if (this.autoDismiss !== 0) {
  276. this._dimissTimeout = window.setTimeout(() => {
  277. if (this.open && !this.contains(document.activeElement)) {
  278. this.open = false;
  279. }
  280. }, this.autoDismiss);
  281. }
  282. });
  283. }
  284. }
  285.  
  286. /**
  287. The Toast variant. See {@link ToastVariantEnum}.
  288.  
  289. @type {String}
  290. @default ToastVariantEnum.DEFAULT
  291. @htmlattribute variant
  292. @htmlattributereflected
  293. */
  294. get variant() {
  295. return this._variant || variant.DEFAULT;
  296. }
  297.  
  298. set variant(value) {
  299. value = transform.string(value).toLowerCase();
  300. this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
  301. this._reflectAttribute('variant', this._variant);
  302.  
  303. this._renderVariantIcon();
  304.  
  305. // Remove all variant classes
  306. this.classList.remove(...ALL_VARIANT_CLASSES);
  307.  
  308. // Set new variant class
  309. this.classList.add(`${CLASSNAME}--${this._variant}`);
  310.  
  311. // Set the role attribute to alert or status depending on
  312. // the variant so that the element turns into a live region
  313. this.setAttribute('role', (this.variant === variant.ERROR || this.variant === variant.WARNING || this.variant === variant.SUCCESS || this.variant === variant.INFO) ? 'alert' : 'status');
  314. this.setAttribute('aria-live', 'polite');
  315. }
  316.  
  317. /**
  318. The Toast content element.
  319.  
  320. @type {ToastContent}
  321. @contentzone
  322. */
  323. get content() {
  324. return this._getContentZone(this._elements.content);
  325. }
  326.  
  327. set content(value) {
  328. this._setContentZone('content', value, {
  329. handle: 'content',
  330. tagName: 'coral-toast-content',
  331. insert: function (content) {
  332. content.classList.add(`${CLASSNAME}-content`);
  333. // After the header
  334. this._elements.body.insertBefore(content, this._elements.body.firstChild);
  335. }
  336. });
  337. }
  338.  
  339. /**
  340. The Toast placement. See {@link ToastPlacementEnum}.
  341.  
  342. @type {String}
  343. @default ToastPlacementEnum.CENTER
  344. @htmlattribute placement
  345. */
  346. get placement() {
  347. return this._placement || placement.CENTER;
  348. }
  349.  
  350. set placement(value) {
  351. value = transform.string(value).toLowerCase();
  352. this._placement = validate.enumeration(placement)(value) && value || placement.CENTER;
  353.  
  354. this._debounceLayout();
  355. }
  356.  
  357. _renderVariantIcon() {
  358. if (this._elements.icon) {
  359. this._elements.icon.remove();
  360. }
  361.  
  362. let variantValue = this.variant;
  363.  
  364. // Default variant has no icon
  365. if (variantValue === variant.DEFAULT) {
  366. return;
  367. }
  368.  
  369. // Inject the SVG icon
  370. const iconName = variantValue === variant.ERROR ? 'Alert' : capitalize(variantValue);
  371. const icon = Icon._renderSVG(`spectrum-css-icon-${iconName}Medium`, ['_coral-Toast-typeIcon', `_coral-UIIcon-${iconName}Medium`]);
  372. this.insertAdjacentHTML('afterbegin', icon);
  373. this._elements.icon = this.querySelector('._coral-Toast-typeIcon');
  374. }
  375.  
  376. _moveToDocumentBody() {
  377. // Not in the DOM
  378. if (!document.body.contains(this)) {
  379. document.body.appendChild(this);
  380. }
  381. // In the DOM but not a direct child of body
  382. else if (this.parentNode !== document.body) {
  383. this._ignoreConnectedCallback = true;
  384. this._repositioned = true;
  385. document.body.appendChild(this);
  386. this._ignoreConnectedCallback = false;
  387. }
  388. }
  389.  
  390. _debounceLayout() {
  391. // Debounce
  392. if (this._layoutTimeout !== null) {
  393. clearTimeout(this._layoutTimeout);
  394. }
  395.  
  396. this._layoutTimeout = window.setTimeout(() => {
  397. this._layoutTimeout = null;
  398. this._position();
  399. }, this._wait);
  400. }
  401.  
  402. _position() {
  403. if (this.open) {
  404. requestAnimationFrame(() => {
  405. if (this.placement === placement.CENTER) {
  406. this.style.left = `${document.body.clientWidth / 2 - this.clientWidth / 2}px`;
  407. this.style.right = '';
  408. } else if (this.placement === placement.LEFT) {
  409. this.style.left = 0;
  410. this.style.right = '';
  411. } else if (this.placement === placement.RIGHT) {
  412. this.style.left = '';
  413. this.style.right = 0;
  414. }
  415. });
  416. }
  417. }
  418.  
  419. _onEscape(event) {
  420. if (this.open && this.classList.contains('is-open') && this._isTopOverlay()) {
  421. event.stopPropagation();
  422. this.open = false;
  423. }
  424. }
  425.  
  426. _onCloseClick(event) {
  427. const dismissTarget = event.matchedTarget;
  428. const dismissValue = dismissTarget.getAttribute('coral-close');
  429. if (!dismissValue || this.matches(dismissValue)) {
  430. this.open = false;
  431. event.stopPropagation();
  432. }
  433. }
  434.  
  435. _onClose() {
  436. // Unmark it
  437. this._queued = false;
  438.  
  439. // Continue emptying the queue
  440. unqueue();
  441. }
  442.  
  443. get _contentZones() {
  444. return {
  445. 'coral-toast-content': 'content'
  446. };
  447. }
  448.  
  449. static get _queue() {
  450. return PRIORITY_QUEUE;
  451. }
  452.  
  453. /**
  454. Returns {@link Toast} placement options.
  455.  
  456. @return {ToastPlacementEnum}
  457. */
  458. static get placement() {
  459. return placement;
  460. }
  461.  
  462. /**
  463. Returns {@link Toast} variants.
  464.  
  465. @return {ToastVariantEnum}
  466. */
  467. static get variant() {
  468. return variant;
  469. }
  470.  
  471. static get _attributePropertyMap() {
  472. return commons.extend(super._attributePropertyMap, {
  473. autodismiss: 'autoDismiss'
  474. });
  475. }
  476.  
  477. /** @ignore */
  478. static get observedAttributes() {
  479. return super.observedAttributes.concat([
  480. 'variant',
  481. 'placement',
  482. 'autodismiss'
  483. ]);
  484. }
  485.  
  486. /** @ignore */
  487. render() {
  488. super.render();
  489.  
  490. this.classList.add(CLASSNAME);
  491.  
  492. // Default reflected attributes
  493. if (!this._variant) {
  494. this.variant = variant.DEFAULT;
  495. }
  496.  
  497. // Create a fragment
  498. const fragment = document.createDocumentFragment();
  499.  
  500. const templateHandleNames = ['body', 'buttons'];
  501.  
  502. // Render the template
  503. fragment.appendChild(this._elements.body);
  504. fragment.appendChild(this._elements.buttons);
  505.  
  506. const content = this._elements.content;
  507. if (content.parentNode) {
  508. content.remove();
  509. }
  510.  
  511. const action = this.action;
  512. if (action) {
  513. action.remove();
  514. }
  515.  
  516. while (this.firstChild) {
  517. const child = this.firstChild;
  518. if (child.nodeType === Node.TEXT_NODE ||
  519. child.nodeType === Node.ELEMENT_NODE && templateHandleNames.indexOf(child.getAttribute('handle')) === -1) {
  520. // Add non-template elements to the content
  521. content.appendChild(child);
  522. } else {
  523. // Remove anything else
  524. this.removeChild(child);
  525. }
  526. }
  527.  
  528. // Insert template
  529. this.appendChild(fragment);
  530.  
  531. // If default variant, does nothing
  532. this._renderVariantIcon();
  533.  
  534. // Assign the content zones
  535. this.content = this._elements.content;
  536. this.action = action;
  537. }
  538.  
  539. /** @ignore */
  540. disconnectedCallback() {
  541. super.disconnectedCallback();
  542.  
  543. if (this._queued) {
  544. let el = null;
  545. PRIORITY_QUEUE.some((item, index) => {
  546. if (item.el === this) {
  547. this._queued = false;
  548. el = index;
  549. return true;
  550. }
  551. });
  552.  
  553. if (el !== null) {
  554. PRIORITY_QUEUE.splice(el, 1);
  555. }
  556. }
  557. }
  558. });
  559.  
  560. export default Toast;