Surface Drupal messages as small top-right toasts, auto-dismissed

Two problems: (1) the node form's modal AJAX submit handler
(figli_compta_ledger_node_form_ajax_submit) closed the dialog on
success without ever surfacing whatever messenger() had queued during
save() -- e.g. NodeForm's own "Ligne comptable X has been updated."
Since the /lignes table only ever refreshes via AJAX afterwards, never
a full page load, that message was silently lost instead of just
delayed. Now forwarded via MessageCommand before closing the modal.
(2) Gin's default message styling is a full-size flash banner meant
for a single admin form, in-flow (pushing the page down) and far too
large for frequent background feedback on this dashboard.

admin-chrome.css repositions [data-drupal-messages] /
[data-drupal-messages-fallback] to fixed top-right (out of document
flow, so nothing shifts) and shrinks text/padding/dismiss-button size
drastically while keeping them legible. admin-chrome.js auto-dismisses
non-error messages after 2s via a MutationObserver (Drupal.Message.add()
just appends a DOM node, it never re-fires attachBehaviors(), so
behaviors/once() would miss anything added after the initial page
load); error messages are left alone, requiring the existing manual
click on Gin's own dismiss button. Both attached globally via the
existing admin_chrome library (already loaded on every page for the
phantom-top-bar fix), consistent with that precedent.

Verified: a real full-form save on a clean node returns exactly
{message (status, "has been updated"), closeDialog} in that order: a
répartition-inconsistent node still correctly blocks the full form
(unaffected, skip_validation is never used here); a synthetic
status/warning/error trio confirms fixed positioning, no layout
shift, ~40% smaller text, and status/warning auto-hiding while error
persists until manually dismissed.
This commit is contained in:
2026-09-04 22:17:52 +02:00
parent 3ec894c167
commit 2947a542da
4 changed files with 148 additions and 0 deletions
@@ -12,3 +12,70 @@
.top-bar ~ .dialog-off-canvas-main-canvas {
margin-block-start: 0 !important;
}
/* Drupal status messages: fixed top-right, out of document flow, so they
never shift the page layout the way the default in-flow placement does
-- and shrunk to a fraction of Gin's default size, which is sized for a
single full-width flash banner on an admin form, not a toast for
frequent background feedback here. Targets both the real region
([data-drupal-messages], present once a message is queued on a normal
page load) and the JS-only fallback container
([data-drupal-messages-fallback], used when Drupal.Message.add() -- our
own MessageCommand-driven AJAX messages included -- has no region to
attach to). Auto-dismiss timing for non-error messages is handled in
admin-chrome.js. */
[data-drupal-messages],
[data-drupal-messages-fallback] {
position: fixed !important;
top: 0.75rem;
right: 0.75rem;
left: auto !important;
width: auto;
max-width: 22rem;
z-index: 1000;
}
[data-drupal-messages] .messages-list__wrapper,
[data-drupal-messages] .messages__wrapper,
[data-drupal-messages-fallback] .messages-list__wrapper,
[data-drupal-messages-fallback] .messages__wrapper {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
[data-drupal-messages] .messages,
[data-drupal-messages-fallback] .messages {
padding: 0.5rem 0.6rem;
font-size: 0.78rem;
line-height: 1.3;
border-radius: 6px;
}
[data-drupal-messages] .messages__header,
[data-drupal-messages-fallback] .messages__header {
margin-block-end: 0.15rem;
margin-inline-end: 1.1em;
}
[data-drupal-messages] .messages__header::before,
[data-drupal-messages-fallback] .messages__header::before {
width: 1rem !important;
height: 1rem !important;
}
[data-drupal-messages] .messages__title,
[data-drupal-messages-fallback] .messages__title {
font-size: 0.78rem;
}
[data-drupal-messages] .messages__title,
[data-drupal-messages] .messages__content,
[data-drupal-messages-fallback] .messages__title,
[data-drupal-messages-fallback] .messages__content {
margin-inline-start: 1.4rem;
}
[data-drupal-messages] .button--dismiss,
[data-drupal-messages-fallback] .button--dismiss {
height: 20px !important;
width: 20px !important;
margin: 0.3rem !important;
}
[data-drupal-messages] .button--dismiss .icon-close,
[data-drupal-messages-fallback] .button--dismiss .icon-close {
mask-size: 11px 11px !important;
}
@@ -29,3 +29,7 @@ admin_chrome:
css:
theme:
css/admin-chrome.css: {}
js:
js/admin-chrome.js: {}
dependencies:
- core/drupal
@@ -14,6 +14,7 @@ use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\CloseModalDialogCommand;
use Drupal\Core\Ajax\MessageCommand;
use Drupal\Core\Ajax\ReplaceCommand;
/**
@@ -121,6 +122,18 @@ function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterfa
$response->addCommand(new ReplaceCommand('#' . $form['#id'], $form));
return $response;
}
// Surface whatever messenger() queued during save() -- e.g. NodeForm's
// own "Ligne comptable X has been created/updated." -- before closing
// the modal. Without this the message is silently lost: the /lignes
// table only ever refreshes via AJAX afterwards ("dialog:afterclose" ->
// reloadWindow()), never a full page load, so a session-queued message
// would otherwise sit unseen until some unrelated future page render.
foreach (\Drupal::messenger()->all() as $type => $messages) {
foreach ($messages as $message) {
$response->addCommand(new MessageCommand($message, NULL, ['type' => $type], FALSE));
}
}
\Drupal::messenger()->deleteAll();
$response->addCommand(new CloseModalDialogCommand());
return $response;
}
@@ -0,0 +1,64 @@
/**
* @file
* Non-error Drupal status messages (status/warning) auto-dismiss after a
* couple seconds; error messages always require an explicit click on
* Gin's own dismiss button (.js-message-button-hide) -- something that
* needs attention shouldn't vanish on its own.
*
* Drupal.Message.add() (used both for messages already in the initial
* page render and for MessageCommand-driven AJAX messages) just appends a
* DOM node directly -- it never re-fires Drupal.attachBehaviors(), so a
* behaviors/once()-based approach would miss anything added after the
* initial page load. A MutationObserver sees every insertion path.
*/
(function (Drupal) {
'use strict';
var AUTO_DISMISS_MS = 2000;
function dismiss(el) {
var btn = el.querySelector('.js-message-button-hide');
if (btn) {
btn.click();
} else {
el.style.opacity = 0;
el.classList.add('visually-hidden');
}
}
function handleMessage(el) {
if (el.dataset.figliAutoDismissSeen) return;
el.dataset.figliAutoDismissSeen = '1';
if (el.classList.contains('messages--error')) return;
setTimeout(function () {
dismiss(el);
}, AUTO_DISMISS_MS);
}
function scan(root) {
root.querySelectorAll('.messages-list__item').forEach(handleMessage);
}
function init() {
scan(document);
new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
mutation.addedNodes.forEach(function (node) {
if (node.nodeType !== 1) return;
if (node.classList && node.classList.contains('messages-list__item')) {
handleMessage(node);
}
if (node.querySelectorAll) {
scan(node);
}
});
});
}).observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(Drupal);