feat: basic toast notification system - closes #5

This commit is contained in:
axel 2025-04-19 20:47:43 +02:00
parent a051ec8408
commit f1143551d6
8 changed files with 127 additions and 5 deletions

View File

@ -1,7 +1,10 @@
import { env } from '$env/dynamic/public';
import type { NotificationData } from './toast/notification';
export const store = $state<{
pageTitle: string;
notifications: NotificationData[];
}>({
pageTitle: env.PUBLIC_SITE_NAME ?? '',
notifications: [],
});

View File

@ -4,6 +4,7 @@
import IconDeviceFloppy from '~icons/tabler/device-floppy';
import IconTrash from '~icons/tabler/trash';
import IconX from '~icons/tabler/x';
import { Toast } from '../toast/notification';
let { children, createOnly } = $props();
</script>
@ -13,7 +14,15 @@
method="POST"
action="?/update"
use:enhance={() => {
return async ({ update }) => {
return async ({ update, result }) => {
if (result.type == 'failure' && result.data?.error) {
Toast.add({
Icon: IconX,
content: result.data.error as string,
theme: 'error',
});
}
update({ reset: false });
};
}}

View File

@ -0,0 +1,43 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { Toast, type NotificationData, type NotificationTheme } from './notification';
const themeClasses: Record<NotificationTheme, string> = {
neutral: 'bg-neutral-700 hover:bg-neutral-600 border-neutral-500',
error: 'bg-red-700 hover:bg-red-500 border-red-500',
success: 'bg-emerald-700 hover:bg-emerald-600 border-emerald-500',
};
let { data, class: otherClass = '' }: { data: NotificationData; class?: string } = $props();
onMount(() => {
if (data.timeout > -1) {
setTimeout(() => Toast.remove(data.id), data.timeout * 1000);
}
});
afterNavigate(() => {
Toast.clear();
});
</script>
<button
aria-label="Dismiss alert notification"
transition:fade
class="flex items-center px-6 gap-2 py-2 cursor-pointer pointer-events-auto shadow-lg text-sm sm:text-base
rounded-full transition-all duration-300 hover:scale-99 border text-white w-fit
{themeClasses[data.theme]} {otherClass}"
onclick={() => {
if (data.dismissable) Toast.remove(data.id);
}}
>
{#if data.Icon}
<data.Icon class="sm:-ml-2 {data.spin ? 'animate-spin' : ''}" />
{/if}
<p role="alert">{data.content}</p>
{#if data.dismissable}
<p>(Click to dismiss)</p>
{/if}
</button>

View File

@ -0,0 +1,43 @@
import { nanoid } from 'nanoid';
import { store } from '../globalStore.svelte';
export type NotificationTheme = 'neutral' | 'error' | 'success';
export type NotificationData = {
Icon?: any;
id: string;
content: string;
dismissable: boolean;
timeout: number;
spin: boolean;
theme: NotificationTheme;
};
export type BasicNotificationData = Omit<Partial<NotificationData>, 'id'> & { content: string };
export namespace Toast {
export function add(options: BasicNotificationData) {
const data: NotificationData = Object.assign(
{
id: nanoid(),
dismissable: true,
timeout: 10,
theme: 'neutral',
spin: false,
},
options,
);
store.notifications.push(data);
return data.id;
}
export function remove(notificationId: string) {
const currentNotifications = store.notifications;
store.notifications = currentNotifications.filter((n) => n.id !== notificationId);
}
export function clear() {
store.notifications = [];
}
}

View File

@ -1,7 +1,17 @@
<script lang="ts">
import { store } from '$lib/v2/globalStore.svelte';
import Notification from '$lib/v2/toast/Notification.svelte';
import '../app.css';
let { children } = $props();
</script>
{@render children()}
<div
class="fixed flex flex-col gap-5 m-3 sm:m-6 bottom-0 left-0 pointer-events-none transition-all"
>
{#each store.notifications as n (n.id)}
<Notification data={n} />
{/each}
</div>

View File

@ -3,12 +3,11 @@
import { store } from '$lib/v2/globalStore.svelte.js';
import EditPage from '$lib/v2/snippets/EditPage.svelte';
let { data, form } = $props();
let { data } = $props();
store.pageTitle = 'Account details';
</script>
<p class="text-white">{form?.error}</p>
<EditPage createOnly>
<InputText
name="name"

View File

@ -17,7 +17,6 @@
});
</script>
<p class="text-white">{form?.error}</p>
<EditPage createOnly={!data.user}>
<InputText
name="name"

View File

@ -1,11 +1,27 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { env } from '$env/dynamic/public';
import InputText from '$lib/v2/forms/InputText.svelte';
import { Toast } from '$lib/v2/toast/notification';
import { Button } from 'bits-ui';
import { untrack } from 'svelte';
import IconLogin2 from '~icons/tabler/login2';
import IconX from '~icons/tabler/x';
import type { PageProps } from './$types';
let { form }: PageProps = $props();
$effect(() => {
if (form?.error) {
untrack(() => {
Toast.add({
Icon: IconX,
content: form.error,
theme: 'error',
});
});
}
});
</script>
<svelte:head>
@ -17,7 +33,7 @@
class="flex flex-col m-4 w-full max-w-120 bg-neutral-950 p-8 sm:p-12 sm:px
rounded-2xl border border-neutral-700 shadow-xl"
>
<form method="POST">
<form method="POST" use:enhance>
<InputText
name="username"
label="Username"