Skip to main content

Actions

Dropdown code

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

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

@props([
    'align' => 'right',
    'width' => '56',
    'variant' => 'default',
])

@php
    /*
    | Click-to-open menu. Put the trigger in the `trigger` slot and menu contents
    | (typically <x-ui.dropdown.item>) in the default slot.
    |
    | align: left, right
    | variant: default, nav — nav attaches flush beneath the trigger for top bars
    |
    | The default panel is teleported to <body> and positioned with `fixed` so it
    | is not clipped by overflow on tables, drawers, or other scroll containers.
    |
    | Usage:
    |   <x-ui.dropdown>
    |       <x-slot:trigger><x-ui.button variant="outline" icon-trailing="caret-down">Options</x-ui.button></x-slot:trigger>
    |       <x-ui.dropdown.item icon="pencil">Edit</x-ui.dropdown.item>
    |   </x-ui.dropdown>
    */
    $isNav = $variant === 'nav';

    $alignClass = match ($align) {
        'left' => 'origin-top-left start-0',
        default => 'origin-top-right end-0',
    };

    $widthClass = match ($width) {
        '48' => 'w-48', '56' => 'w-56', '64' => 'w-64', 'full' => 'w-full',
        default => $width,
    };

    $dropdownId = 'dropdown-'.substr(md5($align.$width.$variant.$slot), 0, 10);

    $rootClass = $isNav
        ? 'relative inline-flex h-16 -mb-px'
        : 'relative inline-block';

    $triggerClass = $isNav
        ? 'flex h-full cursor-pointer items-center border-b-2 px-2 -mx-2 transition-colors'
        : '';

    $panelClass = $isNav
        ? "absolute top-full z-50 mt-0 {$widthClass} {$alignClass} min-w-[10rem] overflow-hidden rounded-b-xl rounded-t-none border border-t-0 border-gray-200/70 bg-white/95 p-1.5 shadow-sm backdrop-blur-lg dark:border-gray-800/70 dark:bg-gray-950/95"
        : "fixed z-50 {$widthClass} {$alignClass} rounded-xl border border-gray-200 bg-white p-1.5 shadow-lg ring-1 ring-black/5 dark:border-gray-800 dark:bg-gray-900";

    $alignStart = $align === 'left';
    $widthIsFull = $width === 'full';
@endphp

@if ($isNav)
    <div x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false" {{ $attributes->merge(['class' => $rootClass]) }}>
        <div
            @click="open = ! open"
            @keydown.enter.prevent="open = ! open"
            @keydown.space.prevent="open = ! open"
            :class="open ? 'border-brand-500 dark:border-brand-400' : 'border-transparent'"
            @class([$triggerClass])
            :aria-expanded="open.toString()"
            aria-controls="{{ $dropdownId }}"
            aria-haspopup="menu"
        >
            {{ $trigger }}
        </div>

        <div
            id="{{ $dropdownId }}"
            x-show="open" x-cloak
            x-transition:enter="transition ease-out duration-150"
            x-transition:enter-start="opacity-0 -translate-y-1"
            x-transition:enter-end="opacity-100 translate-y-0"
            x-transition:leave="transition ease-in duration-100"
            x-transition:leave-start="opacity-100 translate-y-0"
            x-transition:leave-end="opacity-0 -translate-y-1"
            @class([$panelClass])
            @click="open = false"
            role="menu"
        >
            {{ $slot }}
        </div>
    </div>
@else
    <div
        x-data="{
            open: false,
            toggle() {
                this.open = ! this.open;
                if (this.open) {
                    this.$nextTick(() => this.place());
                }
            },
            place() {
                const trigger = this.$refs.trigger;
                const panel = document.getElementById('{{ $dropdownId }}');
                if (! trigger || ! panel) {
                    return;
                }

                const rect = trigger.getBoundingClientRect();
                const gap = 8;
                const width = {{ $widthIsFull ? 'rect.width' : 'panel.offsetWidth || 224' }};
                const height = panel.offsetHeight || 0;
                let left = {{ $alignStart ? 'rect.left' : 'rect.right - width' }};
                left = Math.max(8, Math.min(left, window.innerWidth - width - 8));
                let top = rect.bottom + gap;

                if (top + height > window.innerHeight - 8 && (rect.top - gap - height) >= 8) {
                    top = rect.top - gap - height;
                }

                panel.style.top = top + 'px';
                panel.style.left = left + 'px';
                @if ($widthIsFull)
                    panel.style.width = rect.width + 'px';
                @endif
            },
            onWindowClick(event) {
                if (! this.open) {
                    return;
                }

                const trigger = this.$refs.trigger;
                const panel = document.getElementById('{{ $dropdownId }}');

                if (trigger?.contains(event.target) || panel?.contains(event.target)) {
                    return;
                }

                this.open = false;
            },
        }"
        @click.window="onWindowClick($event)"
        @keydown.escape.window="open = false"
        @scroll.window="open = false"
        {{ $attributes->merge(['class' => $rootClass]) }}
    >
        <div
            x-ref="trigger"
            @click="toggle()"
            @keydown.enter.prevent="toggle()"
            @keydown.space.prevent="toggle()"
            :aria-expanded="open.toString()"
            aria-controls="{{ $dropdownId }}"
            aria-haspopup="menu"
        >
            {{ $trigger }}
        </div>

        <template x-teleport="body">
            <div
                id="{{ $dropdownId }}"
                x-show="open" x-cloak
                x-transition:enter="transition ease-out duration-150"
                x-transition:enter-start="opacity-0 scale-95"
                x-transition:enter-end="opacity-100 scale-100"
                x-transition:leave="transition ease-in duration-100"
                x-transition:leave-start="opacity-100 scale-100"
                x-transition:leave-end="opacity-0 scale-95"
                @class([$panelClass])
                @click="open = false"
                role="menu"
            >
                {{ $slot }}
            </div>
        </template>
    </div>
@endif

resources/views/components/ui/dropdown/item.blade.php

@props([
    'href' => null,
    'icon' => null,
    'variant' => 'default',
    'type' => 'button',
    'active' => false,
])

@php
    /*
    | A row inside <x-ui.dropdown>. Renders an <a> when `href` is set.
    |
    | variant: default, danger
    */
    $color = match (true) {
        $variant === 'danger' => 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/40',
        $active => 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-950/40',
        default => 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800',
    };

    $classes = "flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition cursor-pointer {$color}";
@endphp

@if ($href)
    <a href="{{ $href }}" role="menuitem" @if ($active) aria-current="page" @endif {{ $attributes->merge(['class' => $classes]) }}>
        @if ($icon)<x-ui.icon :name="$icon" size="base" class="opacity-70" />@endif
        {{ $slot }}
    </a>
@else
    <button type="{{ $type }}" role="menuitem" {{ $attributes->merge(['class' => $classes]) }}>
        @if ($icon)<x-ui.icon :name="$icon" size="base" class="opacity-70" />@endif
        {{ $slot }}
    </button>
@endif