fix(ui): refine overlays, toast actions, and save-state feedback

Improve overlay stacking and positioning, add toast copying, standardize domain labels, and animate unsaved changes while saving.
This commit is contained in:
Andras Bacsai
2026-08-11 12:08:44 +02:00
parent 41b3ed95aa
commit 1a8a4bb5ef
15 changed files with 133 additions and 36 deletions
+1 -1
View File
@@ -163,7 +163,7 @@
}
@utility auth-tooltip {
@apply fixed z-[99] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10;
@apply fixed z-[10000] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10;
}
@utility alert-success {
@@ -43,15 +43,17 @@
<div class="min-w-0">
<x-forms.listbox id="{{ $id }}-protocol" label="Protocol" :wire="false" value="https"
x-model="scheme" portal :options="[
['value' => 'https', 'label' => 'HTTPS'],
['value' => 'http', 'label' => 'HTTP'],
['value' => 'https', 'label' => 'https'],
['value' => 'http', 'label' => 'http'],
]" />
</div>
<div class="min-w-0">
<label for="{{ $id }}" class="mb-1.5 block text-sm font-medium">
Domain <x-highlighted text="*" />
</label>
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}" class="mb-0! flex items-center gap-1.5 leading-4">
Domain <x-highlighted text="*" />
</label>
</div>
<input id="{{ $id }}" type="text" class="input" x-model="host" placeholder="app.example.com"
autocomplete="off" required />
@error($errorId ?? $id)
@@ -60,13 +62,17 @@
</div>
<div class="min-w-0">
<label for="{{ $id }}-port" class="mb-1.5 block text-sm font-medium">Port</label>
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}-port" class="mb-0! flex items-center gap-1.5 leading-4">Port</label>
</div>
<input id="{{ $id }}-port" type="number" class="input" x-model="port" placeholder="3000"
min="1" max="65535" inputmode="numeric" />
</div>
<div class="min-w-0 sm:col-span-3">
<label for="{{ $id }}-path" class="mb-1.5 block text-sm font-medium">Path</label>
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}-path" class="mb-0! flex items-center gap-1.5 leading-4">Path</label>
</div>
<input id="{{ $id }}-path" type="text" class="input" x-model="path" placeholder="/api/v3"
autocomplete="off" />
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
+2 -2
View File
@@ -94,7 +94,7 @@
}
}" @pointerdown.window="closeWhenPointerIsOutside($event)" @keydown.window.escape="close"
@resize.window="open && position()" @scroll.window="open && position()"
{{ $attributes->merge(['class' => 'relative inline-block align-middle']) }}>
{{ $attributes->merge(['class' => 'relative inline-flex align-middle']) }}>
{{-- button (not div) so label-for associations do not steal the click on mobile --}}
<button type="button" x-ref="trigger" data-icon-tooltip-ignore
@class([
@@ -122,7 +122,7 @@
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
:style="style"
class="info-helper-popup fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))] whitespace-normal"
class="info-helper-popup fixed z-[10000] w-max max-w-[min(20rem,calc(100vw-2rem))] whitespace-normal"
@mouseenter="cancelHide()" @mouseleave="hide()" @focusout="closeWhenFocusLeaves()" @click.stop>
<div class="px-3 py-2.5 text-[13px] leading-5">
{!! $helper !!}
@@ -71,6 +71,6 @@
<div x-ref="tooltip" x-show="visible" x-cloak role="tooltip" x-text="text"
:style="`left: ${x}px; top: ${y}px;`"
:class="[below ? '' : '-translate-y-full', positioned ? 'visible' : 'invisible']"
class="pointer-events-none fixed z-[100] whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
class="pointer-events-none fixed z-[10000] whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
</div>
</div>
+1 -1
View File
@@ -221,6 +221,6 @@
</div>
<div x-show="collapsed && tooltip.show" x-cloak x-transition.opacity.duration.100ms
:style="`left: ${tooltip.x}px; top: ${tooltip.y}px;`"
class="fixed z-[100] -translate-y-1/2 px-2 py-1 text-xs font-medium rounded-lg bg-neutral-900 dark:bg-raised text-white whitespace-nowrap pointer-events-none shadow-lg border border-neutral-700 dark:border-white/10"
class="fixed z-[10000] -translate-y-1/2 px-2 py-1 text-xs font-medium rounded-lg bg-neutral-900 dark:bg-raised text-white whitespace-nowrap pointer-events-none shadow-lg border border-neutral-700 dark:border-white/10"
x-text="tooltip.text"></div>
</nav>
@@ -4,6 +4,7 @@
'compactAfter' => null,
'compactStorageKey' => null,
'compactStoragePrefix' => null,
'position' => 'bottom-right',
])
<div x-data="{
@@ -50,19 +51,21 @@
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="translate-y-0 opacity-100"
x-transition:leave-end="translate-y-3 opacity-0"
class="fixed bottom-4 right-4 z-999">
<button x-show="iconOnly" type="button" @click="restore()" aria-label="Restore warning"
class="flex rounded-lg p-2"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
@isset($icon)
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
{{ $icon }}
</span>
@endisset
</button>
class="fixed right-4 z-999 {{ $position === 'top-right' ? 'top-16' : 'bottom-4' }}">
<template x-if="iconOnly">
<button type="button" @click="restore()" aria-label="Restore warning" class="flex rounded-lg p-2"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
@isset($icon)
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
{{ $icon }}
</span>
@endisset
</button>
</template>
<div x-show="!iconOnly" class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
<template x-if="!iconOnly">
<div class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
:class="compact
? 'w-[calc(100vw-2rem)] cursor-pointer sm:w-auto sm:max-w-[calc(100vw-2rem)]'
: 'w-[calc(100vw-2rem)] max-w-sm'"
@@ -90,4 +93,5 @@
<x-reicon name="x" class="size-3.5" />
</button>
</div>
</template>
</div>
+19 -6
View File
@@ -6,7 +6,7 @@
type: options.type ?? 'default',
message,
description: options.description ?? '',
position: options.position ?? 'top-center',
position: options.position ?? 'bottom-right',
html: options.html ?? '',
},
}));
@@ -17,9 +17,9 @@
<template x-teleport="body">
<ul x-data="{
toasts: [],
position: 'top-center',
position: 'bottom-right',
addToast(event) {
this.position = event.detail.position || 'top-center';
this.position = event.detail.position || 'bottom-right';
const toast = {
id: `toast-${Math.random().toString(16).slice(2)}`,
@@ -29,6 +29,8 @@
type: event.detail.type,
html: event.detail.html ? window.sanitizeHTML(event.detail.html) : '',
timeout: null,
copied: false,
copiedTimeout: null,
};
this.toasts.unshift(toast);
@@ -55,12 +57,21 @@
resumeToast(toast) {
this.scheduleToast(toast);
},
async copyToast(toast) {
await navigator.clipboard.writeText(toast.description);
toast.copied = true;
clearTimeout(toast.copiedTimeout);
toast.copiedTimeout = setTimeout(() => {
toast.copied = false;
}, 2000);
},
removeToast(id) {
const toast = this.toasts.find(item => item.id === id);
if (!toast) return;
toast.visible = false;
clearTimeout(toast.timeout);
clearTimeout(toast.copiedTimeout);
setTimeout(() => {
this.toasts = this.toasts.filter(item => item.id !== id);
}, 150);
@@ -124,13 +135,15 @@
</template>
<button type="button" x-show="toast.description && !toast.html"
@click="navigator.clipboard.writeText(toast.description)" title="Copy details"
class="absolute right-10 top-2.5 flex size-7 items-center justify-center rounded-md text-neutral-400 opacity-0 transition-colors hover:bg-black/5 hover:text-neutral-700 group-hover:opacity-100 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<svg class="size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none"
@click="copyToast(toast)" :title="toast.copied ? 'Copied' : 'Copy details'"
class="absolute right-10 top-2.5 flex size-7 items-center justify-center rounded-md text-neutral-400 opacity-0 transition-colors hover:bg-black/5 hover:text-neutral-700 group-hover:opacity-100 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
:class="{ 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400': toast.copied }">
<svg x-show="!toast.copied" class="size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.7" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8.25 7.5V6a2.25 2.25 0 012.25-2.25h7.5A2.25 2.25 0 0120.25 6v7.5A2.25 2.25 0 0118 15.75h-1.5m-8.25-8.25H6A2.25 2.25 0 003.75 9.75v7.5A2.25 2.25 0 006 19.5h7.5a2.25 2.25 0 002.25-2.25V15m-7.5-7.5h5.25A2.25 2.25 0 0115.75 9.75V15" />
</svg>
<x-reicon name="check" x-show="toast.copied" class="size-3.5" />
</button>
<button type="button" @click="removeToast(toast.id)" aria-label="Dismiss"
@@ -41,7 +41,7 @@
window.removeEventListener('resize', this.updateKeyboardInset);
},
}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" wire:dirty.class="is-dirty"
wire:loading.class="!opacity-0 !translate-y-6 !pointer-events-none"
wire:loading.class="is-saving"
@keydown.enter.window="
if ($el.classList.contains('is-dirty') &&
!$event.repeat &&
@@ -56,7 +56,7 @@
}
"
@if ($targets) wire:target="{{ $targets }}" @endif
class="pointer-events-none fixed inset-x-3 bottom-[calc(var(--keyboard-inset,0px)+max(1.5rem,env(safe-area-inset-bottom,0px)+0.75rem))] z-[1000] flex max-w-full translate-y-6 flex-col items-stretch gap-2 rounded-2xl border border-neutral-200 bg-white py-2.5 pr-2.5 pl-4 opacity-0 shadow-modal transition-[opacity,transform] duration-200 ease-out delay-0 dark:border-white/10 dark:bg-surface [&.is-dirty]:pointer-events-auto [&.is-dirty]:translate-y-0 [&.is-dirty]:opacity-100 [&.is-dirty]:delay-300 sm:inset-x-auto sm:left-1/2 sm:bottom-6 sm:w-max sm:max-w-none sm:-translate-x-1/2 sm:flex-row sm:items-center sm:gap-8 sm:py-2 sm:pl-5 sm:pr-2">
class="pointer-events-none fixed inset-x-3 bottom-[calc(var(--keyboard-inset,0px)+max(1.5rem,env(safe-area-inset-bottom,0px)+0.75rem))] z-[1000] flex max-w-full translate-y-6 scale-95 flex-col items-stretch gap-2 rounded-2xl border border-neutral-200 bg-white py-2.5 pr-2.5 pl-4 opacity-0 shadow-modal transition-[opacity,transform,scale] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] delay-0 dark:border-white/10 dark:bg-surface [&.is-dirty]:pointer-events-auto [&.is-dirty]:translate-y-0 [&.is-dirty]:scale-100 [&.is-dirty]:opacity-100 [&.is-dirty]:delay-300 [&.is-saving]:pointer-events-none [&.is-saving]:translate-y-6 [&.is-saving]:scale-95 [&.is-saving]:opacity-0 [&.is-saving]:duration-200 [&.is-saving]:ease-in [&.is-saving]:delay-0 sm:inset-x-auto sm:left-1/2 sm:bottom-6 sm:w-max sm:max-w-none sm:-translate-x-1/2 sm:flex-row sm:items-center sm:gap-8 sm:py-2 sm:pl-5 sm:pr-2">
<span class="text-[13px] font-semibold leading-snug text-neutral-800 dark:text-fg sm:whitespace-nowrap">{{ $label }}</span>
<div class="flex shrink-0 items-center justify-end gap-2">
<button type="button" onclick="window.location.reload()"
@@ -37,7 +37,7 @@
@endphp
<div wire:key="configuration-warning-{{ $currentConfigurationHash }}"
x-data="{ configurationDiffModalOpen: false, expandedRows: {} }">
<x-popup-small :compact-after="5000" :compact-storage-key="$compactStorageKey"
<x-popup-small position="top-right" :compact-after="5000" :compact-storage-key="$compactStorageKey"
:compact-storage-prefix="$compactStoragePrefix">
<x-slot:title>
The latest configuration has not been applied
+4
View File
@@ -1301,6 +1301,10 @@ it('uses segmented fields when adding and editing application domains', function
->toContain("scheme: 'https'")
->toContain('<x-forms.listbox id="{{ $id }}-protocol"')
->not->toContain('<select id="{{ $id }}-protocol"')
->toContain("['value' => 'https', 'label' => 'https']")
->toContain("['value' => 'http', 'label' => 'http']")
->toContain('class="mb-1.5 flex h-4 w-full items-center gap-1.5"')
->not->toContain('class="mb-1.5 block text-sm font-medium"')
->toContain('min="1"')
->toContain('max="65535"');
});
@@ -75,6 +75,7 @@ it('renders the changed configuration labels without a second backend request',
expect($view)
->toContain(':compact-after="5000"')
->toContain('position="top-right"')
->toContain(':compact-storage-key="$compactStorageKey"')
->toContain('wire:key="configuration-warning-{{ $currentConfigurationHash }}"')
->toContain('x-on:click="configurationDiffModalOpen = true"')
@@ -85,6 +86,7 @@ it('supports timed compact popup notifications', function () {
$view = file_get_contents(resource_path('views/components/popup-small.blade.php'));
expect($view)
->toContain("\$position === 'top-right' ? 'top-16' : 'bottom-4'")
->toContain('compactAfter')
->toContain('compactStorageKey')
->toContain("localStorage.setItem(this.storageKey, 'compact')")
@@ -94,8 +96,10 @@ it('supports timed compact popup notifications', function () {
->toContain('compact = true')
->toContain('@click="restore()"')
->toContain('@click.stop="minimizeToIcon()"')
->toContain('x-show="iconOnly"')
->toContain('x-show="!iconOnly"')
->toContain('<template x-if="iconOnly">')
->toContain('<template x-if="!iconOnly">')
->not->toContain('<button x-show="iconOnly"')
->not->toContain('<div x-show="!iconOnly"')
->not->toContain(':class="iconOnly')
->toContain('x-show="!compact"')
->toContain("'w-[calc(100vw-2rem)] cursor-pointer sm:w-auto sm:max-w-[calc(100vw-2rem)]'");
@@ -12,6 +12,7 @@ it('aggregates preview container and health check status', function () {
->toContain('Health check')
->toContain('Not configured')
->toContain('aria-label="About unconfigured health checks"')
->toContain('class="relative inline-flex align-middle"')
->toContain('Traffic can still be routed to the container')
->toContain('aria-haspopup="menu"')
->toContain('right-auto! left-0!')
+26 -1
View File
@@ -31,11 +31,36 @@ test('unsaved bar delays show and hides while loading to avoid instant-save flas
expect($contents)
->toContain('wire:dirty.class="is-dirty"')
->toContain('wire:loading.class="!opacity-0 !translate-y-6 !pointer-events-none"')
->toContain('wire:loading.class="is-saving"')
->toContain('[&.is-dirty]:delay-300')
->toContain('delay-0');
});
test('unsaved bar uses a stable transition for its entrance', function () {
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
expect($contents)
->toContain('scale-95')
->toContain('transition-[opacity,transform,scale]')
->toContain('duration-300')
->toContain('ease-[cubic-bezier(0.16,1,0.3,1)]')
->toContain('[&.is-dirty]:scale-100')
->not->toContain('[&.is-dirty]:animate-in');
});
test('unsaved bar transitions out without restarting its entrance animation', function () {
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
expect($contents)
->toContain('[&.is-saving]:translate-y-6')
->toContain('[&.is-saving]:scale-95')
->toContain('[&.is-saving]:opacity-0')
->toContain('[&.is-saving]:duration-200')
->toContain('[&.is-saving]:ease-in')
->toContain('[&.is-saving]:pointer-events-none')
->not->toContain('[&.is-saving]:animate-out');
});
test('unsaved bar saves with enter and shows the shortcut on the save button', function () {
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
+26
View File
@@ -0,0 +1,26 @@
<?php
test('toast messages default to the bottom right while supporting position overrides', function () {
$toast = file_get_contents(resource_path('views/components/toast.blade.php'));
expect($toast)
->toContain("position: options.position ?? 'bottom-right'")
->toContain("position: 'bottom-right'")
->toContain("this.position = event.detail.position || 'bottom-right'")
->toContain("'right-4 bottom-4 flex-col-reverse': position === 'bottom-right'")
->toContain("'left-1/2 top-4 -translate-x-1/2 flex-col': position === 'top-center'");
});
test('toast copy button shows temporary success feedback', function () {
$toast = file_get_contents(resource_path('views/components/toast.blade.php'));
expect($toast)
->toContain('copied: false')
->toContain('copyToast(toast)')
->toContain('toast.copied = true')
->toContain('toast.copied = false')
->toContain('}, 2000)')
->toContain('x-show="!toast.copied"')
->toContain('x-show="toast.copied"')
->toContain("'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400': toast.copied");
});
+14
View File
@@ -0,0 +1,14 @@
<?php
test('shared tooltips render above toasts and notification banners', function () {
$iconTooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($iconTooltip)->toContain('z-[10000]')
->and($helper)->toContain('z-[10000]')
->and($navbar)->toContain('z-[10000]')
->and($utilities)->toContain('@utility auth-tooltip')
->and($utilities)->toContain('@apply fixed z-[10000]');
});