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