44 lines
2.9 KiB
Svelte
44 lines
2.9 KiB
Svelte
<script lang="ts">
|
||
import { tick, type Snippet } from 'svelte';
|
||
import Button from './Button.svelte';
|
||
|
||
let { open = $bindable(false), title, description, busy = false, wide = false, drawer = false, children, actions }:
|
||
{ open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; drawer?: boolean; children?: Snippet; actions?: Snippet } = $props();
|
||
const id = $props.id();
|
||
let dialog = $state<HTMLDialogElement>();
|
||
let returnFocus: HTMLElement | null = null;
|
||
$effect(() => {
|
||
if (!dialog) return;
|
||
if (open && !dialog.open) {
|
||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||
dialog.showModal();
|
||
} else if (!open && dialog.open) dialog.close();
|
||
});
|
||
function close() { if (!busy) open = false; }
|
||
async function closed() {
|
||
open = false;
|
||
await tick();
|
||
if (returnFocus?.isConnected) returnFocus.focus();
|
||
}
|
||
</script>
|
||
|
||
<dialog bind:this={dialog} class:wide class:drawer aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-description` : undefined}
|
||
oncancel={event => { if (busy) event.preventDefault(); }} onclose={closed}
|
||
onclick={event => { if (event.target === dialog) { const r = dialog!.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close(); } }}>
|
||
<header><div><h2 id={`${id}-title`}>{title}</h2>{#if description}<p id={`${id}-description`}>{description}</p>{/if}</div><Button variant="ghost" ariaLabel="Close dialog" disabled={busy} onclick={close}>×</Button></header>
|
||
<div class="body">{@render children?.()}</div>
|
||
{#if actions}<footer>{@render actions()}</footer>{/if}
|
||
</dialog>
|
||
|
||
<style>
|
||
dialog { width: min(560px, calc(100vw - 32px)); padding: 0; }
|
||
dialog.wide { width: min(880px, calc(100vw - 32px)); }
|
||
dialog.drawer { position:fixed; inset:0 0 0 auto; margin:0; width:min(560px, 100vw); height:100dvh; max-height:100dvh; max-width:100vw; border-radius:0; border:0; border-left:1px solid var(--border-subtle); }
|
||
header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-6); border-bottom: 1px solid var(--border-subtle); }
|
||
h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-tight); }
|
||
p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||
.body { padding: var(--space-6); }
|
||
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-4) var(--space-6); border-top: 1px solid var(--border-subtle); background: var(--surface); position: sticky; bottom: 0; z-index: 1; }
|
||
@media (max-width: 600px) { header, .body { padding: var(--space-4); } footer { padding: var(--space-4); } h2 { font-size: var(--text-lg); } }
|
||
</style>
|