toats notifications in the web

This commit is contained in:
Priec
2026-08-07 21:30:32 +02:00
parent 945dd811f4
commit 7cda7ea833
12 changed files with 102 additions and 25 deletions

2
server

Submodule server updated: aebe3581b6...8ed666531b

7
web/askama.toml Normal file
View File

@@ -0,0 +1,7 @@
# Two template roots. `templates` is this app's own; `penguinui-components` is
# the Penguin UI library checked in beside it, included from templates by its
# own path (e.g. "toast-notification/stacking-toast-notification.html") so the
# component files stay exactly as the library ships them — no vendored copies
# to re-sync when the library is updated.
[general]
dirs = ["templates", "penguinui-components"]

View File

@@ -224,6 +224,13 @@ mod tests {
body.contains("@layer theme, base, app, components, utilities;"), body.contains("@layer theme, base, app, components, utilities;"),
"{path} lost the cascade order app.css depends on" "{path} lost the cascade order app.css depends on"
); );
// Penguin UI's toast stack, included straight from
// penguinui-components. A success anywhere on the page dispatches
// `notify` at it, so a page without it swallows the message.
assert!(
body.contains(r#"x-on:notify.window="addNotification("#),
"{path} is missing the toast stack"
);
} }
} }

View File

@@ -134,8 +134,14 @@ mod tests {
// Once in the inline alert, once in the dialog. // Once in the inline alert, once in the dialog.
assert_eq!(failure.matches("A name is required.").count(), 2); assert_eq!(failure.matches("A name is required.").count(), 2);
// A success only asks the toast stack in `ui/base.html` to show it: it
// dismisses itself, so it neither blocks the page nor stays behind in
// the layout, and it renders nothing of its own here.
let success = render(&Alert::success("Saved", "Two rows written.")); let success = render(&Alert::success("Saved", "Two rows written."));
assert!(success.contains(r#"role="alert""#)); assert!(!success.contains("Template error"), "{success}");
assert!(success.contains("$dispatch('notify'"));
assert!(success.contains(r#"data-message="Two rows written.""#));
assert!(!success.contains(r#"role="dialog""#)); assert!(!success.contains(r#"role="dialog""#));
assert!(!success.contains(r#"role="alert""#));
} }
} }

View File

@@ -15,6 +15,11 @@
:root { color: #17202a; background: #f3f5f7; font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif; } :root { color: #17202a; background: #f3f5f7; font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif; }
body { margin: 0; } body { margin: 0; }
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
/* Penguin UI's toast component file (included by ui/base.html) opens with the
demo buttons that trigger it in the library's own preview. The file is used
unmodified, so those buttons come along; this is what hides them. The app's
own trigger is the dispatch in ui/toast.html. */
.penguin-toast > button { display: none; }
button, input, select, textarea { font: inherit; } button, input, select, textarea { font: inherit; }
select, input, textarea { width: 100%; padding: 9px 10px; border: 1px solid #cbd3dd; border-radius: 6px; color: #1e2938; background: white; } select, input, textarea { width: 100%; padding: 9px 10px; border: 1px solid #cbd3dd; border-radius: 6px; color: #1e2938; background: white; }
textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; } textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; }

View File

@@ -9,6 +9,7 @@
#} #}
{% import "ui/alert.html" as alert %} {% import "ui/alert.html" as alert %}
{% import "ui/dialog.html" as dialog %} {% import "ui/dialog.html" as dialog %}
{% import "ui/toast.html" as toast %}
{# {#
A failure is a dialog, not an inline alert: the builder is taller than the A failure is a dialog, not an inline alert: the builder is taller than the
@@ -17,7 +18,7 @@
what is left on the page after the dialog is dismissed. what is left on the page after the dialog is dismissed.
#} #}
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call alert::success("Draft updated", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.status %}{% call toast::success("Draft updated", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
<section class="builder-section"> <section class="builder-section">

View File

@@ -15,9 +15,10 @@
#} #}
{% import "ui/alert.html" as panel_alert %} {% import "ui/alert.html" as panel_alert %}
{% import "ui/dialog.html" as panel_dialog %} {% import "ui/dialog.html" as panel_dialog %}
{% import "ui/toast.html" as panel_toast %}
{% if standalone_column_panel %} {% if standalone_column_panel %}
{%- if let Some(message) = page.error %}{% call panel_alert::error("Could not add the column", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.error %}{% call panel_alert::error("Could not add the column", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call panel_alert::success("Staged", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.status %}{% call panel_toast::success("Staged", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call panel_dialog::error("Could not add the column", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.error %}{% call panel_dialog::error("Could not add the column", message) %}{% endcall %}{% endif -%}
{% endif %} {% endif %}

View File

@@ -9,9 +9,10 @@
#} #}
{% import "ui/alert.html" as alert %} {% import "ui/alert.html" as alert %}
{% import "ui/dialog.html" as dialog %} {% import "ui/dialog.html" as dialog %}
{% import "ui/toast.html" as toast %}
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call alert::success("Done", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.status %}{% call toast::success("Done", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
{% if let Some(sql) = page.sql %} {% if let Some(sql) = page.sql %}

View File

@@ -4,7 +4,11 @@
the dark variants because the app has no dark theme. The colours come from the dark variants because the app has no dark theme. The colours come from
the @theme block in ui/base.html. the @theme block in ui/base.html.
Imported wherever a form reports its outcome: Failures only: a success is ui/toast.html, which says its piece and then
removes itself rather than staying in the layout. An error has to stay until
it is read, so it is this.
Imported wherever a form reports a failure:
{% import "ui/alert.html" as alert %} {% import "ui/alert.html" as alert %}
{% call alert::error("Could not create the table", message) %}{% endcall %} {% call alert::error("Could not create the table", message) %}{% endcall %}
@@ -28,20 +32,3 @@
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro success(title, message) %}
<div class="relative mt-4 w-full overflow-hidden rounded-md border border-success bg-surface text-onSurface" role="alert">
<div class="flex items-center gap-2 bg-success/10 p-4">
<div class="shrink-0 rounded-full bg-success/15 p-1 text-success">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6" aria-hidden="true">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" />
</svg>
</div>
<div class="ml-2">
<h3 class="text-sm font-semibold text-success">{{ title }}</h3>
<p class="text-sm font-medium whitespace-pre-wrap">{{ message }}</p>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -2,15 +2,19 @@
Standalone swap target for every form POST — crate::ui::Alert. Standalone swap target for every form POST — crate::ui::Alert.
Rendered into the page's #submission-status / #login-status div. Rendered into the page's #submission-status / #login-status div.
A success is an inline alert and nothing more. A failure also gets the A success is a toast: it floats over the page, says its piece and removes
itself, so it neither pushes the form around nor stays behind as a stale
claim about a draft that has since moved on. A failure is the opposite —
it has to stay until it is read — so it keeps the inline alert, plus the
dialog: these divs sit at the bottom of their form, so on any page long dialog: these divs sit at the bottom of their form, so on any page long
enough to scroll — the validation forms, the import form, the table builder enough to scroll — the validation forms, the import form, the table builder
— the alert alone lands off screen and the page looks like it did nothing. — the alert alone lands off screen and the page looks like it did nothing.
#} #}
{% import "ui/alert.html" as alert %} {% import "ui/alert.html" as alert %}
{% import "ui/dialog.html" as dialog %} {% import "ui/dialog.html" as dialog %}
{% import "ui/toast.html" as toast %}
{%- if success -%} {%- if success -%}
{% call alert::success(title, message) %}{% endcall %} {% call toast::success(title, message) %}{% endcall %}
{%- else -%} {%- else -%}
{% call alert::error(title, message) %}{% endcall %} {% call alert::error(title, message) %}{% endcall %}
{% call dialog::error(title, message) %}{% endcall %} {% call dialog::error(title, message) %}{% endcall %}

View File

@@ -44,6 +44,25 @@
--color-onPrimary: #ffffff; --color-onPrimary: #ffffff;
--color-danger: #9d342d; --color-danger: #9d342d;
--color-success: #21643a; --color-success: #21643a;
/*
The same palette again under the names penguinui-components/style.css
declares, for the component files this app includes from the library
unmodified rather than copying — currently the toast. The copies above
renamed these; a file used as the library ships it cannot be renamed,
so the theme answers to both spellings.
*/
--color-surface-alt: #f3f5f7;
--color-on-surface: #465267;
--color-on-surface-strong: #17202a;
--color-on-primary: #ffffff;
--color-info: #1d4e89;
--color-on-info: #ffffff;
--color-on-success: #ffffff;
--color-warning: #8a5a08;
--color-on-warning: #ffffff;
--color-on-danger: #ffffff;
--radius-radius: 6px;
} }
</style> </style>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/focus@3/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/focus@3/dist/cdn.min.js"></script>
@@ -67,5 +86,18 @@
<body> <body>
{% include "ui/navbar.html" %} {% include "ui/navbar.html" %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
{#
Penguin UI's stacking toast notification, straight out of the library and
unmodified — askama.toml puts penguinui-components on the template path, so
this is the library's file, not a copy of it.
Mounted once per page and deliberately outside the block every fragment
swaps into: a success is announced by a fragment dispatching `notify` (see
ui/toast.html), and the toast showing it has to outlive the swap that
follows. The component file also ships the demo buttons that trigger it in
the library's own preview; `.penguin-toast > button` in static/app.css is
what keeps those off the page.
#}
<div class="penguin-toast">{% include "toast-notification/stacking-toast-notification.html" %}</div>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,26 @@
{#
Transient success toast.
The toast itself is Penguin UI's stacking toast notification, used straight
from the library — `penguinui-components/toast-notification/`, included
unmodified by ui/base.html, which is also where the demo trigger buttons that
ship in that file are dealt with. It listens for a `notify` window event.
This file is only the trigger. The library fires `notify` from a button's
x-on:click; here the server decides, so the trigger is an empty element that
dispatches the same event as Alpine initialises it. Alpine initialises
htmx-swapped markup too, so a fragment carrying this shows a toast when it
lands.
{% import "ui/toast.html" as toast %}
{% call toast::success("Draft updated", message) %}{% endcall %}
`title` and `message` travel as data attributes rather than interpolated into
the JS, because Askama escapes an attribute value correctly and a message
containing a quote or an apostrophe would otherwise break the expression.
#}
{% macro success(title, message) %}
<div hidden data-title="{{ title }}" data-message="{{ message }}"
x-data
x-init="$dispatch('notify', { variant: 'success', title: $el.dataset.title, message: $el.dataset.message })"></div>
{% endmacro %}