diff --git a/templates/default.phtml b/templates/default.phtml index a64c83c..276c301 100644 --- a/templates/default.phtml +++ b/templates/default.phtml @@ -337,7 +337,6 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents; - @@ -360,7 +359,7 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents; ?> - + diff --git a/templates/login.phtml b/templates/login.phtml index 115ef27..1b7eeac 100644 --- a/templates/login.phtml +++ b/templates/login.phtml @@ -65,7 +65,7 @@ $loginComponentConfig = [ - + diff --git a/templates/page.phtml b/templates/page.phtml index 665af7d..ec4c0b1 100644 --- a/templates/page.phtml +++ b/templates/page.phtml @@ -53,7 +53,7 @@ if ($bufferTitle !== '') { - + diff --git a/templates/partials/app-flash.phtml b/templates/partials/app-flash.phtml deleted file mode 100644 index 580bf08..0000000 --- a/templates/partials/app-flash.phtml +++ /dev/null @@ -1,34 +0,0 @@ - 5000, - 'info' => 6000, - 'warning' => 7000, - 'error' => 0, -]; - -?> -
- - - - -
- -
- - - -
-
- -
diff --git a/templates/partials/app-toast-stack.phtml b/templates/partials/app-toast-stack.phtml index ef12a9c..3179095 100644 --- a/templates/partials/app-toast-stack.phtml +++ b/templates/partials/app-toast-stack.phtml @@ -1,12 +1,55 @@ 5000, + 'info' => 6000, + 'warning' => 7000, + 'error' => 0, +]; +$variantIcons = [ + 'success' => 'bi-check-circle-fill', + 'error' => 'bi-x-octagon-fill', + 'warning' => 'bi-exclamation-triangle-fill', + 'info' => 'bi-info-circle-fill', +]; +$returnTarget = Request::pathWithQuery(); + ?> -
+
+ + +
+ + +
+ + + +
+
+ +
diff --git a/tests/Architecture/FrontendRuntimeHostContractTest.php b/tests/Architecture/FrontendRuntimeHostContractTest.php index a4bea95..47c0ae6 100644 --- a/tests/Architecture/FrontendRuntimeHostContractTest.php +++ b/tests/Architecture/FrontendRuntimeHostContractTest.php @@ -12,7 +12,7 @@ class FrontendRuntimeHostContractTest extends TestCase { $defaultTemplate = $this->readProjectFile('templates/default.phtml'); $loginTemplate = $this->readProjectFile('templates/login.phtml'); - $flashPartial = $this->readProjectFile('templates/partials/app-flash.phtml'); + $flashPartial = $this->readProjectFile('templates/partials/app-toast-stack.phtml'); $topbarPartial = $this->readProjectFile('templates/partials/app-topbar.phtml'); $asideIconBar = $this->readProjectFile('templates/partials/app-main-aside-icon-bar.phtml'); $asidePartial = $this->readProjectFile('templates/partials/app-main-aside.phtml'); diff --git a/web/css/components/app-flash.css b/web/css/components/app-flash.css index 8db0981..9145453 100644 --- a/web/css/components/app-flash.css +++ b/web/css/components/app-flash.css @@ -1,5 +1,101 @@ @layer components { - /* ── Toast stack: fixed top-right overlay for all flash messages ────── */ + /* ── Base notice — works inline (auth pages, edit pages) and in toasts ── */ + + .notice { + --app-notice-accent: var(--app-muted-color); + position: relative; + display: flex; + align-items: center; + gap: calc(var(--app-spacing) * 0.625); + padding: calc(var(--app-spacing) * 0.75) calc(var(--app-spacing) * 0.875); + background: var(--app-card-background-color); + border: 1px solid var(--app-muted-border-color); + border-radius: calc(var(--app-border-radius) * 1.1); + color: var(--app-color); + font-size: var(--text-sm); + line-height: var(--leading-snug); + margin-bottom: var(--app-spacing); + overflow: hidden; + } + + /* Auto-injected variant icon for notices that don't carry an explicit + .notice-icon child (= every inline use site). Toasts emit the icon + in markup so they opt out via :has(). */ + .notice:not(:has(> .notice-icon))::before { + content: ""; + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--app-notice-accent) 16%, transparent); + color: var(--app-notice-accent); + font-family: "Bootstrap-icons"; + font-size: var(--text-base); + font-weight: var(--font-regular); + line-height: var(--leading-none); + } + + .notice[data-variant=success]:not(:has(> .notice-icon))::before { content: "\f26a"; } + .notice[data-variant=info]:not(:has(> .notice-icon))::before { content: "\f430"; } + .notice[data-variant=warning]:not(:has(> .notice-icon))::before { content: "\f33a"; } + .notice[data-variant=error]:not(:has(> .notice-icon))::before { content: "\f626"; } + + /* Explicit icon span (used by toasts) — same look as the ::before pseudo. */ + .notice .notice-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--app-notice-accent) 16%, transparent); + color: var(--app-notice-accent); + font-size: var(--text-base); + } + + /* Message content slot. Inline notices without an explicit wrapper still + work because flex layout picks up the next direct child as content. */ + .notice .notice-content { + flex: 1 1 auto; + color: var(--app-color); + min-width: 0; + overflow-wrap: anywhere; + } + + .notice ul { + margin: 0; + padding-left: 0; + list-style: none; + flex: 1 1 auto; + min-width: 0; + } + + .notice ul li { + list-style: none; + margin: 0; + } + + .notice ul li + li { + margin-top: calc(var(--app-spacing) * 0.25); + } + + .notice p { + margin: 0; + flex: 1 1 auto; + min-width: 0; + } + + /* Variant accent — drives the icon-pill color. Text stays neutral. */ + .notice[data-variant=success] { --app-notice-accent: var(--app-notice-success-color); } + .notice[data-variant=warning] { --app-notice-accent: var(--app-notice-warning-color); } + .notice[data-variant=info] { --app-notice-accent: var(--app-notice-info-color); } + .notice[data-variant=error] { --app-notice-accent: var(--app-notice-error-color); } + + /* ── Toast stack: fixed top-right overlay for ephemeral notifications ── */ .app-toast-stack { position: fixed; @@ -39,31 +135,11 @@ } } - /* ── Individual toast notice ─────────────────────────────────────────── */ - + /* Toast-only enhancements on top of the base .notice. */ .app-toast-stack .notice { - --app-notice-border-color: var(--app-muted-border-color); - --app-notice-background-color: var(--app-background-color); - --app-notice-accent-color: var(--app-color); - --_toast-tint: transparent; - background: linear-gradient(var(--_toast-tint), var(--_toast-tint)), var(--app-background-color); - border: 1px solid color-mix(in srgb, var(--app-notice-border-color) 40%, transparent); - border-left: 3px solid var(--app-notice-border-color); - border-radius: var(--app-border-radius); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.08); - 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: calc(var(--app-spacing) * 0.75); - position: relative; - overflow: hidden; - color: var(--app-notice-border-color); - font-size: var(--text-sm); - line-height: var(--leading-snug); - pointer-events: auto; margin: 0; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06); + pointer-events: auto; animation: toast-slide-in 250ms ease-out; } @@ -73,12 +149,12 @@ @keyframes toast-slide-in { from { opacity: 0; transform: translateX(20px); } - to { opacity: 1; transform: translateX(0); } + to { opacity: 1; transform: translateX(0); } } @keyframes toast-slide-out { from { opacity: 1; transform: translateX(0); } - to { opacity: 0; transform: translateX(20px); } + to { opacity: 0; transform: translateX(20px); } } @media (prefers-reduced-motion: reduce) { @@ -88,37 +164,7 @@ } } - /* ── Variant colors ──────────────────────────────────────────────────── */ - - .app-toast-stack .notice strong { - color: var(--app-notice-accent-color); - } - - .app-toast-stack .notice[data-variant=success] { - --app-notice-border-color: var(--app-notice-success-border-color); - --_toast-tint: var(--app-notice-success-background-color); - --app-notice-accent-color: var(--app-notice-success-color); - } - - .app-toast-stack .notice[data-variant=warning] { - --app-notice-border-color: var(--app-notice-warning-border-color); - --_toast-tint: var(--app-notice-warning-background-color); - --app-notice-accent-color: var(--app-notice-warning-color); - } - - .app-toast-stack .notice[data-variant=info] { - --app-notice-border-color: var(--app-notice-info-border-color); - --_toast-tint: var(--app-notice-info-background-color); - --app-notice-accent-color: var(--app-notice-info-color); - } - - .app-toast-stack .notice[data-variant=error] { - --app-notice-border-color: var(--app-notice-error-border-color); - --_toast-tint: var(--app-notice-error-background-color); - --app-notice-accent-color: var(--app-notice-error-color); - } - - /* ── Progress bar timer ──────────────────────────────────────────────── */ + /* ── Progress bar timer (toast only) ─────────────────────────────────── */ .app-toast-stack .notice.flash-timed::after { content: ""; @@ -127,11 +173,11 @@ bottom: 0; height: 2px; width: 100%; - background: var(--app-notice-border-color); + background: var(--app-notice-accent); transform-origin: left; transform: scaleX(0); animation: flash-progress var(--flash-timeout, 4000ms) linear forwards; - opacity: 0.7; + opacity: 0.6; } .app-toast-stack .notice.flash-timed:hover::after { @@ -140,7 +186,7 @@ @keyframes flash-progress { from { transform: scaleX(0); } - to { transform: scaleX(1); } + to { transform: scaleX(1); } } @media (prefers-reduced-motion: reduce) { @@ -149,37 +195,29 @@ } } - /* ── Dismiss button ──────────────────────────────────────────────────── */ + /* ── Dismiss button (toast only) ─────────────────────────────────────── */ - .app-toast-stack .notice button[type=submit], - .app-toast-stack .notice button[type=button] { - border: none !important; - background: transparent; - color: var(--app-notice-border-color); - opacity: 0.6; - cursor: pointer; - padding: 2px 4px; - line-height: var(--leading-none); + .app-toast-stack .notice .notice-dismiss-form { + margin: 0; flex-shrink: 0; } - .app-toast-stack .notice button[type=submit]:hover, - .app-toast-stack .notice button[type=button]:hover { + .app-toast-stack .notice .notice-dismiss { + border: none !important; + background: transparent; + color: var(--app-muted-color); + opacity: 0.6; + cursor: pointer; + padding: 4px 6px; + line-height: var(--leading-none); + flex-shrink: 0; + border-radius: 6px; + transition: opacity 120ms ease, background-color 120ms ease; + } + + .app-toast-stack .notice .notice-dismiss:hover, + .app-toast-stack .notice .notice-dismiss:focus-visible { opacity: 1; - } - - /* ── List formatting inside notices ──────────────────────────────────── */ - - .app-toast-stack .notice ul { - margin: 0; - padding-left: 0; - } - - .app-toast-stack .notice li { - list-style: none; - } - - .app-toast-stack .notice li:last-child { - margin: 0; + background: color-mix(in srgb, var(--app-color) 8%, transparent); } } diff --git a/web/js/components/app-async-flash.js b/web/js/components/app-async-flash.js index 180545b..2199ff8 100644 --- a/web/js/components/app-async-flash.js +++ b/web/js/components/app-async-flash.js @@ -46,6 +46,13 @@ const defaultTimeouts = { const MAX_VISIBLE_TOASTS = 5; +const VARIANT_ICONS = { + success: 'bi-check-circle-fill', + error: 'bi-x-octagon-fill', + warning: 'bi-exclamation-triangle-fill', + info: 'bi-info-circle-fill', +}; + /** * Resolve the server-rendered toast stack container. * Returns null if the partial is missing — callers must handle that case. @@ -111,14 +118,26 @@ export function showAsyncFlash(type, message, timeout) { notice.setAttribute('aria-live', isAssertive ? 'assertive' : 'polite'); notice.setAttribute('aria-atomic', 'true'); + const iconSpan = document.createElement('span'); + iconSpan.className = 'notice-icon'; + iconSpan.setAttribute('aria-hidden', 'true'); + const iconElement = document.createElement('i'); + iconElement.className = `bi ${VARIANT_ICONS[type] || VARIANT_ICONS.info}`; + iconSpan.appendChild(iconElement); + notice.appendChild(iconSpan); + const messageSpan = document.createElement('span'); + messageSpan.className = 'notice-content'; messageSpan.textContent = message; notice.appendChild(messageSpan); const closeButton = document.createElement('button'); closeButton.type = 'button'; + closeButton.className = 'notice-dismiss'; closeButton.setAttribute('aria-label', 'Dismiss'); - closeButton.innerHTML = ''; + const closeIcon = document.createElement('i'); + closeIcon.className = 'bi bi-x-lg'; + closeButton.appendChild(closeIcon); closeButton.addEventListener('click', () => dismissToast(notice)); notice.appendChild(closeButton);