mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 02:27:57 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!')
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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]');
|
||||
});
|
||||
Reference in New Issue
Block a user