dialog: title & control customization with snippets, fixed callbacks
This commit is contained in:
@@ -42,8 +42,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DialogControlButton = {
|
type DialogControlButton = {
|
||||||
|
/** Label for the button */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Additional classes to apply to the button */
|
||||||
class?: ClassValue;
|
class?: ClassValue;
|
||||||
|
/** Callback when the button is pressed */
|
||||||
action?: (dialog: DialogAPI) => void;
|
action?: (dialog: DialogAPI) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,25 +54,36 @@
|
|||||||
* Configures the default dialog controls.
|
* Configures the default dialog controls.
|
||||||
*/
|
*/
|
||||||
export type DialogControls = {
|
export type DialogControls = {
|
||||||
|
/** Options for the bottom cancel button */
|
||||||
cancel?: DialogControlButton | null;
|
cancel?: DialogControlButton | null;
|
||||||
|
/** Options for the bottom submit button */
|
||||||
ok?: DialogControlButton | null;
|
ok?: DialogControlButton | null;
|
||||||
close?: Omit<DialogControlButton, 'label'> | null;
|
/** Inverts the order of the buttons */
|
||||||
|
flip?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores internal state of the dialog, everything necessary to render
|
||||||
|
* internal snippets.
|
||||||
|
*/
|
||||||
|
type DialogState = {
|
||||||
|
frozen: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
api: DialogAPI;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultDialogControls: DialogControls = {
|
const defaultDialogControls: DialogControls = {
|
||||||
cancel: {
|
cancel: { label: 'Cancel' },
|
||||||
label: 'Cancel'
|
ok: { label: 'OK' }
|
||||||
},
|
|
||||||
ok: {
|
|
||||||
label: 'OK'
|
|
||||||
},
|
|
||||||
close: {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { dialogCancelButton, dialogOkButton, dialogCloseButton };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Portal } from '@jsrob/svelte-portal';
|
import { Portal } from '@jsrob/svelte-portal';
|
||||||
import { type Snippet } from 'svelte';
|
import { untrack, 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';
|
||||||
@@ -80,17 +94,33 @@
|
|||||||
import { mergeOverrideObject } from './util';
|
import { mergeOverrideObject } from './util';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** Bindable open state of the dialog */
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
title: string;
|
/** Title of the dialog */
|
||||||
description?: string;
|
title: string | Snippet;
|
||||||
|
/** Description of the dialog, optionally rendered below the title */
|
||||||
|
description?: string | Snippet;
|
||||||
|
/** Size of the dialog (default: 'sm') */
|
||||||
size?: 'sm' | 'md' | 'lg' | 'max';
|
size?: 'sm' | 'md' | 'lg' | 'max';
|
||||||
|
/** Additional classes for the dialog */
|
||||||
class?: ClassValue;
|
class?: ClassValue;
|
||||||
|
/** Content of the dialog */
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
/** Bottom controls for the dialog */
|
||||||
controls?: Snippet | DialogControls;
|
controls?: Snippet | DialogControls;
|
||||||
|
/** Sets bottom alignment of controls (default: end) */
|
||||||
|
controlsAlign?: 'start' | 'center' | 'end';
|
||||||
|
/** Top-right close control */
|
||||||
|
close?: Snippet | Omit<DialogControlButton, 'label'> | null;
|
||||||
|
/** Callback when the dialog is opened */
|
||||||
onopen?: (dialog: DialogAPI) => void;
|
onopen?: (dialog: DialogAPI) => void;
|
||||||
|
/** Callback when the dialog is closed */
|
||||||
onclose?: (dialog: DialogAPI) => void;
|
onclose?: (dialog: DialogAPI) => void;
|
||||||
|
/** If default controls are used, controls loading state of submit button */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
/** If default controls are used, freezes all interactions */
|
||||||
frozen?: boolean;
|
frozen?: boolean;
|
||||||
|
/** If default controls are used, disables submit button */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +132,8 @@
|
|||||||
class: classValue,
|
class: classValue,
|
||||||
children,
|
children,
|
||||||
controls: rawControls = defaultDialogControls,
|
controls: rawControls = defaultDialogControls,
|
||||||
|
controlsAlign = 'end',
|
||||||
|
close = {},
|
||||||
onopen,
|
onopen,
|
||||||
onclose,
|
onclose,
|
||||||
loading = $bindable(false),
|
loading = $bindable(false),
|
||||||
@@ -122,14 +154,15 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
onopen?.(dialogAPI);
|
untrack(() => onopen?.(dialogAPI));
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
onclose?.(dialogAPI);
|
untrack(() => onclose?.(dialogAPI));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const dialogAPI: DialogAPI = {
|
/** DialogAPI instance to control this dialog */
|
||||||
|
export const dialogAPI: DialogAPI = {
|
||||||
error: (message) => (error = ErrorMessage.from(message)),
|
error: (message) => (error = ErrorMessage.from(message)),
|
||||||
close: () => (open = false),
|
close: () => (open = false),
|
||||||
open: () => (open = true),
|
open: () => (open = true),
|
||||||
@@ -146,6 +179,16 @@
|
|||||||
canContinue: () => !loading && !disabled && !frozen,
|
canContinue: () => !loading && !disabled && !frozen,
|
||||||
title: (newTitle) => (title = newTitle)
|
title: (newTitle) => (title = newTitle)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Returns the current state of the dialog */
|
||||||
|
export const getState = (): DialogState => {
|
||||||
|
return {
|
||||||
|
frozen,
|
||||||
|
loading,
|
||||||
|
disabled,
|
||||||
|
api: dialogAPI
|
||||||
|
};
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Portal target="body">
|
<Portal target="body">
|
||||||
@@ -190,7 +233,21 @@
|
|||||||
start: 0.96
|
start: 0.96
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 class="pointer-events-none mb-2 text-lg font-medium text-black select-none">{title}</h2>
|
<div class="flex items-center justify-between">
|
||||||
|
<!-- Dialog title -->
|
||||||
|
<h2 class="pointer-events-none mb-2 text-lg font-medium text-black select-none">
|
||||||
|
{@render stringOrSnippet(title)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Close Button -->
|
||||||
|
{#if close !== null}
|
||||||
|
{#if typeof close === 'function'}
|
||||||
|
{@render close()}
|
||||||
|
{:else}
|
||||||
|
{@render dialogCloseButton(getState(), close)}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<ErrorBox {error} />
|
<ErrorBox {error} />
|
||||||
@@ -198,68 +255,102 @@
|
|||||||
|
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="mb-3 leading-normal text-zinc-600">
|
<p class="mb-3 leading-normal text-zinc-600">
|
||||||
{description}
|
{@render stringOrSnippet(description)}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if children}{@render children()}{:else}Dialog is empty{/if}
|
{#if children}{@render children()}{:else}Dialog is empty{/if}
|
||||||
|
|
||||||
<!-- Dialog Controls -->
|
<!-- Dialog Controls -->
|
||||||
<div class="mt-6 flex justify-end gap-4">
|
<div
|
||||||
{#if controls && typeof controls === 'function'}{@render controls()}{:else}
|
class={[
|
||||||
{#if controls.cancel !== null}
|
'mt-6 flex gap-4',
|
||||||
<Button
|
controlsAlign === 'start' && 'justify-start',
|
||||||
class={controls?.cancel?.class}
|
controlsAlign === 'center' && 'justify-center',
|
||||||
onclick={() => {
|
controlsAlign === 'end' && 'justify-end'
|
||||||
if (controls?.cancel?.action) {
|
]}
|
||||||
controls.cancel.action(dialogAPI);
|
|
||||||
} else if (!frozen) {
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={frozen}
|
|
||||||
>
|
>
|
||||||
{controls?.cancel?.label || 'Cancel'}
|
{#if controls && typeof controls === 'function'}{@render controls()}{:else}
|
||||||
</Button>
|
{#if controls.ok !== null}
|
||||||
|
{@render dialogOkButton(getState(), controls.ok)}
|
||||||
|
{/if}
|
||||||
|
{#if controls?.flip}
|
||||||
|
{#if controls.cancel !== null}
|
||||||
|
{@render dialogCancelButton(getState(), controls.cancel)}
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#if controls.cancel !== null}
|
||||||
|
{@render dialogCancelButton(getState(), controls.cancel)}
|
||||||
{/if}
|
{/if}
|
||||||
{#if controls.ok !== null}
|
{#if controls.ok !== null}
|
||||||
<Button
|
{@render dialogOkButton(getState(), controls.ok)}
|
||||||
class={controls?.ok?.class}
|
{/if}
|
||||||
onclick={() => {
|
|
||||||
if (controls?.ok?.action) {
|
|
||||||
controls.ok.action(dialogAPI);
|
|
||||||
} else if (!frozen && !loading && !disabled) {
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={frozen || loading || disabled}
|
|
||||||
{loading}
|
|
||||||
>
|
|
||||||
{controls?.ok?.label || 'OK'}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Close Button -->
|
|
||||||
{#if typeof controls === 'function' || controls?.close !== null}
|
|
||||||
<button
|
|
||||||
type="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 (typeof controls !== 'function' && controls?.close?.action) {
|
|
||||||
controls?.close?.action?.(dialogAPI);
|
|
||||||
} else if (!frozen) {
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={frozen}
|
|
||||||
>
|
|
||||||
<X size="1.5em" weight="bold" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet dialogCancelButton(state: DialogState, opts?: DialogControls['cancel'])}
|
||||||
|
<Button
|
||||||
|
class={opts?.class}
|
||||||
|
onclick={() => {
|
||||||
|
if (opts?.action) {
|
||||||
|
opts.action(state.api);
|
||||||
|
} else if (!state.frozen) {
|
||||||
|
state.api.close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={state.frozen}
|
||||||
|
>
|
||||||
|
{opts?.label || 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet dialogOkButton(state: DialogState, opts?: DialogControls['ok'])}
|
||||||
|
<Button
|
||||||
|
class={opts?.class}
|
||||||
|
onclick={() => {
|
||||||
|
if (opts?.action) {
|
||||||
|
opts.action(state.api);
|
||||||
|
} else if (!state.frozen && !state.loading && !state.disabled) {
|
||||||
|
state.api.close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={state.frozen || state.loading || state.disabled}
|
||||||
|
loading={state.loading}
|
||||||
|
>
|
||||||
|
{opts?.label || 'OK'}
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet dialogCloseButton(state: DialogState, opts?: Omit<DialogControlButton, 'label'> | null)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="close"
|
||||||
|
class={[
|
||||||
|
'inline-flex cursor-pointer items-center justify-center',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'rounded-full p-2 transition-colors hover:bg-zinc-200/50'
|
||||||
|
]}
|
||||||
|
onclick={() => {
|
||||||
|
if (opts?.action) {
|
||||||
|
opts.action(state.api);
|
||||||
|
} else if (!state.frozen) {
|
||||||
|
state.api.close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={state.frozen}
|
||||||
|
>
|
||||||
|
<X size="1.25em" weight="bold" />
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet stringOrSnippet(val: string | Snippet)}
|
||||||
|
{#if typeof val === 'string'}
|
||||||
|
{val}
|
||||||
|
{:else}
|
||||||
|
{@render val()}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ 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, type DialogAPI, type DialogControls } from './Dialog.svelte';
|
export {
|
||||||
|
default as Dialog,
|
||||||
|
type DialogAPI,
|
||||||
|
type DialogControls,
|
||||||
|
dialogCancelButton,
|
||||||
|
dialogCloseButton,
|
||||||
|
dialogOkButton
|
||||||
|
} from './Dialog.svelte';
|
||||||
export {
|
export {
|
||||||
default as DurationInput,
|
default as DurationInput,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
|
|||||||
Reference in New Issue
Block a user