import { css, html, render as renderToElement } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { type Ref, ref, createRef } from 'lit/directives/ref.js';
import { OmniElement } from '../core/OmniElement.js';
import { RenderFunction, RenderResult } from '../render-element/RenderElement.js';
import { Toast } from './Toast.js';
import './Toast.js';
import '../render-element/RenderElement.js';
/**
* A toast container that animates in and stacks toast elements.
*
* @import
* ```js
* import '@capitec/omni-components/toast';
* ```
*
* @example
* ```html
*
*
*
*
* ```
*
* @element omni-toast-stack
*
* @slot - Toast(s) to be displayed
*
* @fires {CustomEvent} toast-remove - Dispatched when the a toast is removed from the stack.
* @fires {CustomEvent} toast-stack-remove - Dispatched from a toast when it is removed from the stack.
*
* @global_attribute {number} data-toast-duration - Duration milliseconds that a slotted toast must be shown in the stack before it is removed.
*
* @cssprop --omni-toast-stack-z-index - The z-index of the stack.
* @cssprop --omni-toast-stack-font-color - The font color applied to the stack.
*
* @cssprop --omni-toast-stack-anchor-bottom - The position from the bottom toast `position` is set to `bottom`, `bottom-left`, or `bottom-right`.
* @cssprop --omni-toast-stack-anchor-top - The position from the bottom toast `position` is set to `top`, `top-left`, or `top-right`.
* @cssprop --omni-toast-stack-anchor-left - The position from the bottom toast `position` is set to `left`, `top-left`, or `bottom-left`.
* @cssprop --omni-toast-stack-anchor-right - The position from the bottom toast `position` is set to `right`, `top-right`, or `bottom-right`.
*
* @cssprop --omni-toast-stack-gap - The vertical gap between toast elements in the stack.
*/
@customElement('omni-toast-stack')
export class ToastStack extends OmniElement {
/**
* The position to stack toasts
* @attr
*/
@property({ type: String, reflect: true }) position:
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right' = 'bottom';
/**
* Reverse the order of toast with newest toasts showed on top of the stack. By default newest toasts are showed at the bottom of the stack.
* @attr
*/
@property({ type: Boolean, reflect: true }) reverse?: boolean;
@query('.toast-box') private toastContainer!: HTMLDivElement;
@query('slot') private slotElement!: HTMLSlotElement;
private toastCloseClickBound = this.closeToast.bind(this);
/**
* Creates a new `` element with the provided context and appends it to the DOM (either to document body or to provided target parent element).
* @param init Initialisation context for the element.
* @returns The {@link ToastStack} instance that was created.
*/
public static create(init?: ToastStackInit) {
init = init ?? {};
if (!init.parent) {
// If no parent element is specified, the ToastStack will be appended directly on the document body.
init.parent = document.createElement('div');
init.parent.style.display = 'contents';
document.body.appendChild(init.parent);
}
if (typeof init.parent === 'string') {
// If a parent element is specified as a string, find the actual parent element instance using the provided string as an id.
init.parent = document.getElementById(init.parent);
if (!init.parent) {
return undefined;
}
}
const refToStack: Ref = createRef();
renderToElement(
html`
`,
init.parent
);
return refToStack.value;
}
/**
* Push a toast message onto the toast stack.
* @returns The {@link Toast} instance that was created.
*/
public showToast(init: ShowToastInit) {
// Create the toast element.
const toast = document.createElement('omni-toast') as Toast;
toast.type = init.type;
toast.header = init.header;
toast.detail = init.detail;
toast.closeable = init.closeable;
if (init.duration) {
toast.setAttribute(toastDurationAttribute, init.duration.toString());
}
// Setup optional renderers for slot(s)
if (init.prefix) {
const renderElement = document.createElement('omni-render-element');
renderElement.slot = 'prefix';
renderElement.renderer = typeof init.prefix === 'function' ? init.prefix : () => init.prefix as RenderResult;
toast.appendChild(renderElement);
}
if (init.content) {
const renderElement = document.createElement('omni-render-element');
renderElement.renderer = typeof init.content === 'function' ? init.content : () => init.content as RenderResult;
toast.appendChild(renderElement);
}
if (init.close && init.closeable) {
const renderElement = document.createElement('omni-render-element');
renderElement.slot = 'close';
renderElement.renderer = typeof init.close === 'function' ? init.close : () => init.close as RenderResult;
toast.appendChild(renderElement);
}
return this.showInstance(toast);
}
/**
* Push an existing toast instance onto the toast stack.
*/
public showInstance(instance: Toast, options?: ShowToastOptions) {
if (options?.duration) {
instance.setAttribute(toastDurationAttribute, options.duration.toString());
}
if (typeof options?.closeable !== 'undefined') {
instance.closeable = options.closeable;
}
const { matches: motionOK } = window.matchMedia(animationAllowedMedia);
if (motionOK && document.timeline) {
// Animate in the toast if the user allows motion.
this.slideIn(instance);
} else {
// Add the toast to the stack without animation.
this.appendChild(instance);
}
return instance;
}
private onSlotChange() {
const closeClickEvent = 'close-click';
const toastLoadedAttribute = 'data-toast-loaded';
const { matches: motionOK } = window.matchMedia(animationAllowedMedia);
const animationsAllowed = motionOK && document.timeline;
this.slotElement.assignedElements({ flatten: true }).forEach(async (slotted) => {
// Reset the close listeners so any new elements also have close listeners added now.
slotted.removeEventListener(closeClickEvent, this.toastCloseClickBound);
slotted.addEventListener(closeClickEvent, this.toastCloseClickBound);
if (!slotted.hasAttribute(toastLoadedAttribute)) {
// Slotted element has not been loaded before, set the loaded attribute so it wont load again after this.
slotted.setAttribute(toastLoadedAttribute, '');
// If the slotted element has a duration attribute it needs to be removed after the provided milliseconds.
if (slotted.hasAttribute(toastDurationAttribute)) {
if (!animationsAllowed) {
//No animations, just wait for the time to pass.
await new Promise((resolve) => setTimeout(resolve, Number(slotted.getAttribute(toastDurationAttribute) ?? '5000')));
// Remove the toast from the stack after allocated time.
if (slotted.parentElement) {
slotted.remove();
this.raiseToastRemove(slotted);
}
} else {
// Animations are allowed, animate the fade in and out of the toast for the duration provided.
const anim = slotted.animate(
[
// key frames
{ offset: 0, opacity: 0 },
{ offset: 0.1, opacity: 1 },
{ offset: 0.9, opacity: 1 },
{ offset: 1, opacity: 0 }
],
{
// sync options
duration: Number(slotted.getAttribute(toastDurationAttribute) ?? '5000'),
easing: 'ease'
}
);
await anim.finished;
// Remove the toast from the stack once it finishes animation out.
if (slotted.parentElement) {
slotted.remove();
this.raiseToastRemove(slotted);
}
}
} else if (animationsAllowed) {
// Only animate the toast fading in.
slotted.animate(
[
// key frames
{ offset: 0, opacity: 0 },
{ offset: 1, opacity: 1 }
],
{
// sync options
duration: 500,
easing: 'ease'
}
);
}
}
});
}
private async closeToast(e: Event) {
const toast = e.currentTarget as HTMLElement;
const { matches: motionOK } = window.matchMedia(animationAllowedMedia);
// Animate the toast fading out if the user allows motion.
if (motionOK && document.timeline) {
// Get current opacity to cater for existing fade out of timed toasts.
const currentOpacity = Number(getComputedStyle(toast).getPropertyValue('opacity'));
const anim = toast.animate(
[
// key frames
{ offset: 0, opacity: currentOpacity },
{ offset: 1, opacity: 0 }
],
{
// sync options
duration: 200,
easing: 'ease'
}
);
await anim.finished;
}
if (toast.parentElement) {
toast.remove();
this.raiseToastRemove(toast);
}
}
private raiseToastRemove(toast: Element) {
this.dispatchEvent(
new CustomEvent('toast-remove', {
bubbles: true,
composed: true,
cancelable: false,
detail: toast as Toast
})
);
toast?.dispatchEvent(
new CustomEvent('toast-stack-remove', {
bubbles: true,
composed: true,
cancelable: false,
detail: this
})
);
}
private async slideIn(toast: Toast) {
// Using the FLIP animation technique for performance. See more here: https://aerotwist.com/blog/flip-your-animations/
// Ensure the toast has rendered at least the first update before adding toasts to the container.
if (!this.toastContainer) {
await this.updateComplete;
}
// FIRST
const first = this.toastContainer.offsetHeight;
// add new child to change container size
this.appendChild(toast);
// LAST
const last = this.toastContainer.offsetHeight;
// INVERT
const invert = last - first;
// PLAY
const animation = this.toastContainer.animate([{ transform: `translateY(${invert}px)` }, { transform: 'translateY(0)' }], {
duration: 150,
easing: 'ease-out'
});
animation.startTime = document.timeline.currentTime;
}
static override get styles() {
return [
css`
:host {
position: fixed;
z-index: var(--omni-toast-stack-z-index, 10000);
gap: 20px;
color: var(--omni-toast-stack-font-color, var(--omni-font-color));
}
:host(:not([position])),
:host([position=bottom]) {
bottom: var(--omni-toast-stack-anchor-bottom, 20px);
left: 50%;
transform: translate(-50%, 0);
}
:host([position=top]) {
top: var(--omni-toast-stack-anchor-top, 20px);
left: 50%;
transform: translate(-50%, 0);
}
:host([position=left]) {
left: var(--omni-toast-stack-anchor-left, 20px);
top: 50%;
transform: translate(0, -50%);
}
:host([position=right]) {
right: var(--omni-toast-stack-anchor-right, 20px);
top: 50%;
transform: translate(0, -50%);
}
:host([position=top-left]) {
top: var(--omni-toast-stack-anchor-top, 20px);
left: var(--omni-toast-stack-anchor-left, 20px);
}
:host([position=top-right]) {
top: var(--omni-toast-stack-anchor-top, 20px);
right: var(--omni-toast-stack-anchor-right, 20px);
}
:host([position=bottom-left]) {
bottom: var(--omni-toast-stack-anchor-bottom, 20px);
left: var(--omni-toast-stack-anchor-left, 20px);
}
:host([position=bottom-right]) {
bottom: var(--omni-toast-stack-anchor-bottom, 20px);
right: var(--omni-toast-stack-anchor-right, 20px);
}
.toast-box {
display: flex;
flex-direction: column;
}
:host([reverse]) .toast-box {
flex-direction: column-reverse;
}
::slotted(omni-toast),
omni-toast {
min-width: unset;
max-width: unset;
will-change: opacity;
margin-top: var(--omni-toast-stack-gap, 10px) !important;
}
`
];
}
override render() {
return html`
`;
}
}
const animationAllowedMedia = '(prefers-reduced-motion: no-preference)';
/**
* Attribute for the duration milliseconds that a slotted toast must be shown in an `` before it is removed.
*/
export const toastDurationAttribute = 'data-toast-duration';
/**
* Context for `ToastStack.create` function to programmatically create a new `` instance.
*/
export type ToastStackInit = {
/**
* The id to apply to the ToastStack element.
*/
id?: string;
/**
* The container to append the ToastStack as child. If not provided will append to a new div element on the document body.
*/
parent?: string | HTMLElement | DocumentFragment | null;
/**
* The position to stack toasts
*/
position?: 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
/**
* Reverse the order of toast with newest toasts showed on top of the stack. By default newest toasts are showed at the bottom of the stack.
*/
reverse?: boolean;
};
/**
* Context for `showToast` function to programmatically add a new `` instance to an existing ``.
*/
export type ShowToastInit = {
/**
* The type of toast to display.
*/
type: 'success' | 'warning' | 'error' | 'info' | 'none';
/**
* The toast title.
*/
header?: string;
/**
* The toast description.
*/
detail?: string;
/**
* If true, will display a close button that fires a `close-click` event when clicked and removes the toast from the stack.
*/
closeable?: boolean;
/**
* If provided will be the time in millisecond the toast is displayed before being automatically removed from the stack.
*/
duration?: number;
/**
* Content to render before toast message area.
*/
prefix?: RenderFunction | RenderResult;
/**
* Content to render inside the component message area.
*/
content?: RenderFunction | RenderResult;
/**
* Content to render as the close button when `closeable`.
*/
close?: RenderFunction | RenderResult;
};
/**
* Context for `showToast` function to programmatically add an existing `` instance to an existing ``.
*/
export type ShowToastOptions = {
/**
* If provided will be the time in milliseconds the toast is displayed before being automatically removed from the stack.
*/
duration?: number;
/**
* If true, will display a close button that fires a `close-click` event when clicked and removes the toast from the stack.
*/
closeable?: boolean;
};
declare global {
interface HTMLElementTagNameMap {
'omni-toast-stack': ToastStack;
}
}