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',
|
'verses.njk',
|
||||||
'verse.njk',
|
'verse.njk',
|
||||||
'version.njk',
|
'version.njk',
|
||||||
'status.njk',
|
'toast.njk',
|
||||||
'notice.njk'
|
'notice.njk'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+54
-13
@@ -13,6 +13,7 @@
|
|||||||
// POST /api/bookmark set the bookmark, and return the slot
|
// POST /api/bookmark set the bookmark, and return the slot
|
||||||
// GET /api/reference the navbar's chapter and verse picker
|
// GET /api/reference the navbar's chapter and verse picker
|
||||||
// GET /api/verse the dialog for one verse
|
// 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
|
// 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
|
// 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) {
|
if (!call.navigate && !call.restore) {
|
||||||
// The picker is in the navbar and every boosted link targets #content, so
|
// 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
|
// 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
|
// bookmark write carries its toast: one response, both halves of the
|
||||||
// halves of the page. Sent even when it is null -- that is the hidden
|
// page. Sent even when it is null -- that is the hidden
|
||||||
// state, and arriving at the book list has to put the picker away.
|
// state, and arriving at the book list has to put the picker away.
|
||||||
return fragment + '\n'
|
return fragment + '\n'
|
||||||
+ await view.render('reference.njk', { reference: reference, oob: true });
|
+ await view.render('reference.njk', { reference: reference, oob: true });
|
||||||
@@ -578,20 +579,60 @@ router.post('/api/bookmark', async (call) => {
|
|||||||
await withStore('readwrite',
|
await withStore('readwrite',
|
||||||
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
|
(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
|
// 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
|
// dialog on this, and there is nothing left in the dialog worth keeping
|
||||||
// the line explaining the failure with it.
|
// open for: the confirmation rides out of band into the toast container,
|
||||||
return new Response(await bookmarkFragment('Bookmark set.'), {
|
// which is not inside it.
|
||||||
|
return new Response(await bookmarkFragment('bookmarked'), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'HX-Trigger-After-Settle': 'bookmark-saved'
|
'HX-Trigger-After-Settle': 'verse-done'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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
|
// ----------------------------------------------------------------- library
|
||||||
|
|
||||||
// The background download of the whole corpus.
|
// The background download of the whole corpus.
|
||||||
@@ -693,13 +734,13 @@ function getBookmark() {
|
|||||||
.then((mark) => mark || DEFAULT_BOOKMARK);
|
.then((mark) => mark || DEFAULT_BOOKMARK);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bookmarkFragment(status) {
|
async function bookmarkFragment(say) {
|
||||||
let html = await view.render('bookmark.njk', { mark: await getBookmark() });
|
let html = await view.render('bookmark.njk', { mark: await getBookmark() });
|
||||||
if (status) {
|
if (say) {
|
||||||
// Out of band, so one response updates both the navbar slot and the
|
// Out of band, so one response updates the navbar slot and raises the
|
||||||
// modal's status line without the modal having to ask separately. Same
|
// toast without the page having to ask for the second one. Same template
|
||||||
// template the shell uses, so the two cannot drift apart.
|
// and same table api/toast answers from, so the two cannot drift apart.
|
||||||
html += '\n' + await view.render('status.njk', { text: status, oob: true });
|
html += '\n' + await toastFragment(say, true);
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@@ -147,23 +147,46 @@
|
|||||||
|
|
||||||
after-swap and not after-settle: settling is the 20ms attribute pass and
|
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
|
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
|
here -- the bookmark write targets the navbar slot, and the toast it sends
|
||||||
sends back rides in out of band, which fires its own event and not this one.
|
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:
|
verse-done is the dialog saying it has finished its job, and it arrives
|
||||||
a dialog that closed itself on a failed write would take the message
|
from both of the things in it: sw.js sends it as a trigger header on a
|
||||||
explaining the failure with it. #}
|
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"
|
<div class="modal fade" id="verse-dialog" tabindex="-1"
|
||||||
aria-labelledby="verse-dialog-ref" aria-hidden="true"
|
aria-labelledby="verse-dialog-ref" aria-hidden="true"
|
||||||
hx-on::after-swap="bootstrap.Modal.getOrCreateInstance(this).show();"
|
hx-on::after-swap="bootstrap.Modal.getOrCreateInstance(this).show();"
|
||||||
hx-on:bookmark-saved="var dialog = this;
|
hx-on:verse-done="bootstrap.Modal.getOrCreateInstance(this).hide();">
|
||||||
setTimeout(function () {
|
|
||||||
bootstrap.Modal.getOrCreateInstance(dialog).hide();
|
|
||||||
}, 900);">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content"></div>
|
<div class="modal-content"></div>
|
||||||
</div>
|
</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>
|
</body>
|
||||||
</html>
|
</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>
|
<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"
|
<input class="verse-dialog-url form-control form-control-sm" id="verse-dialog-url"
|
||||||
type="text" readonly value="{{ verse.url }}">
|
type="text" readonly value="{{ verse.url }}">
|
||||||
{% include "status.njk" %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
{# The form is the bookmark write and nothing else. ref and href are hidden
|
{# 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 }}">
|
<input type="hidden" name="href" value="{{ verse.href }}">
|
||||||
{# Selected first, written second: the selection is what makes the field
|
{# 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.
|
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,
|
The toast is only raised once the clipboard has actually taken the link,
|
||||||
which is why the promise is answered rather than assumed. #}
|
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"
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
hx-on:click="var field = document.getElementById('verse-dialog-url');
|
hx-on:click="var button = this;
|
||||||
var line = document.getElementById('verse-dialog-status');
|
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.focus();
|
||||||
field.select();
|
field.select();
|
||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(field.value).then(
|
navigator.clipboard.writeText(field.value)
|
||||||
function () { line.textContent = 'Link copied.'; },
|
.then(copied, refused);
|
||||||
function () { line.textContent = 'Copy the link above.'; });
|
} else if (document.execCommand('copy')) {
|
||||||
|
copied();
|
||||||
} else {
|
} else {
|
||||||
line.textContent = document.execCommand('copy')
|
refused();
|
||||||
? 'Link copied.' : 'Copy the link above.';
|
|
||||||
}">Copy link</button>
|
}">Copy link</button>
|
||||||
<button class="btn btn-primary" type="submit">Set bookmark</button>
|
<button class="btn btn-primary" type="submit">Set bookmark</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in new issue
Block a user