dialog: add dialog api & detailed controls config, fix outer click

This commit is contained in:
Elijah Duffy
2025-07-16 17:41:56 -07:00
parent 20c1a4fa89
commit ee922b49ee
3 changed files with 140 additions and 37 deletions

View File

@@ -1,11 +1,61 @@
<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"> <script lang="ts">
import { Portal } from '@jsrob/svelte-portal'; import { Portal } from '@jsrob/svelte-portal';
import { onMount, type Snippet } from 'svelte'; import { type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { flyAndScale } from './transition'; import { flyAndScale } from './transition';
import Button from './Button.svelte'; import Button from './Button.svelte';
import { X } from 'phosphor-svelte'; import { X } from 'phosphor-svelte';
import { ErrorMessage } from './error';
import ErrorBox from './ErrorBox.svelte';
interface Props { interface Props {
open?: boolean; open?: boolean;
@@ -14,8 +64,9 @@
size?: 'sm' | 'md' | 'lg' | 'max'; size?: 'sm' | 'md' | 'lg' | 'max';
class?: ClassValue; class?: ClassValue;
children?: Snippet; children?: Snippet;
controls?: Snippet; controls?: Snippet | DialogControlOpts;
onsubmit?: () => void; onopen?: (dialog: DialogAPI) => void;
onclose?: (dialog: DialogAPI) => void;
} }
let { let {
@@ -26,42 +77,38 @@
class: classValue, class: classValue,
children, children,
controls, controls,
onsubmit onopen,
onclose
}: Props = $props(); }: Props = $props();
let dialogContainer = $state<HTMLDivElement | null>(null); let dialogContainer = $state<HTMLDivElement | null>(null);
let openSince = $state<Date | undefined>(undefined); let error = $state<ErrorMessage | null>(null);
let loading = $state(false);
let frozen = $state(false);
// disable window scroll when dialog is open
$effect(() => { $effect(() => {
if (open) { if (open) {
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
openSince = new Date(); onopen?.(dialogAPI);
} else { } else {
document.body.style.overflow = ''; document.body.style.overflow = '';
openSince = undefined; onclose?.(dialogAPI);
} }
}); });
onMount(() => { const dialogAPI: DialogAPI = {
window.addEventListener('keydown', (e) => { error: (message) => (error = message),
if (e.key === 'Escape' && open) { close: () => (open = false),
open = false; open: () => (open = true),
} isOpen: () => open,
}); loading: () => (loading = true),
loaded: () => (loading = false),
window.addEventListener('click', (e) => { isLoading: () => loading,
if (open && dialogContainer) { freeze: () => (frozen = true),
if (openSince && new Date().getTime() - openSince.getTime() < 300) { unfreeze: () => (frozen = false),
return; // Ignore clicks immediately after opening isFrozen: () => frozen
} };
const target = e.target as HTMLElement;
if (open && !dialogContainer.contains(target) && target !== dialogContainer) {
open = false;
}
}
});
});
</script> </script>
<Portal target="body"> <Portal target="body">
@@ -77,6 +124,17 @@
classValue classValue
]} ]}
transition:fade={{ duration: 150 }} 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 <div
bind:this={dialogContainer} bind:this={dialogContainer}
@@ -94,6 +152,11 @@
}} }}
> >
<h2 class="pointer-events-none mb-2 text-lg font-medium text-black select-none">{title}</h2> <h2 class="pointer-events-none mb-2 text-lg font-medium text-black select-none">{title}</h2>
{#if error}
<ErrorBox {error} />
{/if}
{#if description} {#if description}
<p class="mb-3 leading-normal text-zinc-600"> <p class="mb-3 leading-normal text-zinc-600">
{description} {description}
@@ -103,15 +166,44 @@
{#if children}{@render children()}{:else}Dialog is empty{/if} {#if children}{@render children()}{:else}Dialog is empty{/if}
<div class="mt-6 flex justify-end gap-4"> <div class="mt-6 flex justify-end gap-4">
{#if controls}{@render controls()}{:else} {#if controls && typeof controls === 'function'}{@render controls()}{:else}
<Button onclick={() => (open = false)}>Cancel</Button> <Button
<Button onclick={() => onsubmit?.()}>OK</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} {/if}
</div> </div>
<button <button
aria-label="close" aria-label="close"
class="absolute top-4 right-4 inline-flex cursor-pointer items-center justify-center" class="absolute top-4 right-4 inline-flex cursor-pointer items-center
onclick={() => (open = false)} justify-center disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
if (!frozen) open = false;
}}
disabled={frozen}
> >
<X size="1.5em" weight="bold" /> <X size="1.5em" weight="bold" />
</button> </button>

View File

@@ -5,7 +5,7 @@ export { default as CenterBox } from './CenterBox.svelte';
export { default as Checkbox, type CheckboxState } from './Checkbox.svelte'; export { default as Checkbox, type CheckboxState } from './Checkbox.svelte';
export { default as Combobox, type ComboboxOption } from './Combobox.svelte'; export { default as Combobox, type ComboboxOption } from './Combobox.svelte';
export { default as DateInput } from './DateInput.svelte'; export { default as DateInput } from './DateInput.svelte';
export { default as Dialog } from './Dialog.svelte'; export { default as Dialog, type DialogAPI, type DialogControlOpts } from './Dialog.svelte';
export { default as ErrorBox } from './ErrorBox.svelte'; export { default as ErrorBox } from './ErrorBox.svelte';
export { default as FramelessButton } from './FramelessButton.svelte'; export { default as FramelessButton } from './FramelessButton.svelte';
export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte'; export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte';

View File

@@ -29,7 +29,7 @@
TextStrikethrough, TextStrikethrough,
TextUnderline TextUnderline
} from 'phosphor-svelte'; } from 'phosphor-svelte';
import type { ComboboxOption, Option } from '$lib'; import { ErrorMessage, type ComboboxOption, type Option } from '$lib';
import Tabs from '$lib/Tabs.svelte'; import Tabs from '$lib/Tabs.svelte';
const comboboxOptions = [ const comboboxOptions = [
@@ -408,9 +408,20 @@
bind:open={dialogOpen} bind:open={dialogOpen}
title="Dialog Title" title="Dialog Title"
size="sm" size="sm"
onsubmit={() => { controls={{
dialogOpen = false; ok: {
alert('Dialog submitted!'); action: (dialog) => {
dialog.close();
alert('Dialog submitted!');
}
}
}}
onopen={(dialog) => {
dialog.error(ErrorMessage.from('Example error message!'));
dialog.loading();
setTimeout(() => {
dialog.loaded();
}, 2000);
}} }}
> >
<p>This is a dialog content area.</p> <p>This is a dialog content area.</p>