Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354<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-lg border border-border-default bg-background-default p-md text-foreground-default shadow-lg", title: "flex flex-row items-center gap-xs px-3xs typography-paragraph-large font-medium", titleIcon: "size-4 text-foreground-muted", description: "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"; import { mobile } from "$lib/mobile.svelte"; import { HOME, Motion } from "$lib/motion/value.svelte";
interface Props { open?: boolean; title?: string; titleIcon?: Component<SvelteHTMLElements["svg"]>; description?: string; size?: ModalVariants["size"]; drawer?: boolean; class?: string; children?: Snippet; footer?: Snippet; }
let { open = $bindable(false), title, description, size = "regular", drawer, class: className, 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>(); 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} 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()}> {#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()} /> {title} </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}</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>