From 9b35c730ab540e0fd72ce13c8baad399e8ae08a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 18 Sep 2026 09:47:20 +0200 Subject: [PATCH] feat: render flash messages as toasts Co-Authored-By: Claude Opus 5 --- assets/app.js | 3 +++ assets/controllers/toast_controller.js | 22 +++++++++++++++++++ importmap.php | 7 ++++++ tailwind/_components.css | 1 + tailwind/components/toast.css | 21 ++++++++++++++++++ templates/_partials/_flashes.html.twig | 14 +++++------- templates/layout.html.twig | 2 +- templates/security/login.html.twig | 6 ++--- .../HtmxErrorFlashListenerTest.php | 13 +++++++++-- 9 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 assets/controllers/toast_controller.js create mode 100644 tailwind/components/toast.css diff --git a/assets/app.js b/assets/app.js index 96924db..e0cf275 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,4 +1,7 @@ import './stimulus_bootstrap.js'; + +// Ahead of app.css: the .toastify--* rules in there override this stylesheet. +import 'toastify-js/src/toastify.css'; import './styles/app.css'; import htmx from 'htmx.org'; diff --git a/assets/controllers/toast_controller.js b/assets/controllers/toast_controller.js new file mode 100644 index 0000000..40b8bbe --- /dev/null +++ b/assets/controllers/toast_controller.js @@ -0,0 +1,22 @@ +import { Controller } from '@hotwired/stimulus'; +import Toastify from 'toastify-js'; + +/** + * Turns one flash message into one toast. The element itself stays empty and invisible -- + * Toastify appends its own node to the body, which is why a toast outlives the out-of-band + * swap of #flashes that spawned it. + */ +export default class extends Controller { + static values = { text: String, class: String }; + + connect() { + Toastify({ + text: this.textValue, + className: this.classValue, + duration: 5000, + gravity: 'top', + position: 'right', + close: true, + }).showToast(); + } +} diff --git a/importmap.php b/importmap.php index 85b58a2..9f90d8a 100644 --- a/importmap.php +++ b/importmap.php @@ -25,4 +25,11 @@ return [ 'htmx.org' => [ 'version' => '2.0.10', ], + 'toastify-js' => [ + 'version' => '1.12.0', + ], + 'toastify-js/src/toastify.css' => [ + 'version' => '1.12.0', + 'type' => 'css', + ], ]; diff --git a/tailwind/_components.css b/tailwind/_components.css index 67f423b..568b588 100644 --- a/tailwind/_components.css +++ b/tailwind/_components.css @@ -2,3 +2,4 @@ @import "components/button.css"; @import "components/forms.css"; @import "components/badge.css"; +@import "components/toast.css"; diff --git a/tailwind/components/toast.css b/tailwind/components/toast.css new file mode 100644 index 0000000..69ed4c7 --- /dev/null +++ b/tailwind/components/toast.css @@ -0,0 +1,21 @@ +/* Overrides for toastify-js. Its own stylesheet is vendored through the importmap and imported + in assets/app.js ahead of this file, so these rules win without extra specificity. */ +.toastify { + @apply rounded shadow-lg text-sm text-white; +} + +.toastify--success { + background: theme('colors.emerald.600'); +} + +.toastify--warning { + background: theme('colors.amber.600'); +} + +.toastify--error { + background: theme('colors.red.600'); +} + +.toastify--info { + background: theme('colors.primary'); +} diff --git a/templates/_partials/_flashes.html.twig b/templates/_partials/_flashes.html.twig index c9a7ed5..df9a08e 100644 --- a/templates/_partials/_flashes.html.twig +++ b/templates/_partials/_flashes.html.twig @@ -1,12 +1,10 @@ -{# Just the alerts. The positioned container with id="flashes" lives in layout.html.twig, because - that is the element htmx swaps out of band after a booking. Flat inline alerts for now: myep - renders these as Toastify toasts, which would mean adopting toastify-js (plan step 2/7). #} -{% set levels = { error: 'bg-red-600', warning: 'bg-amber-600', success: 'bg-emerald-600', info: 'bg-primary' } %} +{# Just the toast triggers. The container with id="flashes" lives in layout.html.twig, because + that is the element htmx swaps out of band after a booking. Each element is empty: the + toast_controller hands the message to Toastify, which renders it at the top right. #} +{% set levels = ['error', 'warning', 'success', 'info'] %} -{% for level, messages in app.flashes(levels|keys) %} +{% for level, messages in app.flashes(levels) %} {% for message in messages %} -
- {{ message|trans }} -
+
{% endfor %} {% endfor %} diff --git a/templates/layout.html.twig b/templates/layout.html.twig index b5e3bbc..40c92a3 100644 --- a/templates/layout.html.twig +++ b/templates/layout.html.twig @@ -31,7 +31,7 @@ {# Swapped out of band after a booking, hence the stable id and the conditional attribute. #} {% block flashes %} -
{% include '_partials/_flashes.html.twig' %}
diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index 863fb05..b5d5560 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -18,9 +18,9 @@ Mit myep anmelden -
- {% include '_partials/_flashes.html.twig' %} -
+ {# Toastify positions itself, so this needs no wrapper -- and the login page has no + board to swap, so it needs no #flashes container either. #} + {% include '_partials/_flashes.html.twig' %} {% endblock %} diff --git a/tests/EventListener/HtmxErrorFlashListenerTest.php b/tests/EventListener/HtmxErrorFlashListenerTest.php index 4fc628e..2fb0229 100644 --- a/tests/EventListener/HtmxErrorFlashListenerTest.php +++ b/tests/EventListener/HtmxErrorFlashListenerTest.php @@ -48,7 +48,7 @@ class HtmxErrorFlashListenerTest extends WebTestCase self::assertFalse($this->client->getResponse()->headers->has('HX-Retarget')); $this->client->request('GET', '/'); - self::assertSelectorTextContains('#flashes', self::MESSAGE); + self::assertSelectorExists(self::toastSelector(self::MESSAGE)); } /** @@ -61,6 +61,15 @@ class HtmxErrorFlashListenerTest extends WebTestCase self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); $this->client->request('GET', '/'); - self::assertSelectorTextNotContains('#flashes', self::MESSAGE); + self::assertSelectorNotExists('#flashes [data-controller~="toast"]'); + } + + /** + * A flash carries its text in the Stimulus value that toast_controller hands to Toastify, so + * there is nothing to read as element text -- the element itself stays empty. + */ + private static function toastSelector(string $message): string + { + return \sprintf('#flashes [data-controller~="toast"][data-toast-text-value*="%s"]', $message); } }