forked from cms/tibi-svelte-starter
✨ feat: add loading bar and toast notification system with responsive design
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Indeterminate loading bar — shows when API requests are in flight.
|
||||
* 300ms delay prevents flickering on fast requests.
|
||||
*/
|
||||
import { activeRequests } from "../lib/requestsStore"
|
||||
|
||||
let showLoading = $state(false)
|
||||
let hideTimeout: ReturnType<typeof setTimeout>
|
||||
let showTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
$effect(() => {
|
||||
if ($activeRequests > 0) {
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
showTimeout = setTimeout(() => {
|
||||
showLoading = true
|
||||
}, 300)
|
||||
} else {
|
||||
if (showTimeout) clearTimeout(showTimeout)
|
||||
hideTimeout = setTimeout(() => {
|
||||
showLoading = false
|
||||
}, 150)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
if (showTimeout) clearTimeout(showTimeout)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="fixed top-0 left-0 right-0 h-1 z-50 bg-blue-600/10 loading-bar" class:visible={showLoading}>
|
||||
<div class="h-full bg-blue-600 loading-bar-progress shadow-lg"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading-bar {
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-bar.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.loading-bar-progress {
|
||||
width: 100%;
|
||||
animation: loadingBarSlide 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes loadingBarSlide {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Toast Container
|
||||
* Displays toast notifications with responsive positioning.
|
||||
* Mobile: bottom-center, Desktop: top-right
|
||||
*/
|
||||
import { toasts, removeToast, type Toast } from "../lib/toast"
|
||||
import { portal } from "../lib/actions/portal"
|
||||
|
||||
function getToastClasses(type: Toast["type"]): string {
|
||||
const base = "flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg"
|
||||
|
||||
switch (type) {
|
||||
case "success":
|
||||
return `${base} bg-green-600 text-white`
|
||||
case "error":
|
||||
return `${base} bg-red-600 text-white`
|
||||
case "warning":
|
||||
return `${base} bg-amber-500 text-white`
|
||||
case "info":
|
||||
default:
|
||||
return `${base} bg-gray-800 text-white`
|
||||
}
|
||||
}
|
||||
|
||||
function getIcon(type: Toast["type"]): string {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return "M9 12.75 11.25 15 15 9.75"
|
||||
case "error":
|
||||
return "M6 6L18 18M6 18L18 6"
|
||||
case "warning":
|
||||
return "M12 9v3.75m0 3v.008"
|
||||
case "info":
|
||||
default:
|
||||
return "M12 9v3.75m0 3v.008"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $toasts.length > 0}
|
||||
<!-- Desktop: top-right -->
|
||||
<div
|
||||
use:portal
|
||||
class="fixed top-4 right-4 z-50 flex-col gap-2 max-w-sm w-full hidden md:flex"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#each $toasts as t (t.id)}
|
||||
<div class={getToastClasses(t.type)} role="alert">
|
||||
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5"></circle>
|
||||
<path
|
||||
d={getIcon(t.type)}
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="text-sm flex-1">{t.message}</p>
|
||||
<button
|
||||
onclick={() => removeToast(t.id)}
|
||||
class="shrink-0 p-1 hover:bg-white/20 rounded transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Mobile: bottom-center -->
|
||||
<div
|
||||
use:portal
|
||||
class="fixed bottom-20 left-4 right-4 z-50 flex flex-col gap-2 md:hidden"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#each $toasts as t (t.id)}
|
||||
<div class={getToastClasses(t.type)} role="alert">
|
||||
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5"></circle>
|
||||
<path
|
||||
d={getIcon(t.type)}
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="text-sm flex-1">{t.message}</p>
|
||||
<button
|
||||
onclick={() => removeToast(t.id)}
|
||||
class="shrink-0 p-1 hover:bg-white/20 rounded transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Toast entrance animation */
|
||||
div[role="alert"] {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: slide up from bottom */
|
||||
:global(.md\:hidden) div[role="alert"] {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user