213 lines
5.2 KiB
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}
|