diff --git a/src/app.html b/src/app.html index 1deb5b4..983e301 100644 --- a/src/app.html +++ b/src/app.html @@ -5,6 +5,14 @@ + + + + + + import { Portal } from '@jsrob/svelte-portal'; + import { onMount, 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'; + + interface Props { + open?: boolean; + title: string; + description?: string; + size?: 'sm' | 'md' | 'lg' | 'max'; + class?: ClassValue; + children?: Snippet; + controls?: Snippet; + onsubmit?: () => void; + } + + let { + open = $bindable(false), + title, + description, + size = 'sm', + class: classValue, + children, + controls, + onsubmit + }: Props = $props(); + + let dialogContainer = $state(null); + let openSince = $state(undefined); + + $effect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + openSince = new Date(); + } else { + document.body.style.overflow = ''; + openSince = undefined; + } + }); + + onMount(() => { + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && open) { + open = false; + } + }); + + window.addEventListener('click', (e) => { + if (open && dialogContainer) { + if (openSince && new Date().getTime() - openSince.getTime() < 300) { + return; // Ignore clicks immediately after opening + } + + const target = e.target as HTMLElement; + if (open && !dialogContainer.contains(target) && target !== dialogContainer) { + open = false; + } + } + }); + }); + + + + {#if open} + {@render dialog()} + {/if} + + +{#snippet dialog()} +
+
+

{title}

+ {#if description} +

+ {description} +

+ {/if} + + {#if children}{@render children()}{:else}Dialog is empty{/if} + +
+ {#if controls}{@render controls()}{:else} + + + {/if} +
+ +
+
+{/snippet} diff --git a/src/lib/index.ts b/src/lib/index.ts index 652743e..dc8a578 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,9 +1,10 @@ // Reexport your entry components here export { default as Button } from './Button.svelte'; export { default as CenterBox } from './CenterBox.svelte'; -export { default as Checkbox } from './Checkbox.svelte'; +export { default as Checkbox, type CheckboxState } from './Checkbox.svelte'; export { default as Combobox, type ComboboxItem } from './Combobox.svelte'; export { default as DateInput } from './DateInput.svelte'; +export { default as Dialog } from './Dialog.svelte'; export { default as FramelessButton } from './FramelessButton.svelte'; export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte'; export { default as InjectUmami } from './InjectUmami.svelte'; diff --git a/src/lib/transition.ts b/src/lib/transition.ts new file mode 100644 index 0000000..d2d5572 --- /dev/null +++ b/src/lib/transition.ts @@ -0,0 +1,62 @@ +import { cubicOut } from 'svelte/easing'; +import type { TransitionConfig } from 'svelte/transition'; + +/** + * A utility function that converts a style object to a string. + * Credits: https://github.com/melt-ui/melt-ui/blob/42e2dd430c3e13f56062c4d610dabd84ea4e5517/src/lib/internal/helpers/style.ts + * License: MIT, https://github.com/melt-ui/melt-ui/blob/develop/LICENSE + * + * @param style - The style object to convert + * @returns The style object as a string + */ +export function styleToString(style: StyleObject): string { + return Object.keys(style).reduce((str, key) => { + if (style[key] === undefined) return str; + return str + `${key}:${style[key]};`; + }, ''); +} + +export type StyleObject = Record; + +/** + * Credits: https://github.com/melt-ui/melt-ui/blob/develop/src/docs/utils/transition.ts + * License: MIT, https://github.com/melt-ui/melt-ui/blob/develop/LICENSE + * Thanks to the Melt-UI team for such an awesome library!https://github.com/melt-ui/melt-ui/blob/develop/src/docs/utils/transition.ts + */ + +const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => { + const [minA, maxA] = scaleA; + const [minB, maxB] = scaleB; + + const percentage = (valueA - minA) / (maxA - minA); + const valueB = percentage * (maxB - minB) + minB; + + return valueB; +}; + +type FlyAndScaleOptions = { + x?: number; + y?: number; + start: number; + duration?: number; +}; +export const flyAndScale = (node: HTMLElement, options: FlyAndScaleOptions): TransitionConfig => { + const style = getComputedStyle(node); + const transform = style.transform === 'none' ? '' : style.transform; + + return { + duration: options.duration ?? 150, + delay: 0, + css: (t) => { + const x = scaleConversion(t, [0, 1], [options.x ?? 0, 0]); + const y = scaleConversion(t, [0, 1], [options.y ?? 0, 0]); + const scale = scaleConversion(t, [0, 1], [options.start, 1]); + + return styleToString({ + transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, + opacity: t + }); + }, + easing: cubicOut + }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 47d709d..a260bd0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,6 +3,7 @@ import Checkbox from '$lib/Checkbox.svelte'; import Combobox from '$lib/Combobox.svelte'; import DateInput from '$lib/DateInput.svelte'; + import Dialog from '$lib/Dialog.svelte'; import FramelessButton from '$lib/FramelessButton.svelte'; import InputGroup from '$lib/InputGroup.svelte'; import Link from '$lib/Link.svelte'; @@ -17,6 +18,7 @@ import ToggleSelect from '$lib/ToggleSelect.svelte'; let dateInputValue = $state(undefined); + let dialogOpen = $state(false); sui @@ -41,6 +43,8 @@ Click Me Visit Svelte + + @@ -154,6 +158,18 @@ Toggle Me! + { + dialogOpen = false; + alert('Dialog submitted!'); + }} +> +

This is a dialog content area.

+
+