@svelte-put/modal
type-safe async modal builder
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:
- type safety: built for typescript users,
- async & stack-able push-pop mechanism,
- 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();
ModalPortal
Registration
2. 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: 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 .
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 theclasses
prop for additional styling. Because svelte style is component-scoped, notice the usage of:global
. See theclasses
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:
- no
type $$Events = ExtendedModalEvents
needed, - no
{dispatch}
prop passed to Modal , - instead, the
resolve
event is forwarded directly withon:resolve
.
- no
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
true
clickoutside
whether clicking outside modal triggers resolve
most helpful when backdrop
is hidden
false
movable
whether modal can be dragged and moved around the screen
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 .
Modal Resolution & Extending Events
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'
},
});
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" .
Happy modal-ing! 👨💻