Feedback & status
Toast code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/toast.blade.php
@props([
'position' => 'top-right',
'duration' => 5000,
])
@php
/*
| Global toast / notification container. Mount it once per page (or in a
| layout) and trigger notifications from anywhere by dispatching a `notify`
| window event with a detail payload:
|
| $dispatch('notify', {
| variant: 'success', // info | success | warning | danger | neutral
| title: 'Saved',
| message: 'Your changes are live.',
| duration: 5000, // ms; 0 keeps it until dismissed
| action: { // optional call-to-action under the message
| label: 'Undo',
| href: '/posts', // renders a link…
| // event: 'undo-delete' // …or a window event name (dispatched, then dismissed)
| },
| })
|
| Toasts slide in from the corner, stack, pause on hover and fade out on
| their own. Hover to pause the timer, click the x to dismiss early.
|
| position: top-right, top-left, bottom-right, bottom-left
*/
$positionClass = [
'top-right' => 'top-4 right-4 sm:top-6 sm:right-6 items-end',
'top-left' => 'top-4 left-4 sm:top-6 sm:left-6 items-start',
'bottom-right' => 'bottom-4 right-4 sm:bottom-6 sm:right-6 items-end',
'bottom-left' => 'bottom-4 left-4 sm:bottom-6 sm:left-6 items-start',
][$position] ?? 'top-4 right-4 sm:top-6 sm:right-6 items-end';
// Slide direction for the enter/leave transition, based on the corner.
$offClass = str_contains($position, 'left') ? '-translate-x-8' : 'translate-x-8';
$variants = [
'info' => ['icon' => 'ph-info', 'color' => 'text-accent-500'],
'success' => ['icon' => 'ph-check-circle', 'color' => 'text-green-500'],
'warning' => ['icon' => 'ph-warning', 'color' => 'text-amber-500'],
'danger' => ['icon' => 'ph-warning-octagon', 'color' => 'text-red-500'],
'neutral' => ['icon' => 'ph-bell', 'color' => 'text-gray-500'],
];
@endphp
<div
x-data="{
toasts: [],
variants: @js($variants),
defaultDuration: @js((int) $duration),
add(detail) {
detail = detail ?? {};
if (typeof detail === 'string') {
detail = { message: detail };
}
const id = Date.now() + Math.random();
const duration = detail.duration ?? this.defaultDuration;
this.toasts.push({
id,
title: detail.title ?? null,
message: detail.message ?? '',
variant: this.variants[detail.variant] ? detail.variant : 'info',
duration,
action: detail.action && detail.action.label ? detail.action : null,
show: false,
timer: null,
remaining: duration,
startedAt: null,
});
// Operate on the reactive proxy from the array, not the raw object,
// so mutations (show/timer) trigger Alpine's x-show / x-for updates.
const toast = this.toasts[this.toasts.length - 1];
this.$nextTick(() => { toast.show = true; });
this.startTimer(toast);
},
startTimer(toast) {
if (toast.duration <= 0) { return; }
toast.startedAt = Date.now();
toast.timer = setTimeout(() => this.remove(toast.id), toast.remaining);
},
pause(toast) {
if (! toast.timer) { return; }
clearTimeout(toast.timer);
toast.timer = null;
toast.remaining -= Date.now() - toast.startedAt;
},
resume(toast) {
if (toast.duration <= 0 || toast.timer) { return; }
this.startTimer(toast);
},
remove(id) {
const toast = this.toasts.find((t) => t.id === id);
if (! toast) { return; }
if (toast.timer) { clearTimeout(toast.timer); }
toast.show = false;
setTimeout(() => {
this.toasts = this.toasts.filter((t) => t.id !== id);
}, 300);
},
}"
@notify.window="add($event.detail)"
class="pointer-events-none fixed z-[60] flex w-full max-w-sm flex-col gap-3 {{ $positionClass }}"
aria-live="polite"
aria-atomic="false"
>
<template x-for="toast in toasts" :key="toast.id">
<div
x-show="toast.show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 {{ $offClass }} sm:scale-95"
x-transition:enter-end="opacity-100 translate-x-0 sm:scale-100"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100 translate-x-0 sm:scale-100"
x-transition:leave-end="opacity-0 {{ $offClass }} sm:scale-95"
@mouseenter="pause(toast)"
@mouseleave="resume(toast)"
role="status"
class="pointer-events-auto flex w-full items-start gap-3 rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-lg shadow-gray-900/5 dark:shadow-black/30"
>
<i
:class="'ph-fill ' + variants[toast.variant].icon + ' ' + variants[toast.variant].color"
class="mt-0.5 shrink-0 text-lg leading-none"
aria-hidden="true"
></i>
<div class="min-w-0 flex-1 text-sm">
<p
x-show="toast.title"
x-text="toast.title"
class="font-semibold text-gray-900 dark:text-white"
></p>
<p
x-show="toast.message"
x-text="toast.message"
:class="toast.title ? 'mt-0.5 text-gray-500 dark:text-gray-400' : 'text-gray-700 dark:text-gray-300'"
></p>
<div x-show="toast.action" class="mt-2">
<a
x-show="toast.action && toast.action.href"
:href="toast.action ? toast.action.href : '#'"
x-text="toast.action ? toast.action.label : ''"
class="text-sm font-semibold text-brand-600 transition hover:text-brand-500 dark:text-brand-400"
></a>
<button
x-show="toast.action && ! toast.action.href"
type="button"
@click="$dispatch(toast.action.event || 'toast-action', { id: toast.id }); remove(toast.id)"
x-text="toast.action ? toast.action.label : ''"
class="text-sm font-semibold text-brand-600 transition hover:text-brand-500 dark:text-brand-400 cursor-pointer"
></button>
</div>
</div>
<button
type="button"
@click="remove(toast.id)"
class="-m-1 shrink-0 rounded-md p-1 text-gray-400 opacity-70 transition hover:bg-gray-100 dark:hover:bg-gray-800 hover:opacity-100"
aria-label="Dismiss notification"
>
<x-ui.icon name="x" weight="bold" size="base" />
</button>
</div>
</template>
</div>