@svelte-put/noti GitHub

type-safe and headless async notification builder

@svelte-put/noti @svelte-put/noti @svelte-put/noti @svelte-put/noti changelog

Installation

npm install --save-dev @svelte-put/noti
pnpm add -D @svelte-put/noti
yarn add -D @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. For that reason, the setup is quite verbose. This, however, enables flexibility: the package is not a plug-and-play prebuilt notification bundle but rather designed to help build custom notification system. We will unpack each part of the library in later sections of the document to see how customization can be achieved.

Example

Click to push toast notification

  • Component Setup (Notification.svelte): define a component to be rendered as notification.

  • Store Setup (notification-store.ts): a NotificationStore is created with (optionally) predefined notification variants.

  • Portal Setup (NotificationPortal.svelte): a NotificationPortal component is registered with use:portal as a centralized place to insert rendered notifications.

  • Usage (usage.svelte): notification is pushed using the previously created NotificationStore.

<script lang="ts">
  import type { NotificationInstance } from '@svelte-put/noti';
  import { createEventDispatcher } from 'svelte';
  import { fly } from 'svelte/transition';

  // optional, injected automatically by @svelte-put/noti
  export let notification: NotificationInstance;

  export let content = 'Placeholder';
  export let special = false;

  const { progress } = notification;

  const dispatch = createEventDispatcher<{ resolve: string }>();
  const dismiss = () => dispatch('resolve', 'popped from within component');
</script>

<!-- eslint-disable svelte/valid-compile -->
<div
  class="relative px-4 py-2 bg-blue-200 rounded-sm not-prose shadow-lg text-black pointer-events-auto flex items-start md:items-center justify-between"
  class:bg-pink-300={special}
  in:fly|global={{ duration: 200, y: -20 }}
  on:mouseenter={progress.pause}
  on:mouseleave={progress.resume}
>
  <p>Notification (variant: {notification.variant}): {content} (id = {notification.id})</p>
  <button on:click={dismiss} class="md-max:mt-0.5">
    <svg inline-src="lucide/x" width="24" height="24" />
  </button>
  <div
    class="progress absolute inset-x-0 bottom-0 h-0.5 bg-blue-500 origin-left"
    class:bg-pink-500={special}
    class:paused={$progress.state === 'paused'}
    style={`--progress-duration: ${notification.timeout}ms;`}
  />
</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>
// notification-store.ts
import { store } from '@svelte-put/noti';

import Notification from './Notification.svelte';

// define somewhere global, reuse across app
export const notiStore = store()
  // add a minimalistic variant config
  .variant('info', Notification)
  // add a verbose variant config
  .variant('special', {
    id: 'counter',
    component: Notification,
    props: {
      special: true,
      content: 'A very special notification',
    },
  })
  // build the actual NotificationStore
  .build();
<script>
  import { portal } from '@svelte-put/noti';

  import { notiStore } from './notification-store';
</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"
  use:portal={notiStore}
/>
<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 c-btn--outlined" on:click={pushInfo}>Push an info notification</button>
<button class="c-btn" on:click={pushSpecial}>Push a special notification</button>

Notification Store

This is a key part of @svelte-put/noti. It holds all internal logics and is used for the push & pop mechanism. As shown in Comprehensive Example, a NotificationStore is created with a builder pattern that provides type-safety for push invocations.

Initialization

import { store } from '@svelte-put/noti';

export 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.

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);
};

Predefined Variants

Use the variant method to add a variant config, allowing quick notification dispatch with minimal ad-hoc configuration. It accepts a mandatory variant string, and either a Svelte component or a config object (as seen in Comprehensive Example).

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 */
  };
};

Notification Push

New notifications can be pushed with the NotificationStore.push method. A push call take either one of the predefined variant, as seen in Comprehensive Example, …

notiStore.push('<variant>', { /** optional config & component props */ });

…or the 'custom' variant, helpful for one-off notification

notiStore.push('custom', {
  component: NotificationComponent, // required
  props: { /** props for NotificationComponent */ },
  id: () => 'one-time-id',
  timeout: false,
});

Custom push mush provide a component in its config.

If you find that the push interface is too verbose (it is), you can further create your own proxy utils.

export const info = (content: string) => notiStore.push('info', { props: { content } });
// later
info('An info notification...');

The API is intentionally kept verbose to maintain a generic interface that can cover many use cases. But if you think it can be further simplified, feedback and proposal are much welcomed 🙇.

Notification Pop

