feat: render flash messages as toasts
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
5a6db29205
commit
9b35c730ab
9 files changed
+75
-14
No files matched your search
@@ -1,4 +1,7 @@
|
|||||||
import './stimulus_bootstrap.js';
|
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 './styles/app.css';
|
||||||
|
|
||||||
import htmx from 'htmx.org';
|
import htmx from 'htmx.org';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,4 +25,11 @@ return [
|
|||||||
'htmx.org' => [
|
'htmx.org' => [
|
||||||
'version' => '2.0.10',
|
'version' => '2.0.10',
|
||||||
],
|
],
|
||||||
|
'toastify-js' => [
|
||||||
|
'version' => '1.12.0',
|
||||||
|
],
|
||||||
|
'toastify-js/src/toastify.css' => [
|
||||||
|
'version' => '1.12.0',
|
||||||
|
'type' => 'css',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
@import "components/button.css";
|
@import "components/button.css";
|
||||||
@import "components/forms.css";
|
@import "components/forms.css";
|
||||||
@import "components/badge.css";
|
@import "components/badge.css";
|
||||||
|
@import "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');
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
{# Just the alerts. The positioned container with id="flashes" lives in layout.html.twig, because
|
{# 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. Flat inline alerts for now: myep
|
that is the element htmx swaps out of band after a booking. Each element is empty: the
|
||||||
renders these as Toastify toasts, which would mean adopting toastify-js (plan step 2/7). #}
|
toast_controller hands the message to Toastify, which renders it at the top right. #}
|
||||||
{% set levels = { error: 'bg-red-600', warning: 'bg-amber-600', success: 'bg-emerald-600', info: 'bg-primary' } %}
|
{% 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 %}
|
{% for message in messages %}
|
||||||
<div class="{{ levels[level] }} text-white rounded shadow-lg px-4 py-3 text-sm">
|
<div {{ stimulus_controller('toast', { text: message|trans, class: 'toastify--' ~ level }) }}></div>
|
||||||
{{ message|trans }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
{# Swapped out of band after a booking, hence the stable id and the conditional attribute. #}
|
{# Swapped out of band after a booking, hence the stable id and the conditional attribute. #}
|
||||||
{% block flashes %}
|
{% block flashes %}
|
||||||
<div id="flashes" class="fixed bottom-4 right-4 z-50 space-y-2"
|
<div id="flashes"
|
||||||
{%- if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
|
{%- if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
|
||||||
{% include '_partials/_flashes.html.twig' %}
|
{% include '_partials/_flashes.html.twig' %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
Mit myep anmelden
|
Mit myep anmelden
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
{# Toastify positions itself, so this needs no wrapper -- and the login page has no
|
||||||
{% include '_partials/_flashes.html.twig' %}
|
board to swap, so it needs no #flashes container either. #}
|
||||||
</div>
|
{% include '_partials/_flashes.html.twig' %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -48,7 +48,7 @@ class HtmxErrorFlashListenerTest extends WebTestCase
|
|||||||
self::assertFalse($this->client->getResponse()->headers->has('HX-Retarget'));
|
self::assertFalse($this->client->getResponse()->headers->has('HX-Retarget'));
|
||||||
|
|
||||||
$this->client->request('GET', '/');
|
$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);
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
|
|
||||||
$this->client->request('GET', '/');
|
$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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user