diff --git a/templates/default.phtml b/templates/default.phtml index 0889bb3..4ec0779 100644 --- a/templates/default.phtml +++ b/templates/default.phtml @@ -145,7 +145,7 @@ $componentPageConfig = [ 'components' => [ 'confirmActions' => [], 'flashAutoDismiss' => [ - 'selector' => '.notice[data-flash-timeout]', + 'selector' => '.app-toast-stack .notice[data-flash-timeout]', ], 'passwordHints' => [], 'copyBadge' => [], @@ -329,7 +329,6 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents;
-
@@ -360,6 +359,7 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents; + diff --git a/templates/login.phtml b/templates/login.phtml index 8d460f5..56d9bad 100644 --- a/templates/login.phtml +++ b/templates/login.phtml @@ -15,7 +15,7 @@ if ($bufferTitle !== '') { $loginComponentConfig = [ 'components' => [ 'flashAutoDismiss' => [ - 'selector' => '.notice[data-flash-timeout]', + 'selector' => '.app-toast-stack .notice[data-flash-timeout]', ], 'passwordHints' => [ 'selector' => '[data-password-hints]', @@ -58,9 +58,6 @@ $loginComponentConfig = [
-
- -
@@ -69,6 +66,7 @@ $loginComponentConfig = [
+ diff --git a/templates/page.phtml b/templates/page.phtml index bac7517..665af7d 100644 --- a/templates/page.phtml +++ b/templates/page.phtml @@ -48,12 +48,12 @@ if ($bufferTitle !== '') {
-
+ diff --git a/templates/partials/app-flash.phtml b/templates/partials/app-flash.phtml index 91bef73..806cffb 100644 --- a/templates/partials/app-flash.phtml +++ b/templates/partials/app-flash.phtml @@ -18,15 +18,16 @@ $timeouts = [ ]; ?> -
+
-
- + +
+
- +
diff --git a/templates/partials/app-topbar.phtml b/templates/partials/app-topbar.phtml index 57c49dc..df497f4 100644 --- a/templates/partials/app-topbar.phtml +++ b/templates/partials/app-topbar.phtml @@ -36,7 +36,7 @@ $moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $modu
  • -
  • +
      diff --git a/tests/Service/Settings/SettingsAppGatewayTest.php b/tests/Service/Settings/SettingsAppGatewayTest.php deleted file mode 100644 index dc8ef76..0000000 --- a/tests/Service/Settings/SettingsAppGatewayTest.php +++ /dev/null @@ -1,72 +0,0 @@ -createMock(SettingRepositoryInterface::class); - $settings->expects($this->once())->method('set')->with('app_locale', null, 'setting.app_locale')->willReturn(true); - - $gateway = $this->newGateway($settings); - $this->assertTrue($gateway->setAppLocale(' ')); - } - - public function testSetAppThemeStoresNullForUnknownTheme(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->once())->method('set')->with('app_theme', null, 'setting.app_theme')->willReturn(true); - - $gateway = $this->newGateway($settings); - $this->assertTrue($gateway->setAppTheme('unknown')); - } - - public function testBooleanFlagsUseExpectedDefaults(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->method('getValue')->willReturnMap([ - ['app_theme_user', null], - ['app_registration', null], - ]); - - $gateway = $this->newGateway($settings); - $this->assertTrue($gateway->isUserThemeAllowed()); - $this->assertTrue($gateway->isRegistrationEnabled()); - } - - public function testSetAppPrimaryColorNormalizesHexWithoutHash(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->once()) - ->method('set') - ->with('app_primary_color', '#abc', 'setting.app_primary_color') - ->willReturn(true); - - $gateway = $this->newGateway($settings); - $this->assertTrue($gateway->setAppPrimaryColor('abc')); - } - - public function testSetAppPrimaryColorRejectsInvalidValue(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->never())->method('set'); - - $gateway = $this->newGateway($settings); - $this->assertFalse($gateway->setAppPrimaryColor('not-a-color')); - } - - private function newGateway(SettingRepositoryInterface $settingRepository): SettingsAppGateway - { - $themeConfigService = $this->createMock(ThemeConfigGateway::class); - $themeConfigService->method('all')->willReturn(['light' => 'Light', 'dark' => 'Dark']); - - return new SettingsAppGateway(new SettingsMetadataGateway($settingRepository), $themeConfigService); - } -} diff --git a/tests/Service/Settings/SettingsFrontendTelemetryGatewayTest.php b/tests/Service/Settings/SettingsFrontendTelemetryGatewayTest.php deleted file mode 100644 index 85f931f..0000000 --- a/tests/Service/Settings/SettingsFrontendTelemetryGatewayTest.php +++ /dev/null @@ -1,47 +0,0 @@ -createMock(SettingRepositoryInterface::class); - $settings->method('getValue')->willReturn(null); - - $gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings)); - $this->assertFalse($gateway->isFrontendTelemetryEnabled()); - $this->assertSame(0.2, $gateway->getFrontendTelemetrySampleRate()); - $this->assertSame(['warn_once', 'ajax_error'], $gateway->getFrontendTelemetryAllowedEvents()); - } - - public function testSetSampleRateRejectsOutOfRangeValues(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->never())->method('set'); - - $gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings)); - $this->assertFalse($gateway->setFrontendTelemetrySampleRate(1.5)); - } - - public function testSetAllowedEventsNormalizesValues(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->once()) - ->method('set') - ->with( - 'frontend_telemetry_allowed_events', - 'ajax_error,warn_once', - 'setting.frontend_telemetry_allowed_events' - ) - ->willReturn(true); - - $gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings)); - $this->assertTrue($gateway->setFrontendTelemetryAllowedEvents(['frontend.warn_once', 'ajax_error', 'invalid'])); - } -} diff --git a/tests/Service/Settings/SettingsGatewayNormalizationTest.php b/tests/Service/Settings/SettingsGatewayNormalizationTest.php new file mode 100644 index 0000000..c7ec217 --- /dev/null +++ b/tests/Service/Settings/SettingsGatewayNormalizationTest.php @@ -0,0 +1,167 @@ +createMock(SettingRepositoryInterface::class); + $settings->expects($this->once()) + ->method('set') + ->with('app_locale', null, 'setting.app_locale') + ->willReturn(true); + + $gateway = $this->newAppGateway($settings); + self::assertTrue($gateway->setAppLocale(' ')); + } + + public function testAppThemeStoresNullForUnknownTheme(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->once()) + ->method('set') + ->with('app_theme', null, 'setting.app_theme') + ->willReturn(true); + + $gateway = $this->newAppGateway($settings); + self::assertTrue($gateway->setAppTheme('unknown')); + } + + public function testAppBooleanFlagsUseExpectedDefaults(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturnMap([ + ['app_theme_user', null], + ['app_registration', null], + ]); + + $gateway = $this->newAppGateway($settings); + self::assertTrue($gateway->isUserThemeAllowed()); + self::assertTrue($gateway->isRegistrationEnabled()); + } + + public function testAppPrimaryColorNormalizesHexWithoutHash(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->once()) + ->method('set') + ->with('app_primary_color', '#abc', 'setting.app_primary_color') + ->willReturn(true); + + $gateway = $this->newAppGateway($settings); + self::assertTrue($gateway->setAppPrimaryColor('abc')); + } + + public function testAppPrimaryColorRejectsInvalidValue(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->never())->method('set'); + + $gateway = $this->newAppGateway($settings); + self::assertFalse($gateway->setAppPrimaryColor('not-a-color')); + } + + public function testSystemAuditUsesFallbacksAndRejectsOutOfRangeRetention(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturnCallback(static function (string $key): ?string { + return $key === 'system_audit_retention_days' ? '5' : null; + }); + $settings->expects($this->never())->method('set'); + + $gateway = new SettingsSystemAuditGateway(new SettingsMetadataGateway($settings)); + self::assertTrue($gateway->isSystemAuditEnabled()); + self::assertSame(365, $gateway->getSystemAuditRetentionDays()); + self::assertFalse($gateway->setSystemAuditRetentionDays(10)); + } + + public function testFrontendTelemetryUsesDefaultsWhenSettingsMissing(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturn(null); + + $gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings)); + self::assertFalse($gateway->isFrontendTelemetryEnabled()); + self::assertSame(0.2, $gateway->getFrontendTelemetrySampleRate()); + self::assertSame(['warn_once', 'ajax_error'], $gateway->getFrontendTelemetryAllowedEvents()); + } + + public function testFrontendTelemetryRejectsOutOfRangeSampleRate(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->never())->method('set'); + + $gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings)); + self::assertFalse($gateway->setFrontendTelemetrySampleRate(1.5)); + } + + public function testFrontendTelemetryNormalizesAllowedEvents(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->once()) + ->method('set') + ->with( + 'frontend_telemetry_allowed_events', + 'ajax_error,warn_once', + 'setting.frontend_telemetry_allowed_events' + ) + ->willReturn(true); + + $gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings)); + self::assertTrue($gateway->setFrontendTelemetryAllowedEvents(['frontend.warn_once', 'ajax_error', 'invalid'])); + } + + public function testSmtpSecureReturnsNullForNone(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturnCallback(static function (string $key): ?string { + if ($key === 'smtp_secure') { + return 'none'; + } + + return null; + }); + + $gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings)); + self::assertNull($gateway->getSmtpSecure()); + } + + public function testSmtpSecureRejectsInvalidValue(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->never())->method('set'); + + $gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings)); + self::assertFalse($gateway->setSmtpSecure('starttls')); + } + + public function testSmtpHostPersistsNullForEmptyString(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->once()) + ->method('set') + ->with('smtp_host', null, 'setting.smtp_host') + ->willReturn(true); + + $gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings)); + self::assertTrue($gateway->setSmtpHost(' ')); + } + + private function newAppGateway(SettingRepositoryInterface $settingRepository): SettingsAppGateway + { + $themeConfigService = $this->createMock(ThemeConfigGateway::class); + $themeConfigService->method('all')->willReturn(['light' => 'Light', 'dark' => 'Dark']); + + return new SettingsAppGateway(new SettingsMetadataGateway($settingRepository), $themeConfigService); + } +} diff --git a/tests/Service/Settings/SettingsSmtpGatewayTest.php b/tests/Service/Settings/SettingsSmtpGatewayTest.php deleted file mode 100644 index 16b1eca..0000000 --- a/tests/Service/Settings/SettingsSmtpGatewayTest.php +++ /dev/null @@ -1,46 +0,0 @@ -createMock(SettingRepositoryInterface::class); - $settings->method('getValue')->willReturnCallback(static function (string $key): ?string { - if ($key === 'smtp_secure') { - return 'none'; - } - return null; - }); - - $gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings)); - $this->assertNull($gateway->getSmtpSecure()); - } - - public function testSetSmtpSecureRejectsInvalidValue(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->never())->method('set'); - - $gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings)); - $this->assertFalse($gateway->setSmtpSecure('starttls')); - } - - public function testSetSmtpHostPersistsNullForEmptyString(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->once()) - ->method('set') - ->with('smtp_host', null, 'setting.smtp_host') - ->willReturn(true); - - $gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings)); - $this->assertTrue($gateway->setSmtpHost(' ')); - } -} diff --git a/tests/Service/Settings/SettingsSystemAuditGatewayTest.php b/tests/Service/Settings/SettingsSystemAuditGatewayTest.php deleted file mode 100644 index 8f2ed14..0000000 --- a/tests/Service/Settings/SettingsSystemAuditGatewayTest.php +++ /dev/null @@ -1,38 +0,0 @@ -createMock(SettingRepositoryInterface::class); - $settings->method('getValue')->willReturn(null); - - $gateway = new SettingsSystemAuditGateway(new SettingsMetadataGateway($settings)); - $this->assertTrue($gateway->isSystemAuditEnabled()); - } - - public function testRetentionFallsBackWhenOutOfRange(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->method('getValue')->willReturn('5'); - - $gateway = new SettingsSystemAuditGateway(new SettingsMetadataGateway($settings)); - $this->assertSame(365, $gateway->getSystemAuditRetentionDays()); - } - - public function testSetSystemAuditRetentionRejectsOutOfRange(): void - { - $settings = $this->createMock(SettingRepositoryInterface::class); - $settings->expects($this->never())->method('set'); - - $gateway = new SettingsSystemAuditGateway(new SettingsMetadataGateway($settings)); - $this->assertFalse($gateway->setSystemAuditRetentionDays(10)); - } -} diff --git a/web/css/components/app-flash.css b/web/css/components/app-flash.css index a3a8cb1..b1fe02e 100644 --- a/web/css/components/app-flash.css +++ b/web/css/components/app-flash.css @@ -1,132 +1,195 @@ @layer components { - /* Flash notices — success/warning/error/info feedback banners with auto-dismiss timer. */ -.notice { + /* ── Toast stack: fixed top-right overlay for all flash messages ────── */ + + .app-toast-stack { + position: fixed; + top: calc(var(--app-spacing) * 1.5); + right: calc(var(--app-spacing) * 1.5); + z-index: 1100; + display: flex; + flex-direction: column; + gap: calc(var(--app-spacing) * 0.5); + width: 400px; + max-width: calc(100vw - var(--app-spacing) * 3); + pointer-events: none; + } + + /* In admin layout: offset below topbar */ + .app-container ~ .app-toast-stack, + body:has(.app-container) .app-toast-stack { + top: calc(52px + var(--app-spacing)); + } + + @media (max-width: 576px) { + .app-toast-stack { + top: calc(var(--app-spacing) * 1); + right: calc(var(--app-spacing) * 1); + left: calc(var(--app-spacing) * 1); + width: auto; + } + + body:has(.app-container) .app-toast-stack { + top: calc(52px + var(--app-spacing) * 0.5); + right: calc(var(--app-spacing) * 0.5); + left: calc(var(--app-spacing) * 0.5); + } + } + + /* ── Individual toast notice ─────────────────────────────────────────── */ + + .notice { --app-notice-border-color: var(--app-muted-border-color); --app-notice-background-color: var(--app-card-background-color); --app-notice-accent-color: var(--app-color); background: var(--app-notice-background-color); - padding: calc(var(--app-spacing) * .5) calc(var(--app-spacing) * 1); - padding-right: calc(var(--app-spacing) * .5); + border-left: 3px solid var(--app-notice-border-color); + border-radius: var(--app-border-radius); + box-shadow: var(--app-card-box-shadow, 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.06)); + padding: calc(var(--app-spacing) * 0.625) calc(var(--app-spacing) * 0.875); + padding-right: calc(var(--app-spacing) * 0.5); display: flex; align-items: center; justify-content: space-between; - gap: var(--app-spacing); + gap: calc(var(--app-spacing) * 0.75); position: relative; overflow: hidden; color: var(--app-notice-border-color); - margin-block: calc(var(--app-spacing) * .75); -} + font-size: 0.875rem; + line-height: 1.4; + pointer-events: auto; + } -#async-messages .notice { - border: none; - border-radius: 0; - padding: 0; - padding-right: 5px; - padding-left: 15px; - margin-bottom: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 5px; - position: relative; - overflow: hidden; - color: var(--app-notice-border-color); - margin-block: 0; -} + /* ── Slide-in animation ──────────────────────────────────────────────── */ + .notice { + animation: toast-slide-in 250ms ease-out; + } -.notice strong { + .notice.toast-dismissing { + animation: toast-slide-out 200ms ease-in forwards; + } + + @keyframes toast-slide-in { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } + } + + @keyframes toast-slide-out { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(20px); + } + } + + @media (prefers-reduced-motion: reduce) { + .notice, + .notice.toast-dismissing { + animation: none; + } + } + + /* ── Variant colors ──────────────────────────────────────────────────── */ + + .notice strong { color: var(--app-notice-accent-color); -} + } -.notice[data-variant=success] { + .notice[data-variant=success] { --app-notice-border-color: var(--app-notice-success-border-color); --app-notice-background-color: var(--app-notice-success-background-color); --app-notice-accent-color: var(--app-notice-success-color); -} + } -.notice[data-variant=warning] { + .notice[data-variant=warning] { --app-notice-border-color: var(--app-notice-warning-border-color); --app-notice-background-color: var(--app-notice-warning-background-color); --app-notice-accent-color: var(--app-notice-warning-color); -} + } -.notice[data-variant=info] { + .notice[data-variant=info] { --app-notice-border-color: var(--app-notice-info-border-color); --app-notice-background-color: var(--app-notice-info-background-color); --app-notice-accent-color: var(--app-notice-info-color); -} + } -.notice[data-variant=error] { + .notice[data-variant=error] { --app-notice-border-color: var(--app-notice-error-border-color); --app-notice-background-color: var(--app-notice-error-background-color); --app-notice-accent-color: var(--app-notice-error-color); -} + } -.notice.flash-timed::after { + /* ── Progress bar timer ──────────────────────────────────────────────── */ + + .notice.flash-timed::after { content: ""; position: absolute; left: 0; bottom: 0; - height: 3px; + height: 2px; width: 100%; background: var(--app-notice-border-color); transform-origin: left; transform: scaleX(0); animation: flash-progress var(--flash-timeout, 4000ms) linear forwards; - opacity: 0.9; -} + opacity: 0.7; + } -.notice.flash-timed:hover::after { + .notice.flash-timed:hover::after { animation-play-state: paused; -} + } -@keyframes flash-progress { - from { - transform: scaleX(0); - } - to { - transform: scaleX(1); - } -} + @keyframes flash-progress { + from { transform: scaleX(0); } + to { transform: scaleX(1); } + } -@media (prefers-reduced-motion: reduce) { + @media (prefers-reduced-motion: reduce) { .notice.flash-timed::after { - animation: none; + animation: none; } -} + } -.notice button[type=submit] { - border: 1px solid var(--app-notice-border-color) !important; - background: var(--app-notice-background-color); + /* ── Dismiss button ──────────────────────────────────────────────────── */ + + .notice button[type=submit], + .notice button[type=button] { + border: none !important; + background: transparent; color: var(--app-notice-border-color); -} + opacity: 0.7; + cursor: pointer; + padding: 2px 4px; + line-height: 1; + flex-shrink: 0; + } -.notice ul { + .notice button[type=submit]:hover, + .notice button[type=button]:hover { + opacity: 1; + } + + /* ── List formatting inside notices ──────────────────────────────────── */ + + .notice ul { margin: 0; padding-left: 0; -} + } -.notice li:last-child { - margin: 0; -} - -.app-main-content .flash-stack { - margin-bottom: var(--app-spacing); -} -.app-main-content:has(.app-details-container) .flash-stack { - margin-bottom: 0; -} - -.flash-stack .notice { - margin-block: 0; -} - -.flash-stack .notice + .notice { - margin-top: calc(var(--app-spacing) * .5); -} - -.notice li { + .notice li { list-style: none; -} + } + + .notice li:last-child { + margin: 0; + } } diff --git a/web/css/pages/app-login.css b/web/css/pages/app-login.css index 31c17db..3da9906 100644 --- a/web/css/pages/app-login.css +++ b/web/css/pages/app-login.css @@ -618,12 +618,7 @@ padding-block: 1rem 2rem; } - .login-flash-container:empty { - display: none; - } - - .login-content-container.container-small, - .login-flash-container.container-small { + .login-content-container.container-small { width: min(370px, calc(100vw - 2rem)); max-width: none; } diff --git a/web/js/components/app-async-flash.js b/web/js/components/app-async-flash.js index df7a29b..dbfc178 100644 --- a/web/js/components/app-async-flash.js +++ b/web/js/components/app-async-flash.js @@ -2,8 +2,9 @@ import { warnOnce } from '../core/app-dom.js'; /** * Async Flash Messages & Loading State - * Renders flash messages into #async-messages for JS-triggered notifications - * Provides loading cursor state management + * Renders flash messages into the .app-toast-stack for JS-triggered notifications. + * Creates the stack container on demand if it doesn't exist yet. + * Provides loading cursor state management. */ /** @@ -33,24 +34,67 @@ export async function withLoading(fn) { } const defaultTimeouts = { - success: 10000, - info: 10000, - warning: 10000, - error: 0 + success: 5000, + info: 6000, + warning: 7000, + error: 0, }; +const MAX_VISIBLE_TOASTS = 5; + +/** + * Get or create the toast stack container. + * @returns {HTMLElement} + */ +function getToastStack() { + let stack = document.querySelector('.app-toast-stack'); + if (!stack) { + stack = document.createElement('div'); + stack.className = 'app-toast-stack'; + stack.setAttribute('aria-live', 'polite'); + document.body.appendChild(stack); + } + return stack; +} + +/** + * Dismiss a toast with slide-out animation. + * @param {HTMLElement} notice + */ +function dismissToast(notice) { + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (prefersReducedMotion) { + notice.remove(); + return; + } + notice.classList.add('toast-dismissing'); + notice.addEventListener('animationend', () => notice.remove(), { once: true }); + // Fallback removal if animation doesn't fire + setTimeout(() => { if (notice.parentNode) notice.remove(); }, 300); +} + +/** + * Enforce max visible toasts — dismiss oldest when limit exceeded. + * @param {HTMLElement} stack + */ +function enforceMaxVisible(stack) { + const notices = stack.querySelectorAll('.notice:not(.toast-dismissing)'); + if (notices.length > MAX_VISIBLE_TOASTS) { + for (let i = 0; i < notices.length - MAX_VISIBLE_TOASTS; i++) { + dismissToast(notices[i]); + } + } +} + /** * Show an async flash message * @param {string} type - Message type: 'success', 'info', 'warning', 'error' * @param {string} message - The message text * @param {number} [timeout] - Auto-dismiss timeout in ms (0 = no auto-dismiss) + * @returns {HTMLElement|null} */ export function showAsyncFlash(type, message, timeout) { - const container = document.getElementById('async-messages'); - if (!container) { - warnOnce('UI_EL_MISSING', 'Missing #async-messages container', { module: 'async-flash' }); - return null; - } + const stack = getToastStack(); const effectiveTimeout = timeout ?? defaultTimeouts[type] ?? 5000; @@ -68,17 +112,27 @@ export function showAsyncFlash(type, message, timeout) { const closeButton = document.createElement('button'); closeButton.type = 'button'; - closeButton.className = 'contrast outline small'; - closeButton.innerHTML = ''; - closeButton.addEventListener('click', () => notice.remove()); + closeButton.setAttribute('aria-label', 'Dismiss'); + closeButton.innerHTML = ''; + closeButton.addEventListener('click', () => dismissToast(notice)); notice.appendChild(closeButton); - container.appendChild(notice); + stack.appendChild(notice); + enforceMaxVisible(stack); if (effectiveTimeout > 0) { notice.style.setProperty('--flash-timeout', `${effectiveTimeout}ms`); notice.classList.add('flash-timed'); - setTimeout(() => notice.remove(), effectiveTimeout); + + let timer = setTimeout(() => dismissToast(notice), effectiveTimeout); + + // Pause timer on hover + notice.addEventListener('mouseenter', () => { + clearTimeout(timer); + }); + notice.addEventListener('mouseleave', () => { + timer = setTimeout(() => dismissToast(notice), 1000); + }); } return notice; @@ -88,8 +142,8 @@ export function showAsyncFlash(type, message, timeout) { * Clear all async flash messages */ export function clearAsyncFlash() { - const container = document.getElementById('async-messages'); - if (container) { - container.innerHTML = ''; + const stack = document.querySelector('.app-toast-stack'); + if (stack) { + stack.querySelectorAll('.notice').forEach((n) => dismissToast(n)); } } diff --git a/web/js/components/app-flash-auto-dismiss.js b/web/js/components/app-flash-auto-dismiss.js index 8131dbc..ba3e063 100644 --- a/web/js/components/app-flash-auto-dismiss.js +++ b/web/js/components/app-flash-auto-dismiss.js @@ -1,11 +1,12 @@ /** * Auto-dismisses flash notices after their data-flash-timeout expires. + * Supports slide-out animation and hover-pause. */ import { resolveHost } from '../core/app-dom.js'; export function initFlashAutoDismiss(root = document, options = {}) { const { - selector = '.flash-stack .notice[data-flash-timeout]', + selector = '.app-toast-stack .notice[data-flash-timeout]', defaultTimeout = 0, } = options; const host = resolveHost(root); @@ -16,6 +17,17 @@ export function initFlashAutoDismiss(root = document, options = {}) { const timers = []; let destroyed = false; + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + const dismissNotice = (notice) => { + if (prefersReducedMotion) { + notice.remove(); + return; + } + notice.classList.add('toast-dismissing'); + notice.addEventListener('animationend', () => notice.remove(), { once: true }); + setTimeout(() => { if (notice.parentNode) notice.remove(); }, 300); + }; const postForm = async (form) => { const action = form.getAttribute('action'); @@ -44,7 +56,7 @@ export function initFlashAutoDismiss(root = document, options = {}) { notice.style.setProperty('--flash-timeout', `${timeout}ms`); notice.classList.add('flash-timed'); - const timer = window.setTimeout(async () => { + let timer = window.setTimeout(async () => { if (destroyed) { return; } @@ -55,9 +67,26 @@ export function initFlashAutoDismiss(root = document, options = {}) { return; } } - notice.remove(); + dismissNotice(notice); }, timeout); timers.push(timer); + + // Pause auto-dismiss on hover + notice.addEventListener('mouseenter', () => { + window.clearTimeout(timer); + }); + notice.addEventListener('mouseleave', () => { + timer = window.setTimeout(async () => { + if (destroyed) return; + const form = notice.querySelector('form'); + if (form) { + const response = await postForm(form); + if (!response || !response.ok) return; + } + dismissNotice(notice); + }, 1000); + timers.push(timer); + }); }); const destroy = () => {