dialog: add dialog api & detailed controls config, fix outer click
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user