File size: 4,247 Bytes
cf47645 d7cd63b f33fa43 fbef5e8 cf47645 f33fa43 97c4991 cf47645 97c4991 cf47645 97c4991 427a3a2 cf47645 f33fa43 cf47645 9104321 cf47645 97c4991 f33fa43 cf47645 97c4991 fbef5e8 d7cd63b 427a3a2 cf47645 d7cd63b cf47645 d7cd63b cf47645 d7cd63b fbef5e8 9104321 f33fa43 9104321 cf47645 f33fa43 cf47645 97c4991 cf47645 9104321 f33fa43 97c4991 fbef5e8 cf47645 97c4991 cf47645 9104321 cf47645 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
<script lang="ts">
import { autoUpdate, computePosition, flip, type Placement } from "@floating-ui/dom";
import { Toaster } from "melt/builders";
import { type Snippet } from "svelte";
import { type Attachment } from "svelte/attachments";
interface Props {
children: Snippet<[{ addToast: typeof toaster.addToast; trigger: typeof trigger }]>;
toast?: Snippet<[{ toast: (typeof toaster.toasts)[0]; float: typeof float }]>;
closeDelay?: number;
}
const { children, closeDelay = 2000, toast: toastSnippet }: Props = $props();
const id = $props.id();
export const trigger = {
"data-local-toast-trigger": id,
} as const;
type ToastData = {
content: string;
variant: "info" | "danger";
};
export const toaster = new Toaster<ToastData>({
hover: null,
closeDelay: () => closeDelay,
});
export const addToast = toaster.addToast;
const float: Attachment<HTMLElement> = function (node) {
let placement: Placement = $state("top");
const triggerEl = document.querySelector(`[data-local-toast-trigger=${id}]`);
if (!triggerEl) return;
const compute = () =>
computePosition(triggerEl, node, {
strategy: "absolute",
placement: "top",
middleware: [flip({ fallbackPlacements: ["left"] })],
}).then(({ x, y, placement: _placement }) => {
placement = _placement;
Object.assign(node.style, {
left: placement === "top" ? `${x}px` : `${x - 4}px`,
top: placement === "top" ? `${y - 6}px` : `${y}px`,
});
// Animate
// Cancel any ongoing animations
node.getAnimations().forEach(anim => anim.cancel());
// Determine animation direction based on placement
let keyframes: Keyframe[] = [];
switch (placement) {
case "top":
keyframes = [
{ opacity: 0, transform: "translateY(8px)", scale: "0.8" },
{ opacity: 1, transform: "translateY(0)", scale: "1" },
];
break;
case "left":
keyframes = [
{ opacity: 0, transform: "translateX(8px)", scale: "0.8" },
{ opacity: 1, transform: "translateX(0)", scale: "1" },
];
break;
}
node.animate(keyframes, {
duration: 500,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
fill: "forwards",
});
});
const reference = node.cloneNode(true) as HTMLElement;
node.before(reference);
reference.style.visibility = "hidden";
const destroyers = [
autoUpdate(triggerEl, node, compute),
async () => {
// clone node
const cloned = node.cloneNode(true) as HTMLElement;
reference.before(cloned);
reference.remove();
cloned.getAnimations().forEach(anim => anim.cancel());
// Animate out
// Cancel any ongoing animations
cloned.getAnimations().forEach(anim => anim.cancel());
// Determine animation direction based on placement
let keyframes: Keyframe[] = [];
switch (placement) {
case "top":
keyframes = [
{ opacity: 1, transform: "translateY(0)" },
{ opacity: 0, transform: "translateY(-8px)" },
];
break;
case "left":
keyframes = [
{ opacity: 1, transform: "translateX(0)" },
{ opacity: 0, transform: "translateX(-8px)" },
];
break;
}
await cloned.animate(keyframes, {
duration: 400,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
fill: "forwards",
}).finished;
cloned.remove();
},
];
return () => destroyers.forEach(d => d());
};
const classMap: Record<ToastData["variant"], string> = {
info: "border border-blue-400 bg-gradient-to-b from-blue-500 to-blue-600",
danger: "border border-red-400 bg-gradient-to-b from-red-500 to-red-600",
};
</script>
{@render children({ trigger, addToast: toaster.addToast })}
{#each toaster.toasts.slice(toaster.toasts.length - 1) as toast (toast.id)}
<div
data-local-toast
data-variant={toast.data.variant}
class={[!toastSnippet && `${classMap[toast.data.variant]} rounded-full px-2 py-1 text-xs`]}
{@attach float}
>
{#if toastSnippet}
{@render toastSnippet({ toast, float })}
{:else}
{toast.data.content}
{/if}
</div>
{/each}
<style>
[data-local-toast] {
/* Float on top of the UI */
position: absolute;
/* Avoid layout interference */
width: max-content;
top: 0;
left: 0;
}
</style>
|