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

View File

@ -4,6 +4,7 @@
import IconDeviceFloppy from '~icons/tabler/device-floppy'; import IconDeviceFloppy from '~icons/tabler/device-floppy';
import IconTrash from '~icons/tabler/trash'; import IconTrash from '~icons/tabler/trash';
import IconX from '~icons/tabler/x'; import IconX from '~icons/tabler/x';
import { Toast } from '../toast/notification';
let { children, createOnly } = $props(); let { children, createOnly } = $props();
</script> </script>
@ -13,7 +14,15 @@
method="POST" method="POST"
action="?/update" action="?/update"
use:enhance={() => { 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 }); 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"> <script lang="ts">
import { store } from '$lib/v2/globalStore.svelte';
import Notification from '$lib/v2/toast/Notification.svelte';
import '../app.css'; import '../app.css';
let { children } = $props(); let { children } = $props();
</script> </script>
{@render children()} {@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 { store } from '$lib/v2/globalStore.svelte.js';
import EditPage from '$lib/v2/snippets/EditPage.svelte'; import EditPage from '$lib/v2/snippets/EditPage.svelte';
let { data, form } = $props(); let { data } = $props();
store.pageTitle = 'Account details'; store.pageTitle = 'Account details';
</script> </script>
<p class="text-white">{form?.error}</p>
<EditPage createOnly> <EditPage createOnly>
<InputText <InputText
name="name" name="name"

View File

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

View File

@ -1,11 +1,27 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import InputText from '$lib/v2/forms/InputText.svelte'; import InputText from '$lib/v2/forms/InputText.svelte';
import { Toast } from '$lib/v2/toast/notification';
import { Button } from 'bits-ui'; import { Button } from 'bits-ui';
import { untrack } from 'svelte';
import IconLogin2 from '~icons/tabler/login2'; import IconLogin2 from '~icons/tabler/login2';
import IconX from '~icons/tabler/x';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { form }: PageProps = $props(); let { form }: PageProps = $props();
$effect(() => {
if (form?.error) {
untrack(() => {
Toast.add({
Icon: IconX,
content: form.error,
theme: 'error',
});
});
}
});
</script> </script>
<svelte:head> <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 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" rounded-2xl border border-neutral-700 shadow-xl"
> >
<form method="POST"> <form method="POST" use:enhance>
<InputText <InputText
name="username" name="username"
label="Username" label="Username"