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.
This commit is contained in:
Andras Bacsai committed 2026-08-16 19:19:04 +02:00
1 parent 232e2eab51
commit b976d19f25
13 files changed
+274 -299

No files matched your search

-20
View File
@@ -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. */
@@ -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;
@@ -1,69 +0,0 @@
<script>
window.floatingDropdown = function floatingDropdown(config, state = {}) {
state.open = false;
state.positioned = false;
state.toggle = function toggle() {
this.open = !this.open;
this.positioned = false;
if (this.open && config.portal) {
this.$nextTick(() => this.schedulePosition());
}
};
state.close = function close() {
this.open = false;
this.positioned = false;
};
state.schedulePosition = function schedulePosition(panel = null) {
window.requestAnimationFrame(() => this.positionPanel(panel));
};
state.positionPanel = function positionPanel(panel = null) {
const trigger = this.$refs.trigger;
panel ??= document.getElementById(config.panelId);
if (!trigger || !panel) {
return;
}
const gap = 4;
const edge = 12;
const availableWidth = window.innerWidth - (edge * 2);
const availableHeight = window.innerHeight - (edge * 2);
const triggerRect = trigger.getBoundingClientRect();
const desiredWidth = config.matchTriggerWidth
? triggerRect.width
: panel.offsetWidth;
const panelWidth = Math.min(desiredWidth, availableWidth);
const panelHeight = Math.min(panel.scrollHeight, config.maxHeight ?? availableHeight);
const fitsBelow = window.innerHeight - triggerRect.bottom - gap >= panelHeight;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(edge, triggerRect.top - gap - panelHeight);
const alignedLeft = config.align === 'right'
? triggerRect.right - panelWidth
: triggerRect.left;
const left = window.innerWidth < 768
? (window.innerWidth - panelWidth) / 2
: Math.min(Math.max(edge, alignedLeft), window.innerWidth - panelWidth - edge);
panel.style.top = `${top}px`;
panel.style.left = `${left}px`;
panel.style.width = `${panelWidth}px`;
panel.style.maxWidth = `${availableWidth}px`;
if (config.matchTriggerWidth) {
panel.style.minWidth = `${triggerRect.width}px`;
} else {
panel.style.maxHeight = `${availableHeight}px`;
}
this.positioned = true;
};
return state;
};
</script>
@@ -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
</div>
@endif
<div class="relative min-w-0" x-data="floatingDropdown({
panelId: @js($panelId),
portal: @js($portal),
align: 'left',
matchTriggerWidth: true,
maxHeight: 256,
}, {
<div class="relative min-w-0" x-data="{
open: false,
positioned: false,
saving: false,
options: @js(array_values($options)),
value: @if (!$wire) @js($value) @elseif ($live && ! $onChange) @entangle($id).live @else @entangle($id) @endif,
@@ -79,11 +75,47 @@
}
@endif
},
})" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }"
toggle() {
this.open = !this.open;
this.positioned = false;
if (this.open && @js($portal)) {
this.$nextTick(() => 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()">
<button x-ref="trigger" id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="toggle()"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" @if ($tooltip) :title="current" @endif>
@@ -103,7 +135,7 @@
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
x-effect="if (open) schedulePosition($el)" role="listbox">
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
@@ -4,20 +4,13 @@
'multiselectable' => false,
])
@php($panelId = 'table-dropdown-'.uniqid())
<div class="relative"
x-data="floatingDropdown({ panelId: @js($panelId), portal: true, align: 'right', matchTriggerWidth: false })"
@click.outside="close()" @keydown.escape.window="close()"
@resize.window="open && schedulePosition()" @scroll.window="open && schedulePosition()">
<div x-ref="trigger" @click="toggle()">
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
<div @click="open = !open">
{{ $trigger }}
</div>
<div id="{{ $panelId }}" x-show="open" x-cloak
class="listbox-panel floating-dropdown-panel z-[9999]! {{ $panelClass }}" style="position: fixed; visibility: hidden"
:style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-effect="if (open) schedulePosition($el)" role="{{ $role }}"
<div x-show="open" x-cloak
class="listbox-panel absolute top-full! right-0! left-auto! mt-1! {{ $panelClass }}" role="{{ $role }}"
@if ($multiselectable) aria-multiselectable="true" @endif>
{{ $slot }}
</div>
@@ -21,6 +21,45 @@
$currentService = $currentEnvironment && $serviceUuid
? $currentEnvironment->services()->where('uuid', $serviceUuid)->first()
: null;
$currentResource = $currentApplication ?? $currentDatabase ?? $currentService;
$resourceItems = $currentEnvironment
? collect()
->concat($currentEnvironment->applications->map(fn ($application) => [
'type' => 'application',
'resource' => $application,
]))
->concat($currentEnvironment->databases()->map(fn ($database) => [
'type' => 'database',
'resource' => $database,
]))
->concat($currentEnvironment->services->map(fn ($service) => [
'type' => 'service',
'resource' => $service,
]))
->sortBy(fn ($item) => strtolower($item['resource']->name))
->map(fn ($item) => [
'label' => $item['resource']->name,
'href' => match ($item['type']) {
'application' => route('project.application.configuration', [
'project_uuid' => $currentProject->uuid,
'environment_uuid' => $currentEnvironment->uuid,
'application_uuid' => $item['resource']->uuid,
]),
'database' => route('project.database.configuration', [
'project_uuid' => $currentProject->uuid,
'environment_uuid' => $currentEnvironment->uuid,
'database_uuid' => $item['resource']->uuid,
]),
'service' => route('project.service.configuration', [
'project_uuid' => $currentProject->uuid,
'environment_uuid' => $currentEnvironment->uuid,
'service_uuid' => $item['resource']->uuid,
]),
},
'active' => $item['resource']->uuid === $currentResource?->uuid,
])
->values()
: collect();
$storageUuid = request()->route('storage_uuid');
$storages = $storageUuid && $team ? \App\Models\S3Storage::ownedByCurrentTeam()->orderBy('name')->get() : collect();
$currentStorage = $storages->firstWhere('uuid', $storageUuid);
@@ -251,30 +290,20 @@
</div>
@endif
@if ($currentApplication)
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
<span class="flex min-w-0 shrink items-center gap-2 h-8 px-2">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentApplication->name }}</span>
<livewire:project.application.status :application="$currentApplication"
:wire:key="'application-status-'.$currentApplication->uuid" />
</span>
@endif
@if ($currentDatabase)
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
<span class="flex min-w-0 shrink items-center gap-2 h-8 px-2">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentDatabase->name }}</span>
<livewire:project.database.status :database="$currentDatabase"
:wire:key="'database-status-'.$currentDatabase->uuid" />
</span>
@endif
@if ($currentService)
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
<span class="flex min-w-0 shrink items-center gap-2 h-8 px-2">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentService->name }}</span>
<livewire:project.service.status :service="$currentService"
:wire:key="'service-status-'.$currentService->uuid" />
</span>
@if ($currentResource)
<x-breadcrumb-switcher title="Resources" :label="$currentResource->name" :items="$resourceItems">
<x-slot:meta>
@if ($currentApplication)
<livewire:project.application.status :application="$currentApplication"
:wire:key="'application-status-'.$currentApplication->uuid" />
@elseif ($currentDatabase)
<livewire:project.database.status :database="$currentDatabase"
:wire:key="'database-status-'.$currentDatabase->uuid" />
@else
<livewire:project.service.status :service="$currentService"
:wire:key="'service-status-'.$currentService->uuid" />
@endif
</x-slot:meta>
</x-breadcrumb-switcher>
@endif
</div>
-1
View File
@@ -70,7 +70,6 @@
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" />
@endenv
<meta name="csrf-token" content="{{ csrf_token() }}">
@include('components.floating-dropdown-script')
@vite(['resources/js/app.js', 'resources/css/app.css'])
<script>
// Update theme-color meta tag (non-critical, can run async)
@@ -25,137 +25,137 @@
<div @if (! $skip) wire:poll.5000ms="refreshBackupExecutions" @endif
class="application-settings-section-body p-0!">
<div class="data-table deployment-table-scroll">
<div
class="data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]">
<span>Status</span>
<span>Database</span>
<span>Backup path</span>
<span>Finished</span>
<span>Duration</span>
<span>Size</span>
<span>Availability</span>
<span class="text-right">Actions</span>
@if ($executions_count === 0)
<div class="p-4">
<x-empty size="sm" title="No backup executions"
description="Execution history appears here after the schedule runs."
icon-name="browser-terminal" />
</div>
@forelse ($executions as $execution)
@php
$executionStatus = data_get($execution, 'status');
[$executionStatusLabel, $executionStatusType] = match ($executionStatus) {
'success' => data_get($execution, 's3_uploaded') === false
? ['S3 warning', 'warning']
: ['Success', 'success'],
'running' => ['In progress', 'warning'],
'failed' => ['Failed', 'error'],
default => [str($executionStatus)->headline(), 'neutral'],
};
$executionCheckboxes = [];
$deleteActions = [];
@else
<div class="data-table deployment-table-scroll">
<div
class="data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]">
<span>Status</span>
<span>Database</span>
<span>Backup path</span>
<span>Finished</span>
<span>Duration</span>
<span>Size</span>
<span>Availability</span>
<span class="text-right">Actions</span>
</div>
@foreach ($executions as $execution)
@php
$executionStatus = data_get($execution, 'status');
[$executionStatusLabel, $executionStatusType] = match ($executionStatus) {
'success' => data_get($execution, 's3_uploaded') === false
? ['S3 warning', 'warning']
: ['Success', 'success'],
'running' => ['In progress', 'warning'],
'failed' => ['Failed', 'error'],
default => [str($executionStatus)->headline(), 'neutral'],
};
$executionCheckboxes = [];
$deleteActions = [];
if (! data_get($execution, 'local_storage_deleted', false)) {
$deleteActions[] = 'This backup will be permanently deleted from local storage.';
}
if (! data_get($execution, 'local_storage_deleted', false)) {
$deleteActions[] = 'This backup will be permanently deleted from local storage.';
}
if (data_get($execution, 's3_uploaded') === true
&& ! data_get($execution, 's3_storage_deleted', false)) {
$executionCheckboxes[] = [
'id' => 'delete_backup_s3',
'label' => 'Delete the selected backup permanently from S3 Storage',
];
}
if (data_get($execution, 's3_uploaded') === true
&& ! data_get($execution, 's3_storage_deleted', false)) {
$executionCheckboxes[] = [
'id' => 'delete_backup_s3',
'label' => 'Delete the selected backup permanently from S3 Storage',
];
}
if (empty($deleteActions)) {
$deleteActions[] = 'This backup execution record will be deleted.';
}
@endphp
<div wire:key="{{ data_get($execution, 'id') }}"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.06]">
<div class="data-table-row backup-executions-table-grid min-h-14 px-4 py-2.5">
<div class="flex items-center gap-2">
<x-status-badge :status="$executionStatusLabel"
:type="$executionStatusType" />
@if ($executionStatus === 'running')
<x-loading />
@endif
</div>
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
{{ data_get($execution, 'database_name', 'N/A') }}
</div>
<div class="flex min-w-0 items-center gap-1">
<code class="select-all truncate font-mono text-[11px] text-neutral-600 dark:text-fg-dim"
title="Backup path: {{ data_get($execution, 'filename', 'N/A') }}">{{ data_get($execution, 'filename', 'N/A') }}</code>
<x-copy-button :value="data_get($execution, 'filename', '')" label="Copy backup path" />
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
@if ($executionStatus === 'running')
Running now
@else
{{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->diffForHumans() }}
@endif
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
{{ calculateDuration(
data_get($execution, 'created_at'),
$executionStatus === 'running' ? now() : data_get($execution, 'finished_at'),
) }}
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
{{ data_get($execution, 'size') ? formatBytes(data_get($execution, 'size')) : '-' }}
</div>
<div class="flex flex-wrap items-center gap-1.5">
<x-status-badge label="Local"
:status="data_get($execution, 'local_storage_deleted', false) ? 'Deleted' : 'Available'"
:type="data_get($execution, 'local_storage_deleted', false) ? 'neutral' : 'success'" />
@if (data_get($execution, 's3_uploaded') !== null)
<x-status-badge label="S3"
:status="data_get($execution, 's3_storage_deleted', false)
? 'Deleted'
: (data_get($execution, 's3_uploaded') ? 'Available' : 'Failed')"
:type="data_get($execution, 's3_storage_deleted', false)
? 'neutral'
: (data_get($execution, 's3_uploaded') ? 'success' : 'error')" />
@endif
</div>
<div class="flex items-center justify-end gap-1">
@if ($executionStatus === 'success')
<button type="button" class="icon-button shrink-0"
x-on:click="download_file('{{ data_get($execution, 'id') }}')"
title="Download backup" aria-label="Download backup">
<x-reicon name="upload" class="size-3.5 rotate-180" />
</button>
@endif
<x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
submitAction="deleteBackup({{ data_get($execution, 'id') }})"
:checkboxes="$executionCheckboxes" :actions="$deleteActions"
confirmationText="{{ data_get($execution, 'filename') }}"
confirmationLabel="Enter the backup filename to confirm."
shortConfirmationLabel="Backup Filename">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Delete backup" aria-label="Delete backup">
<x-reicon name="trash" class="size-3.5" />
if (empty($deleteActions)) {
$deleteActions[] = 'This backup execution record will be deleted.';
}
@endphp
<div wire:key="{{ data_get($execution, 'id') }}"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.06]">
<div class="data-table-row backup-executions-table-grid min-h-14 px-4 py-2.5">
<div class="flex items-center gap-2">
<x-status-badge :status="$executionStatusLabel"
:type="$executionStatusType" />
@if ($executionStatus === 'running')
<x-loading />
@endif
</div>
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
{{ data_get($execution, 'database_name', 'N/A') }}
</div>
<div class="flex min-w-0 items-center gap-1">
<code class="select-all truncate font-mono text-[11px] text-neutral-600 dark:text-fg-dim"
title="Backup path: {{ data_get($execution, 'filename', 'N/A') }}">{{ data_get($execution, 'filename', 'N/A') }}</code>
<x-copy-button :value="data_get($execution, 'filename', '')" label="Copy backup path" />
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
@if ($executionStatus === 'running')
Running now
@else
{{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->diffForHumans() }}
@endif
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
{{ calculateDuration(
data_get($execution, 'created_at'),
$executionStatus === 'running' ? now() : data_get($execution, 'finished_at'),
) }}
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
{{ data_get($execution, 'size') ? formatBytes(data_get($execution, 'size')) : '-' }}
</div>
<div class="flex flex-wrap items-center gap-1.5">
<x-status-badge label="Local"
:status="data_get($execution, 'local_storage_deleted', false) ? 'Deleted' : 'Available'"
:type="data_get($execution, 'local_storage_deleted', false) ? 'neutral' : 'success'" />
@if (data_get($execution, 's3_uploaded') !== null)
<x-status-badge label="S3"
:status="data_get($execution, 's3_storage_deleted', false)
? 'Deleted'
: (data_get($execution, 's3_uploaded') ? 'Available' : 'Failed')"
:type="data_get($execution, 's3_storage_deleted', false)
? 'neutral'
: (data_get($execution, 's3_uploaded') ? 'success' : 'error')" />
@endif
</div>
<div class="flex items-center justify-end gap-1">
@if ($executionStatus === 'success')
<button type="button" class="icon-button shrink-0"
x-on:click="download_file('{{ data_get($execution, 'id') }}')"
title="Download backup" aria-label="Download backup">
<x-reicon name="upload" class="size-3.5 rotate-180" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
<x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
submitAction="deleteBackup({{ data_get($execution, 'id') }})"
:checkboxes="$executionCheckboxes" :actions="$deleteActions"
confirmationText="{{ data_get($execution, 'filename') }}"
confirmationLabel="Enter the backup filename to confirm."
shortConfirmationLabel="Backup Filename">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Delete backup" aria-label="Delete backup">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
</div>
@if (data_get($execution, 'message'))
<div class="border-t border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-white/[0.06] dark:bg-white/[0.02]">
<pre
class="max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-neutral-100 p-3 font-mono text-xs leading-5 text-neutral-700 dark:bg-black/20 dark:text-fg-dim">{{ data_get($execution, 'message') }}</pre>
</div>
@endif
</div>
@if (data_get($execution, 'message'))
<div class="border-t border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-white/[0.06] dark:bg-white/[0.02]">
<pre
class="max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-neutral-100 p-3 font-mono text-xs leading-5 text-neutral-700 dark:bg-black/20 dark:text-fg-dim">{{ data_get($execution, 'message') }}</pre>
</div>
@endif
@endforeach
</div>
@empty
<div class="p-4">
<x-empty size="sm" title="No backup executions"
description="Execution history appears here after the schedule runs."
icon-name="browser-terminal" />
</div>
@endforelse
</div>
@if ($executions_count > 0)
<div
class="flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
<span>
@@ -44,3 +44,16 @@ test('resource headings do not duplicate database and service breadcrumbs', func
expect($headings->implode("\n"))->not->toContain("@teleport('#server-topbar-context')");
});
test('top breadcrumb lets users switch between resources in the current environment', function () {
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
expect($breadcrumb)
->toContain('$currentEnvironment->applications')
->toContain('$currentEnvironment->databases()')
->toContain('$currentEnvironment->services')
->toContain('<x-breadcrumb-switcher title="Resources"')
->toContain("'application' => route('project.application.configuration'")
->toContain("'database' => route('project.database.configuration'")
->toContain("'service' => route('project.service.configuration'");
});
@@ -43,3 +43,10 @@ it('shows the backup path directly on every execution', function () {
->toContain('class="select-all truncate font-mono text-[11px]')
->not->toContain('backup-executions-table-grid border-t');
});
it('keeps backup execution actions in the normal table flow', function () {
$executions = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($executions)
->not->toContain('sticky right-0');
});
+7 -2
View File
@@ -42,10 +42,13 @@ it('insets empty states nested inside a flush settings section wrapper', functio
expect($css)->toContain('.application-settings-section-body.is-flush > div:has(> .empty-state:only-child)');
});
it('insets the backup executions empty state from the table header', function () {
it('renders the backup executions empty state without a scrollable table', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($view)->toMatch('/@empty\s*<div class="p-4">\s*<x-empty size="sm" title="No backup executions"/');
expect($view)
->toMatch('/@if \(\$executions_count === 0\)\s*<div class="p-4">\s*<x-empty size="sm" title="No backup executions"/')
->toMatch('/@else\s*<div class="data-table deployment-table-scroll">/')
->not->toContain('@forelse ($executions as $execution)');
});
it('keeps backup execution actions compact', function () {
@@ -55,6 +58,8 @@ it('keeps backup execution actions compact', function () {
expect($css)
->toContain('grid-template-columns: 6.5rem minmax(7rem, 0.8fr) minmax(14rem, 1.5fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;')
->and($view)
->toContain('<span class="bg-base sticky right-0 z-10 text-right">Actions</span>')
->toContain('class="bg-base sticky right-0 z-10 flex items-center justify-end gap-1"')
->toContain('title="Download backup" aria-label="Download backup"')
->toContain('<x-reicon name="upload" class="size-3.5 rotate-180" />')
->toContain('title="Delete backup" aria-label="Delete backup"')
+11 -16
View File
@@ -39,30 +39,25 @@ test('listbox component uses shared trigger label truncation', function () {
->not->toContain('class="truncate" x-text="current"');
});
test('portaled listboxes center in the mobile viewport', function () {
test('listboxes stay in their local Alpine scope by default', function () {
$component = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
$html = Blade::render('<x-forms.listbox id="region" :options="[]" :wire="false" />');
expect($html)
->toContain('floatingDropdown(')
expect($component)->toContain("'portal' => false")
->and($html)
->toContain('x-data="{')
->toContain('x-ref="panel"')
->not->toContain('x-teleport="body"')
->toContain("align: 'left'")
->toContain('matchTriggerWidth: true')
->toContain('x-show="open"')
->toContain('schedulePosition($el)')
->toContain("visibility: positioned ? 'visible' : 'hidden'")
->not->toContain('positionPanel(panel = null)')
->not->toContain('x-show="open && positioned"');
->not->toContain('floatingDropdown(')
->not->toContain('style="position: fixed');
});
test('legacy floating panels have a centered mobile fallback', function () {
test('mobile listbox panels stay anchored to their trigger', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('@media (max-width: 767px)')
->toContain('.listbox-panel:not([style*="position: fixed"])')
->toContain('position: fixed !important;')
->toContain('left: 50% !important;')
->toContain('transform: translate(-50%, -50%) !important;');
->not->toContain('.listbox-panel:not([style*="position: fixed"])')
->not->toContain('transform: translate(-50%, -50%) !important;');
});
test('searchable listbox component uses shared trigger label truncation', function () {
+6 -18
View File
@@ -23,33 +23,21 @@ it('renders the standard table toolbar controls', function () {
->toContain('table-search')
->toContain('wire:model.live="search"')
->not->toContain('x-teleport="body"')
->toContain('floatingDropdown(')
->toContain('position: fixed')
->toContain('floating-dropdown-panel')
->not->toContain('floatingDropdown(')
->not->toContain('position: fixed')
->toContain('absolute top-full')
->toContain('x-show="open"')
->toContain('schedulePosition($el)')
->toContain("visibility: positioned ? 'visible' : 'hidden'")
->not->toContain('positionPanel() {')
->not->toContain('x-show="open && positioned"')
->toContain('aria-multiselectable="true"')
->toContain('Reset filters')
->toContain('wire:click="clearFilters"')
->toContain('Sort');
});
it('centralizes floating dropdown positioning in an Alpine data provider', function () {
it('does not load a global floating dropdown positioning provider', function () {
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
$provider = file_get_contents(resource_path('views/components/floating-dropdown-script.blade.php'));
expect($layout)->toContain("@include('components.floating-dropdown-script')")
->and($provider)
->toContain('window.floatingDropdown = function floatingDropdown')
->toContain('window.requestAnimationFrame')
->toContain('positionPanel(panel = null)')
->toContain('const desiredWidth = config.matchTriggerWidth')
->toContain('? triggerRect.width')
->not->toContain('? Math.max(triggerRect.width, panel.offsetWidth)')
->toContain('window.innerWidth < 768');
expect($layout)->not->toContain("@include('components.floating-dropdown-script')")
->and(File::exists(resource_path('views/components/floating-dropdown-script.blade.php')))->toBeFalse();
});
it('renders the standard table loading overlay', function () {