toats notifications in the web
This commit is contained in:
2
server
2
server
Submodule server updated: aebe3581b6...8ed666531b
7
web/askama.toml
Normal file
7
web/askama.toml
Normal 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"]
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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""#));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
26
web/templates/ui/toast.html
Normal file
26
web/templates/ui/toast.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user