This commit is contained in:
mark committed 2026-09-09 09:08:19 -04:00
1 parent 37ac9c2757
commit ce73c76126
6 files changed
+158 -38

No files matched your search

+32 -9
View File
@@ -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>
-5
View File
@@ -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>
+42
View File
@@ -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
View File
@@ -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>