From b976d19f25e70d0ad8e3d6b24bf675ab214798e7 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Sun, 16 Aug 2026 19:19:04 +0200
Subject: [PATCH] fix(ui): improve dropdown positioning and empty backup states
Refactor dropdown behavior, add sorted resource selection to breadcrumbs, and show an empty state when no backup executions exist.
---
resources/css/app.css | 20 --
resources/views/components/dropdown.blade.php | 11 +-
.../floating-dropdown-script.blade.php | 69 -----
.../views/components/forms/listbox.blade.php | 54 +++-
.../views/components/table/dropdown.blade.php | 15 +-
.../views/components/top-breadcrumb.blade.php | 79 ++++--
resources/views/layouts/base.blade.php | 1 -
.../database/backup-executions.blade.php | 244 +++++++++---------
.../Feature/BreadcrumbSelectionStyleTest.php | 13 +
tests/Feature/DatabaseBackupsLayoutTest.php | 7 +
tests/Feature/EmptyStateComponentTest.php | 9 +-
.../Feature/ListboxTriggerTruncationTest.php | 27 +-
tests/Feature/StandardTableComponentsTest.php | 24 +-
13 files changed, 274 insertions(+), 299 deletions(-)
delete mode 100644 resources/views/components/floating-dropdown-script.blade.php
diff --git a/resources/css/app.css b/resources/css/app.css
index 62f718b79..fcb97b814 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -1939,27 +1939,7 @@ html[data-theme="custom"] textarea:disabled {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
-.floating-dropdown-panel {
- min-width: 0;
-}
-
-/* Older menus still render inside their page container. On mobile, keep
- those panels fully visible until they are migrated to the portaled
- dropdown component. Portaled panels carry their own fixed coordinates. */
@media (max-width: 767px) {
- .listbox-panel:not([style*="position: fixed"]) {
- position: fixed !important;
- top: 50% !important;
- right: auto !important;
- left: 50% !important;
- width: max-content;
- max-width: calc(100vw - 1.5rem) !important;
- max-height: calc(100dvh - 1.5rem) !important;
- box-sizing: border-box;
- transform: translate(-50%, -50%) !important;
- z-index: 9999 !important;
- }
-
/* The mobile top bar's backdrop filter makes it the containing block for
fixed descendants. Keep this menu anchored below its trigger instead
of centering it within the short top bar. */
diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php
index d466f3907..b48b04143 100644
--- a/resources/views/components/dropdown.blade.php
+++ b/resources/views/components/dropdown.blade.php
@@ -25,10 +25,13 @@
const triggerRect = this.$refs.trigger.getBoundingClientRect();
const panelRect = this.$refs.panel.getBoundingClientRect();
const viewportPadding = 8;
- const left = Math.max(
- viewportPadding,
- (window.innerWidth - panelRect.width) / 2,
- );
+ let left = triggerRect.left;
+
+ if ((left + panelRect.width + viewportPadding) > window.innerWidth) {
+ left = window.innerWidth - panelRect.width - viewportPadding;
+ }
+
+ left = Math.max(viewportPadding, left);
let top = triggerRect.bottom + 4;
const maxTop = window.innerHeight - panelRect.height - viewportPadding;
diff --git a/resources/views/components/floating-dropdown-script.blade.php b/resources/views/components/floating-dropdown-script.blade.php
deleted file mode 100644
index 81381c5cd..000000000
--- a/resources/views/components/floating-dropdown-script.blade.php
+++ /dev/null
@@ -1,69 +0,0 @@
-
diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php
index 22e1865de..bfcb4437e 100644
--- a/resources/views/components/forms/listbox.blade.php
+++ b/resources/views/components/forms/listbox.blade.php
@@ -14,7 +14,7 @@
'value' => null, // initial value when wire=false
'disabled' => false,
'tooltip' => true,
- 'portal' => true,
+ 'portal' => false,
'preserveValue' => false,
])
@@ -43,13 +43,9 @@
@endif
@endif
-
requestAnimationFrame(() => this.positionPanel()));
+ }
+ },
+ positionPanel(panel = null) {
+ const trigger = this.$refs.trigger;
+ panel ??= document.getElementById(@js($panelId));
+ if (!trigger || !panel) return;
+
+ const gap = 4;
+ const edge = 12;
+ const triggerRect = trigger.getBoundingClientRect();
+ const panelWidth = Math.min(
+ Math.max(triggerRect.width, panel.offsetWidth),
+ window.innerWidth - (edge * 2),
+ );
+ const panelHeight = Math.min(panel.scrollHeight, 256);
+ const fitsBelow = window.innerHeight - triggerRect.bottom - gap >= panelHeight;
+ const top = fitsBelow
+ ? triggerRect.bottom + gap
+ : Math.max(edge, triggerRect.top - gap - panelHeight);
+ const left = Math.min(
+ Math.max(edge, triggerRect.left),
+ window.innerWidth - panelWidth - edge,
+ );
+
+ panel.style.top = `${top}px`;
+ panel.style.left = `${left}px`;
+ panel.style.width = `${panelWidth}px`;
+ panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`;
+ panel.style.minWidth = `${triggerRect.width}px`;
+ this.positioned = true;
+ },
+ }" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }"
{{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@if ($preserveValue) wire:ignore @endif
- @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && schedulePosition()">
+ @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()">