@svelte-put/modal GitHub

type-safe async modal builder

@svelte-put/modal @svelte-put/modal @svelte-put/modal @svelte-put/modal changelog

I am experimenting with new solutions for svelte-put/modal that adopts a "headless" approach and will hopefully reduce the current complexity and allow easier integration with any UI preferences. Therefore, I recommend that you build your own modal instead of using the builtin Modal component (even though they will continue to work in the future).

Installation

terminal

npm install --save-dev @svelte-put/modal

Introduction

@svelte-put/modal differs from existing solution by focusing on a three things:

  1. type safety: built for typescript users,
  2. async & stack-able push-pop mechanism,
  3. extensibility: minimal logics by @svelte-put/modal, ui by you.

This document assumes you are using svelte-kit , so components are imported directly from source svelte files. If you are using this in a different context, perhaps the import method as seen in the REPL might help.

You will notice also that code examples are shown in typescript by default. Typescript is recommended as it provides type safety and an overall better development experience, especially by leveraging the type inference for modal props and resolved payload.

Throughout this document, you will see usage of the $$Props and $$Events special types (check out this github thread ). If you are not familiar with these, read this great article by Andrew Lester before continuing.

Usage

Follow the steps laid out below.

1. Global Modal Store Setup

Expose a global modal store singleton where it makes sense for you. This store is used in the next steps for modal push/pop.

example: src/lib/services/modal.ts

import { createModalStore } from '@svelte-put/modal';
export const modalStore = createModalStore();

Register the ModalPortal where you want to render the modal stack, typically as a direct descendant of the root element of your site.

example: src/routes/+layout.svelte

<script lang="ts">
  import ModalPortal from '@svelte-put/modal/ModalPortal.svelte';

  import { modalStore } from '$client/services/modal';
</script>

<slot />

<ModalPortal store={modalStore} />

3. Building Modal Component

@svelte-put/modal by design does not provide any predefined modals but only a base Modal component with basic skeleton that can be extended to fit a wide variety of use cases.

Alternatively, any regular svelte component can be used as modal as long as it exposes the correct interface, as discussed the later Building Your Own Modal section.

The example below shows how a confirmation modal might be implemented. See Building Compatible Modal Components for more details about customization possibilities.

example: ConfirmationModal.svelte

<script lang="ts">
  import { createModalEventDispatcher } from '@svelte-put/modal';
  import type { ExtendedModalProps, ExtendedModalEvents } from '@svelte-put/modal';
  import Modal from '@svelte-put/modal/Modal.svelte';

  // extending the base props
  type $$Props = ExtendedModalProps<{ disabled?: boolean }>;
  // extending the `resolve` event
  type $$Events = ExtendedModalEvents<{ confirmed: boolean }>;

  export let disabled = false;

  // create a custom event dispatcher with built-in helper
  const dispatch = createModalEventDispatcher<$$Events>();

  function resolve(confirmed: $$Props['disabled']) {
    // should get type autocompletion for dispatch here
    dispatch('resolve', {
      trigger: 'custom',
      confirmed,
    });
  }
  function confirm() {
    resolve(true);
  }
  function cancel() {
    resolve(false);
  }
</script>

<!-- props is forwarded to the base Modal component -->
<!-- and the custom dispatch is also passed down -->
<Modal classes={{ container: 'confirmation-modal bg-bg p-10' }} {...$$restProps} {dispatch}>
  <div class="modal-content">
    <p>Confirmation Modal</p>
    <p>Are you sure you want to proceed?</p>
    <div>
      <button type="button" on:click={confirm} {disabled} class="c-btn-primary">Confirm</button>
      <button type="button" on:click={cancel} {disabled} class="c-btn-primary-outline"
        >Cancel</button
      >
    </div>
  </div>
</Modal>

4. Pushing and Popping

The custom modal in the last step can be opened idiomatically with the modal store created in step 1 . See Modal Resolution for details about the push&pop mechanism.

Example

Try open the modal using the button below. Then, with the dev console open, try closing by:

  • clicking one of the two action buttons,
  • clicking the x button on top right,
  • clicking the backdrop.

example: push & pop

<script lang="ts">
  import type { ModalPushOutput } from '@svelte-put/modal';

  import { modalStore } from '$client/services/modal';

  import ConfirmationModal from './ConfirmationModal.code.svelte';

  let pushed: ModalPushOutput<ConfirmationModal>;

  async function open() {
    // should get type autocompletion for pushed here
    pushed = modalStore.push(ConfirmationModal);
    const { confirmed, trigger } = await pushed.resolve();
    if (confirmed) {
      // do something
    }
    console.log('Modal resolves to', confirmed);
    console.log('Modal was popped by', trigger);
  }

  // actions inside modal will also call pop internally
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  function close() {
    if (pushed) {
      modalStore.pop(pushed);
      // if the modal is successfully popped by this operation,
      // the `await` expression in the `open` method above will resolve
      // the `trigger` will be `pop`

      // alternatively, pass undefined to pop the topmost modal:
      modalStore.pop();
    }
  }
</script>

<button on:click={open} class="c-btn-primary">Open ConfirmationModal</button>

Building Compatible Modal Components

Any component that satisfies the ModalComponentBase interface can be used with the modal store as in the example in Usage . That is, any modal that dispatches a resolve CustomEvent with details extending ModalComponentBaseResolved . See Extending Events for more info.

The following sections show different customizable parts of the base Modal .

UI, Styling, and Interaction

The base Modal exposes some slots and props that work together to provide customization.

It is recommended to read through these api documentation pages:

Overriding Slots

