@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

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.

Example

Click to push toast notification

  • 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 created NotificationStore.

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), or

  • via the pop method of NotificationStore,

    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();
Example

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.

Example

Click to push toast notification

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.

Injected notification Prop

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.

Example

Waiting for notification to be pushed

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
>
mouse click faster

Happy notifying! 👨‍💻

Edit this page on GitHub