Skip to main content

Overlays

Modal code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/modal.blade.php

@props([
    'name',
    'title' => null,
    'description' => null,
    'maxWidth' => 'lg',
    'show' => false,
    'dismissible' => true,
    'closeOnBackdrop' => true,
    'closeOnEscape' => true,
])

@php
    /*
    | Accessible modal dialog. Open/close it by dispatching window events:
    |   $dispatch('open-modal', 'my-modal')  /  $dispatch('close-modal', 'my-modal')
    |
    | maxWidth: sm, md, lg, xl, 2xl, 3xl, 4xl, full
    |
    | Usage:
    |   <x-ui.button @click="$dispatch('open-modal', 'confirm')">Open</x-ui.button>
    |   <x-ui.modal name="confirm" title="Delete item?">
    |       Body...
    |       <x-slot:footer><x-ui.button variant="danger">Delete</x-ui.button></x-slot:footer>
    |   </x-ui.modal>
    */
    $maxWidthClass = [
        'sm' => 'sm:max-w-sm', 'md' => 'sm:max-w-md', 'lg' => 'sm:max-w-lg',
        'xl' => 'sm:max-w-xl', '2xl' => 'sm:max-w-2xl', '3xl' => 'sm:max-w-3xl',
        '4xl' => 'sm:max-w-4xl', 'full' => 'sm:max-w-full',
    ][$maxWidth] ?? 'sm:max-w-lg';

    $dialogId = 'modal-'.md5($name);
    $titleId = $dialogId.'-title';
    $descriptionId = $dialogId.'-description';
@endphp

<div
    x-data="{
        show: @js($show),
        previouslyFocused: null,
        open() {
            this.previouslyFocused = document.activeElement;
            this.show = true;
            this.$nextTick(() => this.focusFirst());
        },
        close() {
            this.show = false;
            this.$nextTick(() => this.previouslyFocused?.focus?.());
        },
        focusable() {
            return Array.from(this.$refs.panel?.querySelectorAll('a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\'-1\'])') ?? [])
                .filter((el) => ! el.hasAttribute('disabled') && ! el.getAttribute('aria-hidden'));
        },
        focusFirst() {
            (this.$refs.initial ?? this.focusable()[0] ?? this.$refs.panel)?.focus?.();
        },
        trap(event) {
            if (! this.show || event.key !== 'Tab') { return; }
            const items = this.focusable();
            if (items.length === 0) { event.preventDefault(); return; }
            const first = items[0];
            const last = items[items.length - 1];
            if (event.shiftKey && document.activeElement === first) {
                event.preventDefault();
                last.focus();
            } else if (! event.shiftKey && document.activeElement === last) {
                event.preventDefault();
                first.focus();
            }
        },
    }"
    x-on:open-modal.window="$event.detail === @js($name) ? open() : null"
    x-on:close-modal.window="$event.detail === @js($name) ? close() : null"
    x-on:keydown.escape.window="@js($dismissible && $closeOnEscape) ? close() : null"
    x-on:keydown.tab.capture="trap($event)"
    x-effect="document.body.classList.toggle('overflow-hidden', show)"
    x-show="show"
    x-cloak
    class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto px-4 py-6"
>
    <div
        x-show="show"
        x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
        x-transition:leave="ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
        @if ($dismissible && $closeOnBackdrop) @click="close()" @endif
        class="fixed inset-0 bg-gray-950/60 backdrop-blur-sm"
    ></div>

    <div
        x-ref="panel"
        tabindex="-1"
        x-show="show"
        x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
        x-transition:leave="ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
        class="relative w-full {{ $maxWidthClass }} rounded-2xl bg-white shadow-2xl dark:bg-gray-900"
        role="dialog"
        aria-modal="true"
        @if ($title) aria-labelledby="{{ $titleId }}" @endif
        @if ($description) aria-describedby="{{ $descriptionId }}" @endif
    >
        @if ($title || $description || $dismissible)
            <div class="flex items-start justify-between gap-4 px-6 pt-6">
                <div>
                    @if ($title)
                        <h3 id="{{ $titleId }}" class="text-lg font-semibold text-gray-900 dark:text-white">{{ $title }}</h3>
                    @endif
                    @if ($description)
                        <p id="{{ $descriptionId }}" class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $description }}</p>
                    @endif
                </div>
                @if ($dismissible)
                    <button x-ref="initial" type="button" @click="close()" class="-m-1.5 rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800" aria-label="Close">
                        <x-ui.icon name="x" weight="bold" size="lg" />
                    </button>
                @endif
            </div>
        @endif

        <div class="px-6 py-5 text-sm text-gray-600 dark:text-gray-300">
            {{ $slot }}
        </div>

        @isset($footer)
            <div class="flex items-center justify-end gap-3 border-t border-gray-200 px-6 py-4 dark:border-gray-800">
                {{ $footer }}
            </div>
        @endisset
    </div>
</div>