@svelte-put/noti
type-safe and headless async notification builder
Installation
terminal
npm install --save-dev @svelte-put/noti
Comprehensive Example
This section presents a working example of the package. You will notice that @svelte-put/noti
only handles the core logics and leave UI up to you to configure. And for that reason, the setup
can be a bit verbose. However, this enables flexibility. We will unpack each part of the library
in later sections of the document to see how customization can be achieved.
1
: a component is set up to be rendered as notification,
2
: a
NotificationStore
is created with (optionally) predefined notification variants,3
: a "notification portal" is registered with
use:portal
, for rendering notifications,4
: notification is "pushed
" using the previously createdNotificationStore
.
a comprehensive example
<script lang="ts">
import { notiStore } from './notification-store';
const pushInfo = () => notiStore.push('info', { props: { content: 'An info notification' } });
const pushSpecial = () => notiStore.push('special');
</script>
<!-- notification push triggers -->
<button class="c-btn-primary" on:click={pushInfo}>Push an info notification</button>
<button class="c-btn-primary-outline" on:click={pushSpecial}>Push a special notification</button>
NotificationStore
The NotificationStore
is the key part of @svelte-put/noti
. It holds
all internal logics and is used for the push
& pop
mechanism. As shown
in the Comprehensive Example section,
NotificationStore
is created with a builder pattern that provides type-safety for
push
invocations.
store
notification store builder pattern
import { store } from '@svelte-put/noti';
const notiStore = store({ /** optional common config */ })
.variant(/* */)
.build(); // remember to call this to build the actual store
The store
function accepts an optional config object that will be merged to all
notification instance config on push
.
simplified interfaces of store & NotificationCommonConfig
type store = (
config?: NotificationCommonConfig,
) => import('@svelte-put/noti').NotificationStoreBuilder;
type NotificationCommonConfig = {
/**
* milliseconds to wait and automatically pop the notification.
* Defaults to `3000`. Set to `false` to disable
*/
timeout?: number | false;
/**
* id generator for notifications. Defaults to 'uuid'.
*
* @remarks
* - counter: use an auto-incremented counter that is scoped to the store
* - uuid: use `crypto.randomUUID()`, fallback to `counter` if not available
* - callback: a custom function that accepts {@link NotificationInstanceConfig} and returns a string as the id
*/
id?:
| 'counter'
| 'uuid'
| ((config: {
/* NotificationInstanceConfig, omitted for conciseness */
}) => string);
};
variant
The variant
method adds a predefined variant config that provides intellisense
and reusability. It accepts a mandatory variant
string, and either a Svelte
component or a config object (see Comprehensive Example ).
simplified interfaces of variant & NotificationVariantConfig
type SvelteComponentClass = import('svelte').ComponentType<import('svelte').SvelteComponent>;
type variant = (
variant: string,
config: NotificationVariantConfig | SvelteComponentClass,
) => import('@svelte-put/noti').NotificationStoreBuilder;
type NotificationVariantConfig = {
/** extends NotificationCommonConfig, omitted for conciseness */
variant: string;
component: SvelteComponentClass;
props?: {
/** inferred props for component */
};
};
Pushing
New notifications can be pushed with the NotificationStore.push
method. A
push
call can use either one of the predefined variant, as seen in the
Comprehensive Example section, ...
push from predefined variant config
notiStore.push('<variant>', { /** optional config & component props */ });
... or the 'custom'
variant, helpful for one-off notification for example.
Custom push
must provide a component in its config.
push with custom config
notiStore.push('custom', {
component: NotificationComponent, // required
props: { /** props for NotificationComponent */ },
id: () => 'one-time-id',
timeout: false,
});
If you find that the push
interface is too verbose (it is), you can further create
your own proxy utils.
user-abstracted proxy push
export const info = (content: string) => notiStore.push('info', { props: { content } });
// later
info('An info notification...');
The API is intentionally kept verbose to maintain a unified interface that works for all use cases. But if you think it can be further simplified, feedback & proposal are welcomed 🙇.
Popping and Awaiting for Resolution
An active notification can be popped either:
from within the component (typically from user actions), by dispatching a
resolve
event (as seen in the Comprehensive Example section or demo below), orvia the
pop
method ofNotificationStore
,popping via NotificationStore
import { notiStore } from './notification-store'; // pop the topmost notification notiStore.pop(); // pop a specific notification notiStore.pop('specific-id'); // pop a specific notification with custom resolution value notiStore.pop('id', 'custom-resolve-detail'); // alternatively, provide arguments as object notiStore.pop({ detail: 'custom-resolve-detail', }); // pop the topmost notification with custom resolution value
Notification resolution can be awaited. The awaited value is inferred from either the argument
provided to NotificationStore.pop
or CustomEvent.detail
of the
resolve
event. This is especially helpful for complex interactive notification; see
Notification Component section for an
example.
awaiting for resolution
import { notiStore } from './notification-store';
const pushed = notiStore.push('info');
const resolved = await pushed.resolve();
demo: popping & awaiting
<script lang="ts">
import { notiStore } from './notification-store';
let promise: Promise<unknown> | null = null;
async function pushNoti() {
const pushed = notiStore.push('info', {
timeout: false,
props: {
content: 'A persistent notification',
},
});
promise = pushed.resolve();
await promise;
setTimeout(() => (promise = null), 2000);
}
function popNoti() {
notiStore.pop();
}
</script>
<button
on:click={pushNoti}
disabled={!!promise}
class="c-btn-primary"
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-btn-text" on:click={popNoti}>click here</button> to pop the notification.
{:then}
Resolved (resetting in 2 seconds)
{/await}
</p>
{/if}
Timeout and Progress
If your notification has timeout
specified in its config, a
setTimeout
is setup and the notification
will be automatically popped from the stack. This
timeout can be paused
and resumed.
NotificationStore - progress control
notiStore.pause(notificationId);
notiStore.resume(notificationId);
The pause
and resume
methods on NotificationStore
are
actually just proxy methods for the same ones on NotificationInstance
, which is
accessible from within the Notification component via
the injected notification prop .
NotificationInstance - progress control
<script lang="ts">
import type { NotificationInstance } from '@svelte-put/noti';
export let notification: NotificationInstance;
const { progress } = notification;
$: console.log($progress.state); // 'idle' | 'running' | 'paused' | 'ended'
const pause = () => progress.pause();
const resume = () => progress.resume();
</script>
Notification Portal
use:portal
The accompanying portal
svelte action provides a quick & minimal solution to set any
HTMLElement
as the rendering portal for a NotificationStore .
When using the portal
action, only one portal can be bound to a
NotificationStore
, and vice versa.
Portal action
import { portal } from 'svelte-put/noti';
<div use:portal={notiStore} />
Notification instances are rendered as direct children of the HTMLElement use:portal
is attached to. Newest instance is the last child.
Limitation
use:portal
is helpful for reducing boilerplate and keeping everything connected.
However, there are some known UI limitations:
- transition for the notification component must be global (for example
in:fly|global
), - outro transition (during unmount) will not run (but soon will be able to when this PR is merged),
animate
is not available because it requires a keyed each block.
The next section discusses how a custom portal can be built to overcome these limitations, should it be necessary.
Custom Portal
Instead of use:portal , rendering of notifications
can be manually handled by subscribing to the notifications
array property of a NotificationStore . This is helpful when more granular control over rendering is necessary. For example, to
coordinate and animate smoothly the positions of the notifications, as done in the following
demo.
custom portal demo
<script lang="ts">
import { store } from '@svelte-put/noti';
import NotificationWrapper from '@svelte-put/noti/Notification.svelte';
import { flip } from 'svelte/animate';
import { fly, fade } from 'svelte/transition';
import Notification from './Notification.svelte';
// define somewhere global, reuse across app
const notiStore = store()
.variant('info', Notification)
.variant('special', {
id: 'counter',
component: Notification,
props: {
special: true,
content: 'A very special notification',
},
})
.build();
const pushInfo = () => notiStore.push('info', { props: { content: 'An info notification' } });
const pushSpecial = () => notiStore.push('special');
</script>
<!-- notification portal, typically setup at somewhere global like root layout -->
<aside
class="fixed z-notification inset-y-0 right-0 md:left-1/2 p-10 pointer-events-none flex flex-col-reverse gap-4 justify-end md:justify-start"
>
{#each $notiStore.notifications as notification (notification.id)}
<div animate:flip={{ duration: 200 }} in:fly={{ duration: 200 }} out:fade={{ duration: 120 }}>
<NotificationWrapper {notification} />
</div>
{/each}
</aside>
<!-- notification push triggers -->
<button class="c-btn-primary" on:click={pushInfo}>Push an info notification</button>
<button class="c-btn-primary-outline" on:click={pushSpecial}>Push a special notification</button>
Notice the usage of @svelte-put/noti/Notification.svelte
component above. It is
just a small abstraction on top of svelte:component
to provide the same functionality that use:portal
does.
You can even go more granular and omit it; just make sure to
provide the necessary props .
Notification Component
There is no limitation on the Svelte component to be used with @svelte-put/noti
.
However, this section lists some optional prop & event interfaces that helps build
feature-rich notifications.
notification
Prop
Injected This is an optional prop that provides access to the corresponding NotificationInstance
(element of notification stack from NotificationStore
).
simplified NotificationInstance interface
type NotificationInstanceConfig = {
/** extends NotificationVariantConfig, omitted for conciseness */
id: string;
};
type NotificationInstance = NotificationInstanceConfig & {
/** reference to the rendered notification component */
instance?: SvelteComponent;
/** internal api for resolving a notification, effectively popping it from the stack */
$resolve: (e: ComponentEvents['resolve']) => Promise<ComponentEvents['resolve']['detail']>;
/** svelte store with .pause & .resume methods for controlling automatic timeout */
progress: NotificationProgressStore;
}
This is helpful, for example, if you want access to the id
or
variant
of the pushed notification.
use case for injected notification prop
<!-- SomeNotificationComponent.svelte -->
<script lang="ts">
import type { NotificationInstance } from '@svelte-put/noti';
export let notification: NotificationInstance;
</script>
<div data-id={notification.id} class="notification notification--{notification.variant}" />
The notification
prop also allows access to the progress
store for
controlling timeout. Check Timeout and Progress
for more information. Also refer to the Notification
component used in Comprehensive Example
which made use of the progress
store to pause notification timeout on mouse hover.
resolve
Event
If set up correctly, either automatically via use:portal or manually in your custom portal , a
resolve
event dispatched from the pushed instance will prompt NotificationStore
to remove it from the current notification stack.
The detail of this resolve
CustomEvent can be awaited, allowing us to receive user actions from complex
interactive notifications such as the example below.
interactive notification
<script>
import InteractiveNotification from './InteractiveNotification.svelte';
import { notiStore } from './notification-store';
let state = 'idle';
async function pushNoti() {
const pushed = notiStore.push('custom', {
timeout: false,
component: InteractiveNotification,
props: {
message: 'You are invited to join the Svelte community!',
},
});
state = 'pending';
const agreed = await pushed.resolve();
state = agreed ? 'accepted' : 'denied';
}
</script>
<p class="text-blue-500">
{#if state === 'idle'}
Waiting for notification to be pushed
{:else if state === 'pending'}
Waiting for user action to resolve notification
{:else}
Invitation was <strong
class:text-red-500={state == 'denied'}
class:text-green-500={state === 'accepted'}
>
{state}
</strong>
{/if}
</p>
<button class="c-btn-primary" on:click={pushNoti} disabled={state === 'pending'}
>Trigger Interactive Notification</button
>
Happy notifying! 👨💻