Skip to main content

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>