Skip to main content

Overlays

Drawer code

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

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

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

@php
    /*
    | Slide-over panel. Open/close by dispatching window events:
    |   $dispatch('open-drawer', 'filters')  /  $dispatch('close-drawer', 'filters')
    |
    | side:     left, right
    | maxWidth: sm, md, lg, xl, 2xl
    |
    | Usage:
    |   <x-ui.button @click="$dispatch('open-drawer', 'filters')">Filters</x-ui.button>
    |   <x-ui.drawer name="filters" title="Filters">
    |       Body...
    |       <x-slot:footer><x-ui.button block>Apply</x-ui.button></x-slot:footer>
    |   </x-ui.drawer>
    */
    $isLeft = $side === 'left';

    $maxWidthClass = [
        'sm' => 'max-w-sm', 'md' => 'max-w-md', 'lg' => 'max-w-lg',
        'xl' => 'max-w-xl', '2xl' => 'max-w-2xl',
    ][$maxWidth] ?? 'max-w-md';

    $sideClass = $isLeft ? 'start-0' : 'end-0';
    $enterStart = $isLeft ? '-translate-x-full' : 'translate-x-full';
    $dialogId = 'drawer-'.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-drawer.window="$event.detail === @js($name) ? open() : null"
    x-on:close-drawer.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 overflow-hidden"
>
    <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="absolute inset-0 bg-gray-950/60 backdrop-blur-sm"
    ></div>

    <div
        x-ref="panel"
        tabindex="-1"
        x-show="show"
        x-transition:enter="transform transition ease-out duration-300" x-transition:enter-start="{{ $enterStart }}" x-transition:enter-end="translate-x-0"
        x-transition:leave="transform transition ease-in duration-200" x-transition:leave-start="translate-x-0" x-transition:leave-end="{{ $enterStart }}"
        class="absolute inset-y-0 {{ $sideClass }} flex w-full {{ $maxWidthClass }} flex-col 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 border-b border-gray-200 px-6 py-4 dark:border-gray-800">
                <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="flex-1 overflow-y-auto 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>