An active notification can be popped either from within the component (typically via user interactions), by dispatching a resolve CustomEvent (as seen in Comprehensive Example)…

<script lang="ts">
  import type { NotificationInstance } from '@svelte-put/noti';
  import { createEventDispatcher } from 'svelte';

  // ...truncated...

  const dispatch = createEventDispatcher<{ resolve: string }>();
  const dismiss = () => dispatch('resolve', 'popped from within component');
</script>

<!-- ...truncated... -->

or via the pop method from 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

Resolution Await

Notification resolution can be await. The awaited value is inferred from either the argument provided to NotificationStore.pop, or CustomEvent.detail of the resolve CustomEvent. This is especially helpful for complex interactive notification (see Notification Component section for an example of interactive notification).

import { notiStore } from './notification-store';

const pushed = notiStore.push('info');
const resolved = await pushed.resolve();

In the following example, try pressing the “Push a persistent notification” button and observe the async nature of the push & pop mechanism.

Example
<script lang="ts">
  import { notiStore } from './comprehensive/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"
  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" on:click={popNoti}>click here</button> to pop the notification.
    {:then}
      Resolved (resetting in 2 seconds)
    {/await}
  </p>
{/if}

Timeout and Progress

const pushed = notiStore.push('info', { timeout: 3000 });

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.

notiStore.pause(pushed.id);
notiStore.resume(pushed.id);

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.

<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>

<!-- ...truncated... -->

NotificationInstance.progress is a Svelte store, its value contain a state property with value of 'idle', 'running', 'paused', or 'ended', helpful to control the play state of some animation, for example.

Notification Portal

use:portal

The complementary portal Svelte action provides a quick and minimal solution to set any HTMLElement as the rendering portal for a NotificationStore. When using the portal action, only one portal can bind to a NotificationStore, and vice versa.

<script>
  import { portal } from 'svelte-put/noti';
  import { notiStore } from './notification-store';
</script>

<aside use:portal={notiStore} />

Notification instances are rendered as direct children of the HTMLElement to which use:portal is attached. 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:

  • Svelte transition for the notification component root node must be global (in:fly|global, for example),
  • outro transition (upon unmount) will not run (but hopefully 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.

Example

Click to push toast notification

Notice the subtle difference compared to Comprehensive Example. Specifically, thanks to animate:flip, the unmount transition is much smoother, especially when multiple notifications are active.

<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 './comprehensive/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 c-btn--outlined" on:click={pushInfo}>Push an info notification</button>
<button class="c-btn" on:click={pushSpecial}>Push a special notification</button>

Notice the usage of @svelte-put/noti/Notification.svelte in above code snippet. It is just a small abstraction on top of svelte:component to conveniently 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

Any Svelte component can be used with @svelte-put/noti. This section lists some optional prop & event interfaces that help build feature-rich notifications.

Injected notification Prop

This is an optional prop that provides access to the corresponding NotificationInstance interface (element of notification stack managed by NotificationStore).

type NotificationInstanceConfig = NotificationVariantConfig & {
  /** 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.

<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 pointer hover.

resolve CustomEvent

If set up correctly, either automatically via use:portal or manually in your custom portal, a resolve CustomEvent dispatched from the pushed instance component 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 in the example below.

Example

Waiting for notification to be pushed

<script lang="ts">
  import { createEventDispatcher } from 'svelte';
  import { fly } from 'svelte/transition';

  export let message: string;

  const dispatch = createEventDispatcher<{
    resolve: boolean;
  }>();

  const join = () => dispatch('resolve', true);
  const del = () => dispatch('resolve', false);
</script>

<div
  class="px-4 py-2 bg-bg-100 rounded-sm shadow-lg pointer-events-auto"
  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" on:click={join}> Join </button>
    <button class="c-btn c-btn--outlined w-40" on:click={del}> Delete </button>
  </div>
</div>
<script>
  import { notiStore } from '../comprehensive/notification-store';

  import InteractiveNotification from './InteractiveNotification.svelte';

  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>
  {#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:text-error-text={state == 'denied'}
      class:bg-error-surface={state == 'denied'}
      class:text-success-text={state === 'accepted'}
      class:bg-success-surface={state == 'accepted'}
    >
      {state}
    </span>
  {/if}
</p>
<button
	class="c-btn"
	on:click={pushNoti}
	disabled={state === 'pending'}
>
	Trigger Interactive Notification
</button>

Happy pushing and popping notifications! 👨‍💻

Edit this page on Github