add dialog component

This commit is contained in:
Elijah Duffy
2025-07-04 00:28:24 -07:00
parent 24912efe31
commit f1b7ef2375
5 changed files with 208 additions and 1 deletions

120
src/lib/Dialog.svelte Normal file
View File

@@ -0,0 +1,120 @@
<script lang="ts">
import { Portal } from '@jsrob/svelte-portal';
import { onMount, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { fade } from 'svelte/transition';
import { flyAndScale } from './transition';
import Button from './Button.svelte';
import { X } from 'phosphor-svelte';
interface Props {
open?: boolean;
title: string;
description?: string;
size?: 'sm' | 'md' | 'lg' | 'max';
class?: ClassValue;
children?: Snippet;
controls?: Snippet;
onsubmit?: () => void;
}
let {
open = $bindable(false),
title,
description,
size = 'sm',
class: classValue,
children,
controls,
onsubmit
}: Props = $props();
let dialogContainer = $state<HTMLDivElement | null>(null);
let openSince = $state<Date | undefined>(undefined);
$effect(() => {
if (open) {
document.body.style.overflow = 'hidden';
openSince = new Date();
} else {
document.body.style.overflow = '';
openSince = undefined;
}
});
onMount(() => {
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && open) {
open = false;
}
});
window.addEventListener('click', (e) => {
if (open && dialogContainer) {
if (openSince && new Date().getTime() - openSince.getTime() < 300) {
return; // Ignore clicks immediately after opening
}
const target = e.target as HTMLElement;
if (open && !dialogContainer.contains(target) && target !== dialogContainer) {
open = false;
}
}
});
});
</script>
<Portal target="body">
{#if open}
{@render dialog()}
{/if}
</Portal>
{#snippet dialog()}
<div
class={[
'fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm',
classValue
]}
transition:fade={{ duration: 150 }}
>
<div
bind:this={dialogContainer}
class={[
'relative max-h-[85vh] w-[90vw] rounded-xl bg-white p-6 shadow-lg',
size === 'sm' && 'max-w-[450px]',
size === 'md' && 'max-w-[650px]',
size === 'lg' && 'max-w-[850px]',
size === 'max' && 'max-w-[95vw]'
]}
transition:flyAndScale={{
duration: 150,
y: -8,
start: 0.96
}}
>
<h2 class="pointer-events-none mb-2 text-lg font-medium text-black select-none">{title}</h2>
{#if description}
<p class="mb-3 leading-normal text-zinc-600">
{description}
</p>
{/if}
{#if children}{@render children()}{:else}Dialog is empty{/if}
<div class="mt-6 flex justify-end gap-4">
{#if controls}{@render controls()}{:else}
<Button onclick={() => (open = false)}>Cancel</Button>
<Button onclick={() => onsubmit?.()}>OK</Button>
{/if}
</div>
<button
aria-label="close"
class="absolute top-4 right-4 inline-flex cursor-pointer items-center justify-center"
onclick={() => (open = false)}
>
<X size="1.5em" weight="bold" />
</button>
</div>
</div>
{/snippet}