/** * Copyright Aquera Inc 2023 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { LitElement, CSSResultArray, TemplateResult } from 'lit'; import '../nile-icon-button/nile-icon-button'; import { animateTo, stopAnimations } from '../internal/animate'; import { classMap } from 'lit/directives/class-map.js'; import { customElement, property, query } from 'lit/decorators.js'; import { getAnimation, setDefaultAnimation, } from '../utilities/animation-registry'; import { HasSlotController } from '../internal/slot'; import { html } from 'lit'; import { waitForEvent } from '../internal/event'; import { watch } from '../internal/watch'; import NileElement from '../internal/nile-element'; import { styles } from './nile-toast.css'; import type { CSSResultGroup } from 'lit'; const toastStack = Object.assign(document.createElement('div'), { className: 'nile-toast-stack', }); /** * Nile icon component. * * @tag nile-toast * * @dependency nile-icon-button * * @slot - The alert's main content. * @slot icon - An icon to show in the alert. Works best with ``. * * @event nile-show - Emitted when the alert opens. * @event nile-after-show - Emitted after the alert opens and all animations are complete. * @event nile-hide - Emitted when the alert closes. * @event nile-after-hide - Emitted after the alert closes and all animations are complete. * * @csspart base - The component's base wrapper. * @csspart icon - The container that wraps the optional icon. * @csspart message - The container that wraps the alert's main content. * @csspart close-button - The close button, an ``. * @csspart close-button__base - The close button's exported `base` part. * * @animation alert.show - The animation to use when showing the alert. * @animation alert.hide - The animation to use when hiding the alert. */ @customElement('nile-toast') export class NileToast extends NileElement { static styles: CSSResultGroup = styles; private autoHideTimeout: number; private readonly hasSlotController = new HasSlotController( this, 'icon', 'suffix' ); @query('[part~="base"]') base: HTMLElement; /** * Indicates whether or not the alert is open. You can toggle this attribute to show and hide the alert, or you can * use the `show()` and `hide()` methods and this attribute will reflect the alert's open state. */ @property({ type: Boolean, reflect: true }) open = false; @property({ type: Boolean, reflect: true }) noIcon = false; /** Enables a close button that allows the user to dismiss the alert. */ @property({ type: Boolean, reflect: true }) closable = false; @property({ type: Boolean, reflect: true }) hasSlottedContent = false; @property({ type: Boolean, reflect: true }) hasSlottedIcon = false; @property({ type: String, reflect: true }) prefixImageUrl = ''; @property({ type: String, reflect: true }) closeIconName = 'var(--nile-icon-close, var(--ng-icon-x-close))'; /** The alert's theme variant. */ @property({ reflect: true }) variant: | 'success' | 'info' | 'warning' | 'error' | 'gray' | 'black' = 'success'; /** * The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with * the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning * the alert will not close on its own. */ @property({ type: Number }) duration = Infinity; @property({ type: String }) title = ''; @property({ type: String }) content = ''; @property({ type: Array, reflect: true }) tags: any[] = []; firstUpdated() { this.base.hidden = !this.open; } private restartAutoHide() { clearTimeout(this.autoHideTimeout); if (this.open && this.duration < Infinity) { this.autoHideTimeout = window.setTimeout( () => this.hide(), this.duration ); } } private handleCloseClick() { this.hide(); } private handleMouseMove() { this.restartAutoHide(); } @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { if (this.open) { // Show this.emit('nile-show'); if (this.duration < Infinity) { this.restartAutoHide(); } await stopAnimations(this.base); this.base.hidden = false; const { keyframes, options } = getAnimation(this, 'alert.show', { dir: 'ltr', }); await animateTo(this.base, keyframes, options); this.emit('nile-after-show'); } else { // Hide this.emit('nile-hide'); clearTimeout(this.autoHideTimeout); await stopAnimations(this.base); const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: 'ltr', }); await animateTo(this.base, keyframes, options); this.base.hidden = true; this.emit('nile-after-hide'); } } @watch('duration') handleDurationChange() { this.restartAutoHide(); } /** Shows the alert. */ async show() { if (this.open) { return undefined; } this.open = true; return waitForEvent(this, 'nile-after-show'); } /** Hides the alert */ async hide() { if (!this.open) { return undefined; } this.open = false; return waitForEvent(this, 'nile-after-hide'); } getIconNameByVariant() { switch (this.variant) { case 'success': return 'var(--nile-icon-radiodone, var(--ng-icon-check-circle))'; case 'info': return 'var(--nile-icon-info, var(--ng-icon-info-circle))'; case 'warning': return 'var(--nile-icon-warning, var(--ng-icon-alert-circle))'; case 'error': return 'var(--nile-icon-warning, var(--ng-icon-alert-circle))'; case 'gray': return 'var(--nile-icon-info, var(--ng-icon-info-circle))'; case 'black': return 'var(--nile-icon-info, var(--ng-icon-info-circle))'; } } getIconColorByVariant() { switch (this.variant) { case 'success': return 'var(--nile-toast-color-icon-color-success, var(--ng-colors-fg-success-primary))'; case 'info': return 'var(--nile-toast-color-icon-color-info, var(--ng-colors-fg-brand-primary-600))'; case 'warning': return 'var(--nile-toast-color-icon-color-warning, var(--ng-colors-fg-warning-primary))'; case 'error': return 'var(--nile-toast-color-icon-color-error, var(--ng-colors-fg-error-primary))'; case 'gray': return 'var(--nile-toast-color-icon-color-gray, var(--ng-colors-fg-tertiary-600))'; case 'black': return 'var(--nile-toast-color-icon-color-black)'; } } handleSlotChange(e: any) { const slot = e.target; const nodes = slot.assignedNodes({ flatten: true }); if (slot.name === 'message') { const nodes = slot.assignedNodes({ flatten: true }); this.hasSlottedContent = nodes.length > 0; } if (slot.name === 'icon') { const nodes = slot.assignedNodes({ flatten: true }); this.hasSlottedIcon = nodes.length > 0; } } /** * Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when * dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by * calling this method again. The returned promise will resolve after the alert is hidden. */ async toast() { return new Promise(resolve => { if (toastStack.parentElement === null) { document.body.append(toastStack); } toastStack.appendChild(this); // Wait for the toast stack to render requestAnimationFrame(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition this.clientWidth; this.show(); }); this.addEventListener( 'nile-after-hide', () => { toastStack.removeChild(this); resolve(); // Remove the toast stack from the DOM when there are no more alerts if (toastStack.querySelector('nile-alert') === null) { toastStack.remove(); } }, { once: true } ); }); } private toastTags(content: string, imageUrl: string) { return html`
${content}
`; } render() { return html` `; } } setDefaultAnimation('alert.show', { keyframes: [ { opacity: 0, scale: 0.8 }, { opacity: 1, scale: 1 }, ], options: { duration: 250, easing: 'ease' }, }); setDefaultAnimation('alert.hide', { keyframes: [ { opacity: 1, scale: 1 }, { opacity: 0, scale: 0.8 }, ], options: { duration: 250, easing: 'ease' }, }); export default NileToast; declare global { interface HTMLElementTagNameMap { 'nile-toast': NileToast; } }