Shown below is an (simplified) diagram of the slots of the base Modal .

backdrop
container
x
x-content
default

The following example demonstrates an InformationModal without any call-to-action. Notice:

  • The default slot is provided as the content of the modal
  • A custom class is added to the modal container through the classes prop for additional styling. Because svelte style is component-scoped, notice the usage of :global. See the classes section of ModalComponentBaseProps for more details.
  • There is no event extended or added compared to the example in Usage - Step 3 , hence a couple of differences:
    1. no type $$Events = ExtendedModalEvents needed,
    2. no {dispatch} prop passed to Modal ,
    3. instead, the resolve event is forwarded directly with on:resolve.
Example

example: InformationModal.svelte

<script lang="ts">
  import type { ExtendedModalProps } from '@svelte-put/modal';
  import Modal from '@svelte-put/modal/Modal.svelte';

  // just reexporting props here
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  type $$Props = ExtendedModalProps;
</script>

<Modal classes={{ container: 'custom-modal-container' }} {...$$props} on:resolve>
  <h2>An Information Modal Without Any Actions</h2>
  <p>
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Explicabo commodi maxime nemo quibusdam
    quas, ab adipisci eum distinctio cum dolorum dolores sit dolorem unde officia odio. Quibusdam,
    earum? Eaque, dolor.
  </p>
</Modal>

<style>
  :global(.custom-modal-container) {
    padding: 80px;
    background: lightblue;
    box-shadow: 0 10px 15px -3px rgb(0 0 0 / 10%), 0 4px 6px -4px rgb(0 0 0 / 10%);
  }

  h2 {
    margin-bottom: 2rem;
    font-size: 1.5rem;
    font-weight: bold;
  }
</style>

Interactions

Modal provides a few props for customizing interactions with modals.

Interaction

Description

Powered by

Default

backdrop

whether to show or hide backdrop, and if clicking on it triggers resolve

true

escape

whether escape key press triggers resolve

@svelte-put/shortcut

true

clickoutside

whether clicking outside modal triggers resolve

most helpful when backdrop is hidden

@svelte-put/clickoutside

false

movable

whether modal can be dragged and moved around the screen

@svelte-put/movable

false

Refer to the ModalComponentBaseProps api page for details & examples.

Extending Props

The example in Usage - Step 3 is a good starter for extending props when using the Modal base component. For details, see ExtendedModalProps .

For a modal to close gracefully, it dispatches a resolve CustomEvent whose details containing a trigger property set to ResolveTrigger , indicating where resolve event originated from.

This resolve event is captured by ModalPortal and forwarded to the pop method of the modal store.

The example in Usage - Step 3 is a good starter for extending events when using the Modal base component. For more details, see ExtendedModalEvents .

Building Your Own Modal

As discussed in the beginning of Building Compatible Modal Components and Modal Resolution & Extending Events :

the resolve event is the only true requirement for any svelte component to work with the modal store push/pop mechanism.

By understanding this, you are not limited to use the Modal base component for building your own modals. The example below shows exactly that.

Imagine a target FullCustomModal with the following push interface:

modalStore.push({
  component: FullCustomModal,
  props: {
    content: 'Some custom content'
  },
});
Example

FullCustomModal.svelte

<!-- without anything from @svelte-put (except for some types) -->
<script lang="ts">
  import type { ResolveTrigger } from '@svelte-put/modal';
  import { createEventDispatcher } from 'svelte';

  export let content = 'Prop should work as usual';

  const dispatch = createEventDispatcher<{
    resolve: {
      trigger: ResolveTrigger;
      payload: string;
    };
  }>();

  function resolve() {
    dispatch('resolve', { trigger: 'custom', payload: 'completely custom modal' });
  }
</script>

<!-- notice this component is just a normal svelte component -->

<div class="custom-modal">
  <button type="button" class="c-btn-primary-outline" on:click={resolve}> Resolve </button>
  <p>{content}</p>
</div>

<style>
  .custom-modal {
    position: absolute;
    top: 50%;
    left: 50%;
    translate: -50% -50%;

    display: grid;
    place-items: center;

    width: 80%;
    height: 80%;

    background-color: #fff;
    box-shadow: 0 0 0.5rem rgb(0 0 0 / 25%);
  }
</style>

Side Effects On Pop

Callback can be registered to run when a modal is popped from the store.

ModalStore.onPop

import { createModalStore } from '@svelte-put/modal';
import type { ModalResolveCallback, ModalResolved } from '@svelte-put/modal';

import FullCustomModal from './FullCustomModal.code.svelte';

const store = createModalStore();
const pushed = store.push(FullCustomModal);

let unsubscribe = store.onPop<FullCustomModal>(pushed.id, (_resolved) => {
  /** */
});
unsubscribe(); // to unregister the callback

// or

function onPop(_resolved: ModalResolved<FullCustomModal>) {
  /** */
}
unsubscribe = store.onPop<FullCustomModal>(pushed.id, onPop);
unsubscribe(); // to unregister the callback

// or

const sideEffect: ModalResolveCallback<FullCustomModal> = (_resolved) => {
  /** */
};
unsubscribe = store.onPop(pushed.id, sideEffect);
unsubscribe(); // to unregister the callback

Accessibility

This package does not use dialog because it cannot assume what browsers to support. However, as seen in Building Your Own Modal , you can build a modal component that uses dialog inherently.

Using the base Modal component, the accessibility prop can be provided to set role and aria attributes, similar to what is described here in MDN "dialog role" .

mouse click faster

Happy modal-ing! 👨‍💻

Edit this page on GitHub