<script module lang="ts">
import { tv, type VariantProps } from "tailwind-variants";
export const modal = tv({
slots: {
panel: "modal-panel m-auto w-[95%] flex-col gap-md overscroll-none rounded-xl bg-background-default p-md text-foreground-default shadow-lg edge-ring focus:outline-hidden",
title: "flex flex-row items-center gap-xs px-3xs typography-paragraph-large font-medium",
titleIcon: "size-4 shrink-0 text-foreground-muted",
titleText: "min-w-0 wrap-anywhere",
description: "wrap-anywhere text-foreground-muted",
header: "flex flex-col gap-xs",
footer: "flex gap-xs",
handle: "-mx-md -mt-md flex cursor-grab touch-none justify-center py-xs active:cursor-grabbing",
grip: "h-1 w-10 rounded-full bg-border-strong"
},
variants: {
size: {
regular: { panel: "md:w-96" },
wide: { panel: "md:w-narrow" }
},
drawer: {
true: {
panel: "modal-drawer mx-0 mt-auto mb-0 w-full max-w-full rounded-b-none pb-[calc(env(safe-area-inset-bottom)+1rem)]"
}
}
},
defaultVariants: {
size: "regular"
}
});
export type ModalVariants = VariantProps<typeof modal>;
</script>
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { prefersReducedMotion } from "svelte/motion";
import { popoverExit } from "#lib/popoverExit.ts";
import { mobile } from "#lib/mobile.svelte.ts";
import { HOME, Motion } from "#lib/motion/value.svelte.ts";
interface Props {
open?: boolean;
title?: string;
titleIcon?: Component<SvelteHTMLElements["svg"]>;
description?: string;
size?: ModalVariants["size"];
drawer?: boolean;
class?: string;
testid?: string;
children?: Snippet;
footer?: Snippet;
}
let {
open = $bindable(false),
title,
description,
size = "regular",
drawer,
class: className,
testid,
children,
footer,
titleIcon
}: Props = $props();
const sheet = $derived(drawer ?? mobile.current);
const classes = $derived(modal({ size, drawer: sheet }));
const DISMISS = 0.3;
const FLING = 0.5;
const offset = new Motion(0);
interface Drag {
id: number;
from: number;
y: number;
at: number;
speed: number;
}
let drag: Drag | null = null;
let panel = $state<HTMLDialogElement>();
let mounted = $state(false);
$effect.pre(() => {
if (open) mounted = true;
});
const titleId = $props.id();
// portals to <body>: a modal opened from inside a popover must not go dark when
// that popover's top-layer entry closes.
const toBody = (node: HTMLElement) => {
document.body.appendChild(node);
return () => node.remove();
};
$effect(() => {
if (!panel) return;
if (open && !panel.open) {
offset.set(0);
panel.showModal();
panel.focus();
} else if (!open && panel.open) panel.close();
});
const TitleIcon = $derived(titleIcon);
// safari has no `closedby` yet, so dismissing on an outside click is on us there.
// a click on the backdrop is reported against the dialog itself, so the pointer
// is what tells the two apart
const onClick = (event: MouseEvent) => {
if (!panel || event.target !== panel) return;
const box = panel.getBoundingClientRect();
const inside =
event.clientX >= box.left &&
event.clientX <= box.right &&
event.clientY >= box.top &&
event.clientY <= box.bottom;
if (!inside) panel.close();
};
const settle = () => {
if (prefersReducedMotion.current) offset.set(0);
else offset.animate(0, HOME);
};
const onHandleDown = (event: PointerEvent) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
if (event.currentTarget instanceof HTMLElement)
event.currentTarget.setPointerCapture(event.pointerId);
offset.stop();
drag = {
id: event.pointerId,
from: event.clientY - offset.current,
y: event.clientY,
at: event.timeStamp,
speed: 0
};
};
const onHandleMove = (event: PointerEvent) => {
if (!drag || drag.id !== event.pointerId) return;
const elapsed = event.timeStamp - drag.at;
if (elapsed > 0) drag.speed = (event.clientY - drag.y) / elapsed;
drag.y = event.clientY;
drag.at = event.timeStamp;
offset.set(Math.max(0, event.clientY - drag.from));
};
const onHandleUp = (event: PointerEvent) => {
if (!drag || drag.id !== event.pointerId) return;
const { speed } = drag;
drag = null;
if (panel && (offset.current > panel.offsetHeight * DISMISS || speed > FLING))
panel.close();
else settle();
};
const onHandleCancel = (event: PointerEvent) => {
if (!drag || drag.id !== event.pointerId) return;
drag = null;
settle();
};
</script>
<dialog
bind:this={panel}
data-testid={testid}
closedby="any"
onclose={() => (open = false)}
onclick={onClick}
aria-labelledby={title ? titleId : undefined}
tabindex={-1}
class={classes.panel({ class: className })}
style:transform={offset.current ? `translateY(${offset.current}px)` : undefined}
{@attach toBody}
{@attach popoverExit((exiting) => {
if (!exiting && !open) mounted = false;
})}
>
{#if mounted}
{#if sheet}
<div
aria-hidden="true"
class={classes.handle()}
onpointerdown={onHandleDown}
onpointermove={onHandleMove}
onpointerup={onHandleUp}
onpointercancel={onHandleCancel}
>
<span class={classes.grip()}></span>
</div>
{/if}
{#if title || description}
<div class={classes.header()}>
{#if title}
<p id={titleId} class={classes.title()}>
<TitleIcon class={classes.titleIcon()} />
<span class={classes.titleText()}>{title}</span>
</p>
{/if}
{#if description}<p class={classes.description()}>{description}</p>{/if}
</div>
{/if}
{@render children?.()}
{#if footer}
<div class={classes.footer()}>{@render footer()}</div>
{/if}
{/if}
</dialog>
{#if sheet}
<div class="modal-veil" data-open={open} aria-hidden="true" {@attach toBody}></div>
{/if}
<style>
:global(html:has(.modal-panel:modal)) {
overflow: hidden;
}
.modal-panel,
.modal-veil {
--modal-spring: linear(
0,
0.043,
0.142,
0.263,
0.387,
0.503,
0.606,
0.693,
0.765,
0.823,
0.868,
0.904,
0.931,
0.951,
0.966,
0.977,
0.984,
0.99,
0.994,
0.996,
1
);
--modal-leave: cubic-bezier(0.4, 0, 1, 1);
--modal-enter: cubic-bezier(0.23, 1, 0.32, 1);
}
.modal-panel {
/* `display` belongs here and not to a `flex` utility: an author-level
`display: flex` outranks the UA's `dialog:not([open])`, so a closed panel
would keep an invisible click-eating box in the middle of the viewport */
display: none;
opacity: 0;
scale: 0.98;
translate: 0 6px;
transition:
opacity 150ms var(--modal-leave),
scale 150ms var(--modal-leave),
translate 150ms var(--modal-leave),
overlay 150ms allow-discrete,
display 150ms allow-discrete;
}
.modal-panel:modal {
display: flex;
opacity: 1;
scale: 1;
translate: 0 0;
transition:
opacity 160ms var(--modal-enter),
scale 300ms var(--modal-spring),
translate 300ms var(--modal-spring);
}
@starting-style {
.modal-panel:modal {
opacity: 0;
scale: 0.96;
translate: 0 12px;
}
}
/* the panel fades, so the backdrop has to fade with it — a `backdrop:` utility
can only paint it, and it would pop in under a panel that is still fading */
.modal-panel::backdrop {
background-color: rgb(0 0 0 / 0.4);
opacity: 0;
transition:
opacity 150ms var(--modal-leave),
overlay 150ms allow-discrete,
display 150ms allow-discrete;
}
.modal-panel:modal::backdrop {
opacity: 1;
transition: opacity 200ms var(--modal-enter);
}
@starting-style {
.modal-panel:modal::backdrop {
opacity: 0;
}
}
.modal-panel.modal-drawer {
scale: 1;
translate: 0 100%;
}
.modal-panel.modal-drawer:modal {
translate: 0 0;
}
@starting-style {
.modal-panel.modal-drawer:modal {
scale: 1;
translate: 0 100%;
}
}
.modal-panel.modal-drawer::backdrop {
background-color: transparent;
}
.modal-veil {
position: fixed;
inset: 0;
z-index: 50;
pointer-events: none;
background-color: rgb(0 0 0 / 0.4);
opacity: 0;
transition: opacity 150ms var(--modal-enter);
}
.modal-veil[data-open="true"] {
opacity: 1;
transition: opacity 200ms var(--modal-enter);
}
@starting-style {
.modal-veil[data-open="true"] {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.modal-panel,
.modal-panel:modal,
.modal-panel::backdrop,
.modal-panel:modal::backdrop,
.modal-veil,
.modal-veil[data-open="true"] {
transition: none;
}
}
</style>