Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Svelte
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223<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-4 overscroll-none rounded-lg border border-border-default bg-background-default p-4 text-foreground-default shadow-lg", title: "flex flex-row items-center gap-2 px-0.5 typography-paragraph-large font-medium", titleIcon: "size-4.5 text-foreground-muted", description: "text-foreground-muted", header: "flex flex-col gap-2", footer: "flex gap-2" }, variants: { size: { regular: { panel: "md:w-96" }, wide: { panel: "md:w-lg" } } }, 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 { popoverExit } from "$lib/popoverExit";
interface Props { open?: boolean; title?: string; titleIcon?: Component<SvelteHTMLElements["svg"]>; description?: string; size?: ModalVariants["size"]; class?: string; children: Snippet; footer?: Snippet; }
let { open = $bindable(false), title, description, size = "regular", class: className, children, footer, titleIcon }: Props = $props();
const classes = $derived(modal({ size }));
let panel = $state<HTMLDialogElement>(); const titleId = $props.id();
// the top layer still needs a rendered element, so a modal opened from inside a // popover (a dropdown item, say) would go dark with the popover that closes behind // it. living on <body> keeps it clear of anything that hides its ancestors. const toBody = (node: HTMLElement) => { document.body.appendChild(node); return () => node.remove(); };
$effect(() => { if (!panel) return; if (open && !panel.open) { 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(); };</script>
<dialog bind:this={panel} closedby="any" onclose={() => (open = false)} onclick={onClick} aria-labelledby={title ? titleId : undefined} tabindex={-1} class={classes.panel({ class: className })} {@attach toBody} {@attach popoverExit()}> {#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>
<style> :global(html:has(.modal-panel:modal)) { overflow: hidden; }
/* the panel rides in on a spring (stiffness 420, damping 36, mass 0.9) sampled over its 300ms settle, while the fade finishes at 160ms — so it is fully painted while still travelling its last couple of pixels. leaving is a plain curve, shorter and closer in, because a dismissal should not linger. */ .modal-panel { --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);
/* `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; } }
@media (prefers-reduced-motion: reduce) { .modal-panel, .modal-panel:modal, .modal-panel::backdrop, .modal-panel:modal::backdrop { transition: none; } }</style>