@svelte-put/async-stack
GithubCompatible with or powered directly by Svelte runes.
Introduction
@svelte-put/async-stack
is no more than a stack structure that sits between your component library and application, providing type-safe push&pop mechanism to mount/unmount components from anywhere to a centralized DOM parent element.
Installation
npm install --save-dev @svelte-put/async-stack
pnpm add -D @svelte-put/async-stack
yarn add -D @svelte-put/async-stack
Comprehensive Example
This section presents a working example of the package. You will notice that @svelte-put/async-stack
only handles the core logic and leave UI up to you to configure. For that reason, the setup is quite verbose. This, however, enables flexibility: the package is not a plug-and-play prebuilt component bundle but rather designed to help build custom notification/dialog system, for example. We will unpack each part of the library in later sections of the document to see how customization can be achieved.
Stack Setup (
notification-stack.ts
): aStack
is created with (optionally) predefined notification variants.Component Setup (
Notification.svelte
): define a component to be rendered as notification.Portal Setup (
NotificationPortal.svelte
): a centralized place to insert rendered notifications, with the help of therender
action fromStack
, and some sensible animations powered bysvelte/animate
andsvelte/transition
.Usage (
usage.svelte
): notification is pushed using the previously createdStack
.
import { stack } from '@svelte-put/async-stack';
import Notification from './Notification.svelte';
export const notiStack = stack({ timeout: 3000 })
// add a minimalistic variant config
.addVariant('info', Notification)
// add a verbose variant config
.addVariant('special', {
id: 'counter',
component: Notification,
props: {
special: true,
content: 'A very special notification',
},
})
// build the actual stack
.build();
<script lang="ts" module>
import type { StackItemProps } from '@svelte-put/async-stack';
export interface NotificationProps extends StackItemProps<{ reason: string }> {
content?: string;
special?: boolean;
}
</script>
<script lang="ts">
import { fly } from 'svelte/transition';
let {
content = 'Placeholder',
special = false,
item, // injected by @svelte-put/async-stack
}: NotificationProps = $props();
function dismiss() {
item.resolve({ reason: 'popped from within component' });
}
</script>
<!-- eslint-disable svelte/valid-compile -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="not-prose pointer-events-auto relative flex items-start justify-between px-4 py-2 shadow-lg md:items-center"
class:hl-error={special}
class:hl-info={!special}
in:fly|global={{ duration: 200, y: -20 }}
onmouseenter={item.pause}
onmouseleave={item.resume}
>
<p>Notification (variant: {item.config.variant}): {content} (id = {item.config.id})</p>
<button onclick={dismiss} type="button" class="c-btn c-btn--icon">
<i class="i i-[x] h-6 w-6"></i>
<span class="sr-only">Dismiss</span>
</button>
<div
class="progress absolute inset-x-0 bottom-0 h-0.5 origin-left {special
? 'bg-error-bg-200'
: 'bg-info-bg-200'}"
class:paused={item.state === 'paused'}
style={`--progress-duration: ${item.config.timeout}ms;`}
aria-disabled={true}
></div>
</div>
<style>
.progress {
animation: progress var(--progress-duration) linear;
}
.progress.paused {
animation-play-state: paused;
}
@keyframes progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
</style>
<script>
import { flip } from 'svelte/animate';
import { fly, fade } from 'svelte/transition';
import { notiStack } from './notification-stack';
</script>
<!-- notification portal, typically setup at somewhere global like root layout -->
<aside
class="z-notification pointer-events-none fixed inset-y-0 right-0 flex flex-col-reverse justify-end gap-4 p-10 md:left-1/2 md:justify-start"
>
{#each notiStack.items as notification (notification.config.id)}
<div
animate:flip={{ duration: 200 }}
in:fly={{ duration: 200, x: 20 }}
out:fade={{ duration: 120 }}
class="relative w-full"
use:notiStack.actions.render={notification}
></div>
{/each}
</aside>
<script lang="ts">
import { notiStack } from './notification-stack';
const pushInfo = () => notiStack.push('info', { props: { content: 'An info notification' } });
const pushSpecial = () => notiStack.push('special');
</script>
<!-- notification push triggers -->
<button class="c-btn c-btn--outlined" onclick={pushInfo}>Push an info notification</button>
<button class="c-btn" onclick={pushSpecial}>Push a special notification</button>
Stack
This is the core part of @svelte-put/async-stack
. It holds all logic for the async push
& pop
mechanism. Shown in Comprehensive Example, a Stack
instance is created using a builder pattern that provides type-safety for push
invocations.
Initialization
import { stack } from '@svelte-put/async-stack';
export const notiStack = stack({ /** optional common config, shared by all notifications */ })
.addVariant('name', /* specific config for notification of this variant */)
.build(); // remember to call this to build the actual stack;
The stack
function accepts an optional config object that will be merged with all StackItem
instance config on push
.
type stack = (config?: StackItemCommonConfig) => import('@svelte-put/async-stack').StackBuilder;
type StackItemCommonConfig = {
/**
* milliseconds to wait and automatically pop the stack item.
* Defaults to `0` (disabled)
*/
timeout?: number;
/**
* id generator for stack items. Defaults to 'uuid'
* - counter: use an auto-incremented counter that is scoped to the stack
* - uuid: use `crypto.randomUUID()`, fallback to `counter` if not available
* - callback: a custom function that accepts {@link StackItemInstanceConfig} and returns a string as the id
*/
id?:
| 'counter'
| 'uuid'
| ((config: /* StackItemInstanceConfig, omitted for conciseness */) => string);
};
A Stack
instance may be wrapped in a Svelte context for better encapsulation. See Recipes for an example.
Predefined Variants
Use the addVariant
method to add a predefined config, allowing quick push with minimal ad-hoc configuration. The first parameter is a unique variant name, while the second accepts either a Svelte component or a config object (as seen in Comprehensive Example).
type addVariant = (
variant: string,
config: StackItemVariantConfig | import('svelte').Component,
) => import('@svelte-put/async-stack').StackBuilder;
type StackItemVariantConfig = {
/** extends StackItemCommonConfig, omitted for conciseness */
variant: string;
component: import('svelte').Component;
props?: {
/** inferred props for component */
};
};
Push
A StackItem
can be pushed onto Stack
by invoking the push
method on Stack
instance. A push
call take either one of the predefined variant, as seen in Comprehensive Example...
notiStack.push('<variant>', /** optional StackItem instance config & component props */);
...or the 'custom'
variant, helpful for one-off, ad-hoc push
notiStack.push('custom', {
component: MyNotification, // required
props: { /** props for MyNotification */ },
id: () => 'one-time-id',
timeout: 4000,
});
Custom push
must provide a component in its config.
If you find that the push
interface is too verbose (it is, by design), you can further create your own proxy util(s).
export const info = (content: string) => notiStack.push('info', { props: { content } });
// later
info('An actual info notification');
Pop
An active StackItem
instance can be popped either from within the component (typically following user interactions), by calling resolve
from the injected item
prop...
<script lang="ts">
import type { StackItemProps } from '@svelte-put/async-stack';
let { item }: StackItemProps<{ confirmed: boolean }> = $props();
const confirm = () => item.resolve({ confirmed: true });
const cancel = () => item.resolve({ confirmed: false });
</script>
<!-- ...truncated... -->
...or via the pop
method on Stack
instance:
import { notiStack } from './notification-stack';
// pop the topmost notification
notiStack.pop();
// pop a specific notification
notiStack.pop('specific-id');
// pop a specific notification with custom resolution value
notiStack.pop('id', 'custom-resolve-detail');
// alternatively, provide arguments as object
notiStack.pop({
detail: 'custom-resolve-detail',
}); // pop the topmost notification with custom resolution value
Stack.pop
returns the popped StackItem
instance, or null
if no notification is found with the provided id
. You can set id in the push config, and get it back from the returned object.
import { notiStack } from './notification-stack';
const pushed = notiStack.push('info', { id: 'my-id' });
// ... later ...
const popped = notiStack.pop(pushed.config.id); // or just 'my-id'
// pushed === popped -> true
Typescript Support
To provide typing for StackItem.pop
, pass type of your component as the generic argument. This helps infer the resolution
type. See Awaiting for Resolution for more information.
import ConfirmNoti from './ConfirmNoti.svelte';
const popped = notiStack.pop<typeof ConfirmNoti>('id');
if (popped) {
const resolution = await notiStack.resolution;
if (resolution) { // can be undefined, for example when timeouted
console.log(resolution.confirmed);
}
}
<script lang="ts">
import type { StackItemProps } from '@svelte-put/async-stack';
let { item }: StackItemProps<{ confirmed: boolean }> = $props();
</script>
<!-- ...truncated... -->
Awaiting for Resolution
A StackItem
instance is resolved when it is popped from a Stack
. This resolution is a Promise that can be awaited, and its value is from argument passed to either Stack.pop
or StackItem.resolve
(injected item
prop). This is especially useful for complex interactive notification (see Component for StackItem section for an exmaple of such).
import { notiStack } from './notification-stack';
const pushed = notiStack.push('info');
const resolved = await pushed.resolution;
To provide typing for resolution
, simply extend the StackItemProps
. The generic argument passed to StackItemProps
is the type of resolution
's Promise value:
<script lang="ts">
import type { StackItemProps } from '@svelte-put/async-stack';
let { item }: StackItemProps<{ confirmed: boolean }> = $props();
// typeof item.resolution === Promise<{ confirmed: boolean }>
</script>
In the following example, try pressing the "Push a persistent notification" button and observe the async nature of the push & pop mechanism.
<script lang="ts">
import { notiStack } from './comprehensive/notification-stack';
let promise: Promise<unknown> | null = null;
async function pushNoti() {
const pushed = notiStack.push('info', {
timeout: 0,
props: { content: 'persistent' },
});
promise = pushed.resolution;
await promise;
setTimeout(() => (promise = null), 2000);
}
function popNoti() {
notiStack.pop();
}
</script>
<button onclick={pushNoti} disabled={!!promise} class="c-btn" class:bg-gray-500={!!promise}>
Push a persistent notification
</button>
{#if promise}
<p class="mt-2 text-blue-500">
{#await promise}
Notification is pushed and waiting for resolution. Either click the x button on the
notification, or <button class="c-link" onclick={popNoti}>click here</button> to pop the notification.
{:then}
Resolved (resetting in 2 seconds)
{/await}
</p>
{/if}
Timeout and Progress
const pushed = notiStack.push('info', { timeout: 3000 });
If a StackItem
has timeout
specified in its config, Stack
sets up a setTimeout
to automatically pop it in due time.
A StackItem
upon being timeouted will resolve with no value. Make sure you are aware of this if you await for its resolution somewhere else in the application.
This timeout can be paused and resumed by invoking corresponding methods on the Stack
instance.
notiStack.pause(pushed.id);
notiStack.resume(pushed.id);
The pause
and resume
methods on Stack
instance are actually just proxies for the same ones on StackItem
instance, which is accessible from within component via the injected item
prop...
<script lang="ts">
import type { StackItemProps } from '@svelte-put/async-stack';
let { item }: StackItemProps<{ confirmed: boolean }> = $props();
$inspect(item.state);
const pause = () => item.pause();
const resume = () => item.resume();
</script>
<!-- ...truncated... -->
...accept when no id
is specified, in which case the method will be applied to all active StackItem
instances.
notiStack.pause(); // pausing all items
notiStack.resume(); // resuming all items
item.state
is a reactive Svelte rune with value of 'idle' | 'elapsing' | 'paused' | 'timeout' | 'resolved'
, helpful to control the play state of some animation, for example as seen in Comprehensive Example.
Portal for Rendering Stack
As seen in Comprehensive Example, @svelte-put/async-stack
does not control how and where you render your stack items, as it cannot assume the styling and animation required for your project. To make it easier to set up one, however, you can utilize the render
action on a Stack
.
render
action
The helper The render
action makes sure your StackItem
components receive all the correct props upon push
. In the example above, the associated components will be rendered as direct children of <div>
.
<script>
import { notiStack } from './notification-stack';
</script>
<!-- notification portal, typically setup at somewhere global like root layout -->
<aside>
<ul>
{#each notiStack.items as notification (notification.config.id)}
<li use:notiStack.actions.render={notification}></li>
{/each}
</ul>
</aside>
StackItem
Events upon Mounting & Unmounting The render
action also exposes two helpful events, stackitemmount
and stackitemunmount
, helpful for post processing and cleanup.
<li
use:notiStack.actions.render={notification}
onstackitemmount
onstackitemunmount
></li>
For a more concrete example on how these events are helpful, see Recipes - Svelte Sonner Inspired Toast System.
Component for StackItem
Any Svelte component can be used with @svelte-put/async-stack
. In any case, however, your component should accept an item
prop that is the actual StackItem
instance created by Stack
on push. If you follow the setup pattern introduced in Portal For Rendering Stack, the render
action should automatically pass the item
prop to your component.
<script lang="ts">
import type { StackItemProps } from '@svelte-put/async-stack';
let { item }: StackItemProps<{ confirmed: boolean }> = $props();
</script>
<!-- ...truncated... -->
The item
prop has the following interface:
interface StackItem<Resolved> {
// the config applied on push
config: StackItemInstanceConfig;
// managing timeout
state: StackItemState;
resume: () => void;
pause: () => void;
// handling resolution
resolution: Promise<Resolved | undefined>;
resolve: (r: Resolved) => void;
}
type StackItemState = 'idle' | 'elapsing' | 'paused' | 'timeout' | 'resolved';
type StackItemInstanceConfig = {
id: string;
timeout: number;
variant: string;
component: import('svelte').Component;
props?: Record<string, unknown>;
}
Timeout Management
When aStackItem
is pushed with a timeout
, calling pause
or resume
can help better control its state
, as described in Timeout and Progress. The Comprehensive Example demonstrates this by pausing timeout upon pointer hover over the notification. Try it out again below:
Resolution Handling
Calling resolve
on the item
prop will pop the notification from Stack
and resolve the resolution
promise, as described in Awaiting for Resolution. This is especially useful for interactive notifications, such as in the example below.
<script lang="ts">
import type { StackItemProps } from '@svelte-put/async-stack';
import { fly } from 'svelte/transition';
let { item, message }: StackItemProps<boolean> & { message: string } = $props();
const join = () => item.resolve(true);
const del = () => item.resolve(false);
</script>
<div
class="bg-bg-100 pointer-events-auto rounded-sm px-4 py-2 shadow-lg"
in:fly|global={{ duration: 200, y: -20 }}
>
<p class="text-xl font-bold">Invitation</p>
<p class="text-lg">{message}</p>
<div class="flex gap-6">
<button class="c-btn w-40" onclick={join}> Join </button>
<button class="c-btn c-btn--outlined w-40" onclick={del}> Delete </button>
</div>
</div>
<script>
import { notiStack } from '../comprehensive/notification-stack';
import InteractiveNotification from './InteractiveNotification.svelte';
let state = $state('idle');
async function pushNoti() {
const pushed = notiStack.push('custom', {
timeout: 0,
component: InteractiveNotification,
props: {
message: 'You are invited to join the Svelte community!',
},
});
state = 'pending';
const agreed = await pushed.resolution;
state = agreed ? 'accepted' : 'denied';
}
</script>
<p>
{#if state === 'idle'}
Waiting for notification to be pushed
{:else if state === 'pending'}
Waiting for user action to resolve notification
{:else}
Invitation was <span
class="px-2"
class:hl-error={state == 'denied'}
class:hl-success={state === 'accepted'}
>
{state}
</span>
{/if}
</p>
<button class="c-btn" onclick={pushNoti} disabled={state === 'pending'}>
Trigger Interactive Notification
</button>
Recipes
This section presents some useful patterns to better integrate @svelte-put/async-stack
into your application.
Wrapping Stack in Svelte Context
To share a Stack
instance across the application, it is useful to wrap it in an idiomatic Svelte context. The example below shows a minimal version of such pattern.
<script>
import { setContext } from 'svelte';
import { notiStack } from '$lib/notification/notification-stack';
let { children } = $props();
setContext('app:noti', notiStack);
</script>
{@render children()}
export const notiStack = stack({ /* common config */ })
.addVariant('name', /* specific config */)
.build();
export type NotiStack = typeof notiStack;
<script lang="ts">
import type { NotiStack } from '$lib/notification/notification-stack';
const notiStack = getContext<NotiStack>('app:noti');
function pushNoti = () => notiStack.push(/* push config */);
</script>
<buttton onclick={pushNoti}></buttton>
Modal / Dialog
A modal or dialog system shares much similarity with a notification system, although components tend to be much more complex. The following example shows one possible setup for such system.
import { stack } from '@svelte-put/async-stack';
import ConfirmationModal from './ConfirmationModal.svelte';
export const modalStack = stack()
.addVariant('confirm', {
component: ConfirmationModal,
props: {
title: 'Confirmation',
description: 'Do we have an accord?',
},
})
.build();
<script lang="ts">
import type { StackItemProps } from '@svelte-put/async-stack';
import { enhancedialog } from './enhance-dialog';
let {
item,
title,
description,
}: StackItemProps<{ confirmed: boolean }> & {
title: string;
description: string;
} = $props();
let dialog: HTMLDialogElement;
function checkAndResolve() {
// when dialog has closed and animation has finished, resolve
if (!dialog.open) {
item.resolve({ confirmed: dialog.returnValue === 'yes' });
}
}
$effect(() => {
dialog?.showModal();
});
</script>
<dialog
class="bg-bg-200 text-fg backdrop:bg-bg space-y-4 p-8 shadow-lg backdrop:opacity-50"
bind:this={dialog}
use:enhancedialog
onclickbackdrop={() => dialog.close()}
onanimationend={checkAndResolve}
>
<p class="text-xl font-medium">{title}</p>
<p>{description}</p>
<form method="dialog" class="flex justify-end gap-4">
<button type="submit" class="c-btn c-btn--outlined" value="no">No</button>
<button type="submit" class="c-btn" value="yes">Yes</button>
</form>
</dialog>
<style>
dialog {
inset: 0;
display: block; /* required for animation to work */
margin: auto;
animation-name: fly-out-down;
animation-duration: 150ms;
animation-timing-function: var(--default-transition-timing-function);
animation-fill-mode: forwards;
}
dialog[open] {
animation-name: fly-in-up;
}
@keyframes fly-in-up {
from {
transform: translateY(50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fly-out-down {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(50px);
opacity: 0;
}
}
</style>
<script>
import { modalStack } from './modal-stack';
</script>
<!-- notification portal, typically setup at somewhere global like root layout -->
<aside class="z-modal pointer-events-none fixed inset-0">
{#each modalStack.items as modal (modal.config.id)}
<div class="pointer-events-auto h-full w-full" use:modalStack.actions.render={modal}></div>
{/each}
</aside>
<script lang="ts">
import { modalStack } from './modal-stack';
let confirmed: boolean | undefined = undefined;
async function confirm() {
const pushed = modalStack.push('confirm');
({ confirmed } = (await pushed.resolution) ?? {});
}
</script>
<div class="not-prose flex items-center gap-2">
<button class="c-btn" onclick={confirm}>Trigger Modal</button>
{#if confirmed === true}
<p class="text-sm font-bold text-green-500">We have an accord.</p>
{:else if confirmed === false}
<p class="text-sm font-bold text-red-500">We don't have an accord.</p>
{/if}
</div>
// TODO: candidate for a separate package?
/**
* @typedef EnhanceDialogAttributes
* @property {(e: CustomEvent<void>) => void} [onclickbackdrop] - fired when clicked on backdrop
*/
/**
* @typedef {import('svelte/action').ActionReturn<undefined, EnhanceDialogAttributes>} EnhanceDialogActionReturn
*/
/**
* @param {HTMLDialogElement} dialog
* @returns {EnhanceDialogActionReturn}
*/
export function enhancedialog(dialog) {
/**
* @param {MouseEvent} event
*/
function onClick(event) {
let rect = /** @type {HTMLDialogElement} */ (event.target).getBoundingClientRect();
if (!event.clientX || !event.clientY) return; // not a mouse event (probably triggered by keyboard)
if (
rect.left > event.clientX ||
rect.right < event.clientX ||
rect.top > event.clientY ||
rect.bottom < event.clientY
) {
dialog.dispatchEvent(new CustomEvent('clickbackdrop'));
}
}
dialog.addEventListener('click', onClick);
return {
destroy() {
dialog.removeEventListener('click', onClick);
},
};
}
HTML dialog for the win!
The ConfirmationModal
component uses a native <dialog>
element, which is a great way to create accessible dialogs without reinventing the wheel. A couple of things worth noting from the example above:
- dialog.showModal() is used to create dialog-as-a-modal, providing some additional feature like focus-trapping and escape key. Depending on your use cases, you might just need to use dialog.show() instead.
- The
ConfirmationModal
component usesanimationend
event to check for when to resolve theStackItem
(note the fly-in-and-out animation). This works in conjunction with a correctform
setup and the use of dialog.returnValue. If you do not care about such things, simply callitem.resolve
when needed. - Currently there is no way yet to natively capture click event on the
<dialog>
backdrop, theenhancedialog
action is there for that.
Svelte Sonner Inspired Toast System
As demonstrated throughout this documentation, svelte-put/async-stack
can be used to build a push notification system. See Comprehensive Example for a complete example. The example below shows a more fancy version that is inspired by Svelte Sonner.
import { stack } from '@svelte-put/async-stack';
import Toast from './Toast.svelte';
export const toastStack = stack({ timeout: 3_000 }).addVariant('toast', Toast).build();
// create push proxies
const STATUSES = ['info', 'success', 'warning', 'error'] as const;
type Status = (typeof STATUSES)[number];
type ToastPusher = (message: string) => void;
export const toast: Record<Status, ToastPusher> = STATUSES.reduce(
(acc, status) => {
acc[status] = (message) =>
toastStack.push('toast', {
props: {
status,
title: status.charAt(0).toUpperCase() + status.slice(1),
message,
},
});
return acc;
},
{} as Record<Status, ToastPusher>,
);
<script lang="ts" module>
import type { StackItemProps } from '@svelte-put/async-stack';
import type { HTMLAttributes } from 'svelte/elements';
export interface ToastProps extends HTMLAttributes<HTMLElement>, StackItemProps {
title?: string;
message: string;
status?: 'info' | 'success' | 'warning' | 'error';
}
const iconClassMap: Record<NonNullable<ToastProps['status']>, string> = {
info: 'i-[info]',
success: 'i-[check-circle]',
warning: 'i-[warning]',
error: 'i-[warning-circle]',
};
</script>
<script lang="ts">
let { item, title, message, status = 'info', class: cls, ...rest }: ToastProps = $props();
const iconClass = $derived(status ? iconClassMap[status] : 'i-[info]');
function dismiss() {
item.resolve();
}
</script>
<article
class="relative shadow {cls}"
role="status"
aria-live="polite"
aria-atomic="true"
data-status={status}
{...rest}
>
<!-- x button to dismiss -->
<button
onclick={dismiss}
class="absolute right-0 top-0 flex -translate-y-1/2 translate-x-1/2 cursor-pointer rounded-full border border-current bg-inherit p-1.5"
>
<i class="i i-[x] h-3.5 w-3.5"></i>
<span class="sr-only">Dismiss</span>
</button>
<div class="relative flex items-start gap-3 overflow-hidden p-3">
<div class="i {iconClass} h-6 w-6 shrink-0"></div>
<div class="leading-normal">
<p class="mb-2 border-b border-current pb-1 font-medium">
{title}
</p>
<p>{message}</p>
</div>
<!-- progress, (time until auto-dismiss) -->
<div
class="progress absolute inset-x-0 bottom-0 h-0.5 origin-left overflow-hidden"
style:--progress-duration={item.config.timeout + 'ms'}
style:--progress-play-state={item.state === 'paused' ? 'paused' : 'running'}
aria-disabled="true"
></div>
</div>
</article>
<style>
article {
color: var(--toast-color-fg);
background-color: var(--toast-color-bg);
&[data-status='info'] {
--toast-color-icon: var(--color-info-fg);
--toast-color-progress: var(--color-info-bg-200);
--toast-color-bg: var(--color-info-bg);
--toast-color-fg: var(--color-info-fg);
}
&[data-status='success'] {
--toast-color-icon: var(--color-success-fg);
--toast-color-progress: var(--color-success-bg-200);
--toast-color-bg: var(--color-success-bg);
--toast-color-fg: var(--color-success-fg);
}
&[data-status='warning'] {
--toast-color-icon: var(--color-warning-fg);
--toast-color-progress: var(--color-warning-bg-200);
--toast-color-bg: var(--color-warning-bg);
--toast-color-fg: var(--color-warning-fg);
}
&[data-status='error'] {
--toast-color-icon: var(--color-error-fg);
--toast-color-progress: var(--color-error-bg-200);
--toast-color-bg: var(--color-error-bg);
--toast-color-fg: var(--color-error-fg);
}
}
.progress {
background-color: var(--toast-color-progress);
animation: progress var(--progress-duration) linear;
animation-play-state: var(--progress-play-state);
}
.i {
color: var(--toast-color-icon);
}
@keyframes progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
</style>
<script lang="ts">
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import { toastStack } from './toast';
const MAX_ITEMS = 3;
const SCALE_STEP = 0.05;
const EXPAND_REM_GAP = 1;
const TRANSLATE_REM_STEP = 1;
/** if render top down, 1, if bottom up, -1 */
const Y_SIGN = -1;
let expanded = $state(false);
let visibleItems = $derived(toastStack.items.slice(-3));
let liMap: Record<string, HTMLLIElement> = $state({});
let liHeightMap = $derived(
Object.entries(liMap).reduce(
(acc, [id, li]) => {
acc[id] = li.clientHeight;
return acc;
},
{} as Record<string, number>,
),
);
let liOpacityMap = $derived(
Object.entries(liMap).reduce(
(acc, [id, li]) => {
const index = parseInt(li.dataset.index ?? '0') || 0;
acc[id] = index >= toastStack.items.length - MAX_ITEMS ? 1 : 0;
return acc;
},
{} as Record<string, number>,
),
);
let liTransformMap = $derived(
Object.entries(liMap).reduce(
(acc, [id, li]) => {
const index = parseInt(li.dataset.index ?? '0') || 0;
if (!expanded) {
const delta = Math.min(toastStack.items.length - index, MAX_ITEMS + 1) - 1;
const scaleX = 1 - delta * SCALE_STEP;
const liHeight = liHeightMap[id] ?? 0;
let topLiHeight = 0;
const topItem = toastStack.items.at(-1);
if (topItem) topLiHeight = liHeightMap[topItem.config.id] ?? 0;
let scaleY = scaleX;
let yPx = 0;
if (liHeight >= topLiHeight) {
scaleY = topLiHeight / liHeight || 1;
} else {
yPx = Y_SIGN * (topLiHeight - liHeight * scaleY);
}
const yRem = (Y_SIGN * delta * TRANSLATE_REM_STEP) / scaleY;
acc[id] = `scaleX(${scaleX}) scaleY(${scaleY}) translateY(calc(${yPx}px + ${yRem}rem))`;
return acc;
}
let accumulatedHeight = 0;
let rem = 0;
for (let i = toastStack.items.length - 1; i > index; i--) {
accumulatedHeight += liHeightMap[toastStack.items[i].config.id] ?? 0;
rem += EXPAND_REM_GAP;
}
let scale = index < toastStack.items.length - MAX_ITEMS ? 1 - MAX_ITEMS * SCALE_STEP : 1;
acc[id] =
`scale(${scale}) translateY(calc(${Y_SIGN * accumulatedHeight}px + ${Y_SIGN * rem}rem))`;
return acc;
},
{} as Record<string, string>,
),
);
const olHeight = $derived.by(() => {
if (!expanded) return 'auto';
const contentPx = visibleItems.reduce((acc, item) => {
acc += (liHeightMap[item.config.id] ?? 0) + EXPAND_REM_GAP;
return acc;
}, 0);
const remGap = EXPAND_REM_GAP * (visibleItems.length - 1);
return `calc(${contentPx}px + ${remGap}rem)`;
});
function onMouseEnter() {
expanded = true;
toastStack.pause(); // pause all items;
}
function onMouseLeave() {
expanded = false;
toastStack.resume(); // resume all items;
}
</script>
<!-- toast portal, typically setup at somewhere global like root layout -->
<ol
class="z-notification tablet:bottom-10 tablet:right-10 fixed bottom-2 right-4 grid content-end items-end"
style:height={olHeight}
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
data-expanded={expanded}
>
{#each toastStack.items as notification, index (notification.config.id)}
{@const id = notification.config.id}
<li
data-index={index}
class="w-full origin-center"
animate:flip={{ duration: 200 }}
transition:fly={{ duration: 200, y: '2rem' }}
style:opacity={liOpacityMap[id]}
style:transform={liTransformMap[id]}
use:toastStack.actions.render={notification}
onstackitemmount={(e) => {
liMap[id] = e.target as HTMLLIElement;
}}
onstackitemunmount={() => {
delete liMap[id];
}}
></li>
{/each}
</ol>
<style lang="postcss">
ol {
width: min(calc(100% - 2rem), 40rem);
transition-duration: 500ms;
&:hover {
transition-duration: 250ms;
}
}
li {
grid-column: 1;
grid-row: 1;
transition-timing-function: var(--default-transition-timing-function);
transition-duration: inherit;
transition-property: transform;
}
</style>
<script lang="ts">
import { toast } from './toast';
const message = `Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s`;
</script>
<div class="not-prose flex flex-wrap items-center gap-2">
<button class="c-btn c-btn--outlined" onclick={() => toast.info(message)}>info</button>
<button class="c-btn c-btn--outlined" onclick={() => toast.success(message)}>success</button>
<button class="c-btn c-btn--outlined" onclick={() => toast.warning(message)}>warning</button>
<button class="c-btn c-btn--outlined" onclick={() => toast.error(message)}>error</button>
</div>