Files
sui/src/lib/Dialog.svelte

213 lines
5.2 KiB
Svelte

<script lang="ts" module>
/**
* Defines the features available in dialog event callbacks.
*/
export interface DialogAPI {
/** shows an error message at the top of the dialog */
error: (message: ErrorMessage | null) => void;
/** closes the dialog */
close: () => void;
/** opens the dialog */
open: () => void;
/** returns true if the dialog is open */
isOpen: () => boolean;
/** if default controls are used, sets the submit button loading state */
loading: () => void;
/** if default controls are used, sets the submit button loaded state */
loaded: () => void;
/** if default controls are used, returns true if the submit button is in loading state */
isLoading: () => boolean;
/** if default controls are used, disables submission and exiting */
freeze: () => void;
/** if default controls are used, enables submission and exiting */
unfreeze: () => void;
/** if the default controls are used, returns true if the dialog is frozen */
isFrozen: () => boolean;
}
/**
* Configures the default dialog controls.
*/
export type DialogControlOpts = {
cancel?: {
label?: string;
class?: ClassValue;
action?: (dialog: DialogAPI) => void;
};
ok?: {
label?: string;
class?: ClassValue;
action?: (dialog: DialogAPI) => void;
};
close?: {
class?: ClassValue;
action?: (dialog: DialogAPI) => void;
};
};
</script>
<script lang="ts">
import { Portal } from '@jsrob/svelte-portal';
import { 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';
import { ErrorMessage } from './error';
import ErrorBox from './ErrorBox.svelte';
interface Props {
open?: boolean;
title: string;
description?: string;
size?: 'sm' | 'md' | 'lg' | 'max';
class?: ClassValue;
children?: Snippet;
controls?: Snippet | DialogControlOpts;
onopen?: (dialog: DialogAPI) => void;
onclose?: (dialog: DialogAPI) => void;
}
let {
open = $bindable(false),
title,
description,
size = 'sm',
class: classValue,
children,
controls,
onopen,
onclose
}: Props = $props();
let dialogContainer = $state<HTMLDivElement | null>(null);
let error = $state<ErrorMessage | null>(null);
let loading = $state(false);
let frozen = $state(false);
// disable window scroll when dialog is open
$effect(() => {
if (open) {
document.body.style.overflow = 'hidden';
onopen?.(dialogAPI);
} else {
document.body.style.overflow = '';
onclose?.(dialogAPI);
}
});
const dialogAPI: DialogAPI = {
error: (message) => (error = message),
close: () => (open = false),
open: () => (open = true),
isOpen: () => open,
loading: () => (loading = true),
loaded: () => (loading = false),
isLoading: () => loading,
freeze: () => (frozen = true),
unfreeze: () => (frozen = false),
isFrozen: () => frozen
};
</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 }}
onclick={(e) => {
const target = e.target as HTMLElement;
if (open && !dialogContainer?.contains(target) && target !== dialogContainer) open = false;
}}
onkeydown={(e) => {
if (e.key === 'Escape') {
open = false;
}
}}
role="dialog"
tabindex="-1"
>
<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 error}
<ErrorBox {error} />
{/if}
{#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 && typeof controls === 'function'}{@render controls()}{:else}
<Button
class={controls?.cancel?.class}
onclick={() => {
if (controls?.cancel?.action) {
controls.cancel.action(dialogAPI);
} else if (!frozen) {
open = false;
}
}}
disabled={frozen}
>
{controls?.cancel?.label || 'Cancel'}
</Button>
<Button
class={controls?.ok?.class}
onclick={() => {
if (controls?.ok?.action) {
controls.ok.action(dialogAPI);
} else if (!frozen && !loading) {
open = false;
}
}}
disabled={frozen}
{loading}
>
{controls?.ok?.label || 'OK'}
</Button>
{/if}
</div>
<button
aria-label="close"
class="absolute top-4 right-4 inline-flex cursor-pointer items-center
justify-center disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
if (!frozen) open = false;
}}
disabled={frozen}
>
<X size="1.5em" weight="bold" />
</button>
</div>
</div>
{/snippet}