Toast
This commit is contained in:
1 parent
37ac9c2757
commit
ce73c76126
6 files changed
+158
-38
No files matched your search
+1
-1
@@ -39,7 +39,7 @@
|
||||
'verses.njk',
|
||||
'verse.njk',
|
||||
'version.njk',
|
||||
'status.njk',
|
||||
'toast.njk',
|
||||
'notice.njk'
|
||||
];
|
||||
|
||||
|
||||
+54
-13
@@ -13,6 +13,7 @@
|
||||
// POST /api/bookmark set the bookmark, and return the slot
|
||||
// GET /api/reference the navbar's chapter and verse picker
|
||||
// GET /api/verse the dialog for one verse
|
||||
// GET /api/toast one confirmation, by name
|
||||
//
|
||||
// Anything with a dot in its last segment is a file, not a route, and is
|
||||
// served from the cache or the network without going near the router. Pages
|
||||
@@ -436,8 +437,8 @@ async function servePage(path, code, call) {
|
||||
if (!call.navigate && !call.restore) {
|
||||
// The picker is in the navbar and every boosted link targets #content, so
|
||||
// a swap never reaches it. It rides along out of band instead, the way a
|
||||
// bookmark write carries the modal's status line: one response, both
|
||||
// halves of the page. Sent even when it is null -- that is the hidden
|
||||
// bookmark write carries its toast: one response, both halves of the
|
||||
// page. Sent even when it is null -- that is the hidden
|
||||
// state, and arriving at the book list has to put the picker away.
|
||||
return fragment + '\n'
|
||||
+ await view.render('reference.njk', { reference: reference, oob: true });
|
||||
@@ -578,20 +579,60 @@ router.post('/api/bookmark', async (call) => {
|
||||
await withStore('readwrite',
|
||||
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
|
||||
// Only on the way out of a write that committed. base.njk closes the
|
||||
// dialog on this, and a dialog that closed itself on a failure would take
|
||||
// the line explaining the failure with it.
|
||||
return new Response(await bookmarkFragment('Bookmark set.'), {
|
||||
// dialog on this, and there is nothing left in the dialog worth keeping
|
||||
// open for: the confirmation rides out of band into the toast container,
|
||||
// which is not inside it.
|
||||
return new Response(await bookmarkFragment('bookmarked'), {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'HX-Trigger-After-Settle': 'bookmark-saved'
|
||||
'HX-Trigger-After-Settle': 'verse-done'
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
return bookmarkFragment('Could not save: storage unavailable.');
|
||||
// No trigger, so the dialog stays where it is. The toast says what
|
||||
// happened whether or not it does, but a dialog that closed on a failed
|
||||
// write would look exactly like one that closed on a successful one.
|
||||
return bookmarkFragment('bookmark-refused');
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ toasts
|
||||
|
||||
// Everything the app says back to a reader who has just done something. Named
|
||||
// rather than passed as text: the page asks for 'copied', not for a sentence,
|
||||
// so the wording is here with the rest of the app's words and an attribute in
|
||||
// a template never has to spell one out. bad is the ones that did not happen.
|
||||
const TOASTS = {
|
||||
'copied': { text: 'Link copied.' },
|
||||
'copy-refused': { text: 'Copy the link above — this browser would not '
|
||||
+ 'take it.', bad: true },
|
||||
'bookmarked': { text: 'Bookmark set.' },
|
||||
'bookmark-refused': { text: 'Could not save the bookmark: storage '
|
||||
+ 'unavailable.', bad: true }
|
||||
};
|
||||
|
||||
// oob is how it travels: on its own to the toast container, which is what the
|
||||
// copy button asks for, or out of band on an answer that is already going
|
||||
// somewhere else, which is what a bookmark write sends.
|
||||
function toastFragment(say, oob) {
|
||||
const toast = TOASTS[say];
|
||||
return view.render('toast.njk', { text: toast.text, bad: toast.bad, oob: oob });
|
||||
}
|
||||
|
||||
// One toast, by name.
|
||||
//
|
||||
// say=copied which one, from the table above.
|
||||
//
|
||||
// 204 for a name that is not in it, on the same reasoning api/verse 204s for a
|
||||
// verse that is not there: htmx swaps nothing, so a typo appends nothing,
|
||||
// rather than an empty toast sliding up to say nothing at all.
|
||||
router.get('/api/toast', (call) => {
|
||||
const say = call.url.searchParams.get('say');
|
||||
if (!TOASTS[say]) return new Response('', { status: 204 });
|
||||
return toastFragment(say, false);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- library
|
||||
|
||||
// The background download of the whole corpus.
|
||||
@@ -693,13 +734,13 @@ function getBookmark() {
|
||||
.then((mark) => mark || DEFAULT_BOOKMARK);
|
||||
}
|
||||
|
||||
async function bookmarkFragment(status) {
|
||||
async function bookmarkFragment(say) {
|
||||
let html = await view.render('bookmark.njk', { mark: await getBookmark() });
|
||||
if (status) {
|
||||
// Out of band, so one response updates both the navbar slot and the
|
||||
// modal's status line without the modal having to ask separately. Same
|
||||
// template the shell uses, so the two cannot drift apart.
|
||||
html += '\n' + await view.render('status.njk', { text: status, oob: true });
|
||||
if (say) {
|
||||
// Out of band, so one response updates the navbar slot and raises the
|
||||
// toast without the page having to ask for the second one. Same template
|
||||
// and same table api/toast answers from, so the two cannot drift apart.
|
||||
html += '\n' + await toastFragment(say, true);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
@@ -147,23 +147,46 @@
|
||||
|
||||
after-swap and not after-settle: settling is the 20ms attribute pass and
|
||||
this dialog has nothing to settle. Only the modal-content swap can reach
|
||||
here -- the bookmark write targets the navbar slot, and the status line it
|
||||
sends back rides in out of band, which fires its own event and not this one.
|
||||
here -- the bookmark write targets the navbar slot, and the toast it sends
|
||||
back rides in out of band, which fires its own event and not this one.
|
||||
|
||||
bookmark-saved comes from sw.js once the write has committed, and only then:
|
||||
a dialog that closed itself on a failed write would take the message
|
||||
explaining the failure with it. #}
|
||||
verse-done is the dialog saying it has finished its job, and it arrives
|
||||
from both of the things in it: sw.js sends it as a trigger header on a
|
||||
bookmark write that committed, and the copy button dispatches it when the
|
||||
clipboard has actually taken the link. Neither sends it when the action
|
||||
failed -- the field in the dialog is the fallback for a copy that was
|
||||
refused, and there is nothing to take down the dialog for when there is
|
||||
nothing to celebrate.
|
||||
|
||||
Closed at once rather than after a pause. The pause was there to let the
|
||||
status line inside be read before the dialog took it away; the line is a
|
||||
toast now, outside and above all of this, so it outlives the dialog and
|
||||
there is nothing left to wait for. #}
|
||||
<div class="modal fade" id="verse-dialog" tabindex="-1"
|
||||
aria-labelledby="verse-dialog-ref" aria-hidden="true"
|
||||
hx-on::after-swap="bootstrap.Modal.getOrCreateInstance(this).show();"
|
||||
hx-on:bookmark-saved="var dialog = this;
|
||||
setTimeout(function () {
|
||||
bootstrap.Modal.getOrCreateInstance(dialog).hide();
|
||||
}, 900);">
|
||||
hx-on:verse-done="bootstrap.Modal.getOrCreateInstance(this).hide();">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Where confirmations land. Empty in the page and filled a toast at a time:
|
||||
api/toast appends one when the copy button asks for it, and a bookmark
|
||||
write sends one out of band on its own answer. templates/toast.njk is what
|
||||
lands here, and each one shows and then removes itself, so this is empty
|
||||
again a few seconds later.
|
||||
|
||||
Fixed, and outside #content, for the same reason the dialog is: it has to
|
||||
survive the swap under it. It also has to survive the dialog -- a copy
|
||||
confirmed by a toast inside the modal would go when the modal did.
|
||||
|
||||
Bottom right, and last in the document, so it covers the text rather than
|
||||
moving it, and so nothing in the reading order comes after it. It sits
|
||||
above the modal without being asked to: Bootstrap gives .toast a z-index of
|
||||
1090 against the modal's 1055, and this container takes none of its own,
|
||||
which is what leaves that comparison to the toast. #}
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toasts"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +0,0 @@
|
||||
{# The modal's status line. One template for both paths: the shell renders it
|
||||
empty, and the bookmark API re-renders it with oob set so a single response
|
||||
updates the header slot and this line together. #}
|
||||
<p class="verse-dialog-status form-text m-0" id="verse-dialog-status"
|
||||
role="status" aria-live="polite"{% if oob %} hx-swap-oob="true"{% endif %}>{{ text }}</p>
|
||||
@@ -0,0 +1,42 @@
|
||||
{# One confirmation: something the reader just did, said once and then gone.
|
||||
|
||||
Bootstrap's custom-content shape -- a flex row of body and close button,
|
||||
with no toast-header above it. The header carries a title, an icon and a
|
||||
timestamp, which is what a notification arriving from elsewhere needs; this
|
||||
is the answer to a button pressed a second ago, and the sentence is the
|
||||
whole of it.
|
||||
|
||||
Context: text, bad (it did not happen), oob.
|
||||
|
||||
Two ways in, the same element either way. api/toast renders it alone and
|
||||
htmx appends it to the container; the bookmark write renders it with oob
|
||||
set and it rides along on the answer that updates the navbar slot -- one
|
||||
response, both halves of the page, the way the modal's status line used to.
|
||||
The wrapper is how an out-of-band element with no id of its own says where
|
||||
it goes: htmx appends the wrapper's children to the named container and
|
||||
drops the wrapper itself.
|
||||
|
||||
It shows itself on arrival and removes itself once it has gone. hx-on::load
|
||||
is htmx's own event, fired on content it has just inserted and processed,
|
||||
so it covers both ways in -- and it is the same move base.njk makes to open
|
||||
the dialog: the arrival of the content is the signal to show it. Bootstrap
|
||||
owns the timer, the fade, the close button and Escape from there. Removing
|
||||
the element on hidden.bs.toast is what keeps the container from filling up
|
||||
with dead markup: a toast that has been read is not history, and nothing
|
||||
here ever looks at it again. #}
|
||||
{% if oob %}<div hx-swap-oob="beforeend:#toasts">{% endif %}
|
||||
<div class="toast align-items-center{% if bad %} text-bg-danger{% endif %}"
|
||||
role="alert" aria-live="assertive" aria-atomic="true"
|
||||
hx-on::load="bootstrap.Toast.getOrCreateInstance(this).show();"
|
||||
hx-on:hidden.bs.toast="this.remove();">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">{{ text }}</div>
|
||||
{# me-2 m-auto, not ms-auto: the row is body then button, and the body is
|
||||
what grows -- the button only has to sit centred against however many
|
||||
lines it turns out to be. #}
|
||||
<button type="button"
|
||||
class="btn-close{% if bad %} btn-close-white{% endif %} me-2 m-auto"
|
||||
data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% if oob %}</div>{% endif %}
|
||||
+29
-10
@@ -28,7 +28,6 @@
|
||||
<label class="visually-hidden" for="verse-dialog-url">Link to {{ verse.ref }}</label>
|
||||
<input class="verse-dialog-url form-control form-control-sm" id="verse-dialog-url"
|
||||
type="text" readonly value="{{ verse.url }}">
|
||||
{% include "status.njk" %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
{# The form is the bookmark write and nothing else. ref and href are hidden
|
||||
@@ -40,20 +39,40 @@
|
||||
<input type="hidden" name="href" value="{{ verse.href }}">
|
||||
{# Selected first, written second: the selection is what makes the field
|
||||
useful when the write is refused, and it costs nothing when it is not.
|
||||
The status line is only told once the clipboard has actually taken it,
|
||||
which is why the promise is answered rather than assumed. #}
|
||||
The toast is only raised once the clipboard has actually taken the link,
|
||||
which is why the promise is answered rather than assumed.
|
||||
|
||||
Both outcomes are a toast, and neither is written here: api/toast is
|
||||
asked for one by name, so the wording lives in the worker with the rest
|
||||
of the app's words rather than in the middle of an attribute. It is the
|
||||
same request the dialog itself arrives on -- htmx, the worker, a
|
||||
fragment -- with the container in base.njk as the target.
|
||||
|
||||
Only a copy that happened ends the dialog. A refused one leaves it
|
||||
standing, because the field above it, selected, is the whole of what is
|
||||
left to copy with. #}
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
hx-on:click="var field = document.getElementById('verse-dialog-url');
|
||||
var line = document.getElementById('verse-dialog-status');
|
||||
hx-on:click="var button = this;
|
||||
var field = document.getElementById('verse-dialog-url');
|
||||
var toast = function (say) {
|
||||
htmx.ajax('GET', 'api/toast?say=' + say,
|
||||
{ target: '#toasts', swap: 'beforeend' });
|
||||
};
|
||||
var copied = function () {
|
||||
toast('copied');
|
||||
button.dispatchEvent(
|
||||
new CustomEvent('verse-done', { bubbles: true }));
|
||||
};
|
||||
var refused = function () { toast('copy-refused'); };
|
||||
field.focus();
|
||||
field.select();
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(field.value).then(
|
||||
function () { line.textContent = 'Link copied.'; },
|
||||
function () { line.textContent = 'Copy the link above.'; });
|
||||
navigator.clipboard.writeText(field.value)
|
||||
.then(copied, refused);
|
||||
} else if (document.execCommand('copy')) {
|
||||
copied();
|
||||
} else {
|
||||
line.textContent = document.execCommand('copy')
|
||||
? 'Link copied.' : 'Copy the link above.';
|
||||
refused();
|
||||
}">Copy link</button>
|
||||
<button class="btn btn-primary" type="submit">Set bookmark</button>
|
||||
</form>
|
||||
|
||||
Reference in new issue
Block a user