This commit is contained in:
mark committed 2026-09-08 08:24:21 -04:00
1 parent 9407c26d2d
commit bfb3c87558
5 files changed
+204 -129

No files matched your search

+54 -25
View File
@@ -100,39 +100,68 @@
<div class="row justify-content-center">
{# The swap target. hx-target on <body> names this id, so every boosted
link lands here -- remove it and a click does nothing at all while a
full navigation still works. #}
<div id="content" class="col-12 col-xl-8" hx-history-elt>
full navigation still works.
It is also where a verse click becomes a request. htmx's bootstrap-modal
example puts hx-get on the element that opens the dialog; the element
here is a verse number in a generated book, and there are 38,000 of
them -- an attribute apiece would cost more than the whole shell, and a
listener apiece more than the reading. So one handler sits on the
container they all swap into, and names the verse it found. Delegated,
so it survives every swap: this element is never replaced, only filled.
hx-vals cannot do this. htmx evaluates it as Function("return (...)")()
with nothing passed in, so an expression there can read the document but
not the click -- and which verse was clicked is the one thing that has
to travel.
Enter and space go through the same door rather than a second one:
markVerses() in app.js gives every verse role=button and tabindex, and
a button that answers the keyboard by being clicked is a button with one
behaviour instead of two. #}
<div id="content" class="col-12 col-xl-8" hx-history-elt
hx-on:click="var verse = event.target.closest('.v[id]');
if (verse) htmx.ajax('GET', 'api/verse?at=' + verse.id,
{ target: '#verse-dialog .modal-content',
swap: 'innerHTML' });"
hx-on:keydown="if (event.key !== 'Enter' &amp;&amp; event.key !== ' ') return;
if (!event.target.matches('.v[id]')) return;
event.preventDefault();
event.target.click();">
{% block content %}{% endblock %}
</div>
</div>
{% include "version.njk" %}
</main>
{# Chrome, so it is written once here instead of built in JavaScript on every
page. app.js only fills in the verse it was opened for and shows it. #}
{# The verse dialog: empty in the page, filled per request. Bootstrap owns
showing it, dismissing it, the backdrop and Escape; sw.js owns everything
inside, and templates/verse.njk is what lands in the modal-content below.
Opened by the arrival of the content rather than by data-bs-toggle on
whatever was clicked. The example puts that attribute on its trigger, which
is not available here for the same reason hx-get is not -- see #content
above -- and the swap is the honest signal anyway: there is nothing worth
showing until it is here. A verse the corpus does not have is answered 204,
which swaps nothing, which opens nothing.
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.
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. #}
<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:bookmark-saved="var dialog = this;
setTimeout(function () {
bootstrap.Modal.getOrCreateInstance(dialog).hide();
}, 900);">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5 verse-dialog-ref" id="verse-dialog-ref"></h2>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
{% include "status.njk" %}
</div>
<div class="modal-footer">
<form class="verse-dialog-actions d-flex flex-wrap gap-2 m-0"
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
<input type="hidden" name="ref" value="">
<input type="hidden" name="href" value="">
<button class="btn btn-outline-secondary" type="button"
data-action="copy">Copy link</button>
<button class="btn btn-primary" type="submit">Set bookmark</button>
</form>
</div>
</div>
<div class="modal-content"></div>
</div>
</div>
+60
View File
@@ -0,0 +1,60 @@
{# The verse dialog's contents: what this verse is called, a link to it, and
the one thing you can do with it.
Rendered by sw.js for the verse that was clicked and swapped into the empty
modal-content in base.njk -- the shape htmx's bootstrap-modal example uses.
Bootstrap owns showing, dismissing, the backdrop and Escape; everything
here is what is inside.
Context: verse (ref, href, url).
All three are built in the worker, from the book in the address bar and the
anchor that was clicked. app.js used to read them off the page -- the book
out of article[data-name], the chapter out of the enclosing section -- which
put the reference in two places and left the spelling of an anchor known to
both the file that generates them and the file that scrapes them back.
The link is a field rather than a line of text because it is the fallback
for the button beside it: navigator.clipboard exists only in a secure
context, so over plain http:// from a LAN address there is nothing to write
with and a selected field is the whole of what is left. Readonly, so it
reads as something to take rather than something to fill in. #}
<div class="modal-header">
<h2 class="modal-title fs-5 verse-dialog-ref" id="verse-dialog-ref">{{ verse.ref }}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<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
because they are this dialog's whole subject: the reader chose them by
clicking a verse, and there is nothing here to edit. #}
<form class="verse-dialog-actions d-flex flex-wrap gap-2 m-0"
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
<input type="hidden" name="ref" value="{{ verse.ref }}">
<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. #}
<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');
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.'; });
} else {
line.textContent = document.execCommand('copy')
? 'Link copied.' : 'Copy the link above.';
}">Copy link</button>
<button class="btn btn-primary" type="submit">Set bookmark</button>
</form>
</div>