diff --git a/assets/css/app.css b/assets/css/app.css
index fdadbed..291b653 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -66,3 +66,19 @@
}
.footnotes-title { font-size: 1rem; }
.marker { font-weight: 700; }
+
+/* The navbar is sticky, so an element scrolled to the top of the viewport
+ lands underneath it. These two are everything an anchor in this app points
+ at -- chapter headings and verse numbers -- so the offset lives on them
+ rather than on a scroll container. Roughly the navbar's height. */
+.chapter,
+.v { scroll-margin-top: 4.5rem; }
+
+/* The navbar's reference picker. form-select is width:100% by default, which
+ in a flex row means both selects try to take the whole navbar; and the
+ default right padding leaves the caret sitting on top of a three-digit
+ chapter number. */
+.reference .form-select {
+ width: auto;
+ padding-right: 2rem;
+}
diff --git a/assets/js/app.js b/assets/js/app.js
index a8593fd..11a3455 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -128,7 +128,93 @@
// Close the modal once a bookmark write has come back and swapped, leaving
// the status line up long enough to be read.
if (target.id === 'bookmark') return setTimeout(hideModal, 900);
- if (target.id === 'content') land();
+ if (target.id === 'content') { fillReference(); land(); }
+ });
+
+ // -- reference picker ---------------------------------------------------
+
+ // The "GEN 1:1" in the navbar. Built from the book on the page rather than
+ // from a table shipped beside it: the chapters are the sections and a
+ // chapter's verses are the anchors inside it, so an option can never name a
+ // target that is not there to scroll to, and there is no second file to keep
+ // in step, cache, or version.
+ //
+ // It is not kept in step with the scroll position, on purpose. It says where
+ // you are going, not where you are, and it starts at 1:1 in every book.
+
+ function byId(id) {
+ return document.getElementById(id);
+ }
+
+ function fillOptions(select, items) {
+ select.textContent = '';
+ for (var i = 0; i < items.length; i++) {
+ var option = document.createElement('option');
+ // The value is the id of the element to land on, so nothing downstream
+ // has to know how a verse anchor is spelled.
+ option.value = items[i].value;
+ option.textContent = items[i].label;
+ select.appendChild(option);
+ }
+ }
+
+ function fillVerses(chapterId) {
+ var section = byId(chapterId);
+ var verses = section ? section.querySelectorAll('.v') : [];
+ var items = [];
+ for (var i = 0; i < verses.length; i++) {
+ items.push({ value: verses[i].id,
+ label: (verses[i].textContent || '').trim() });
+ }
+ fillOptions(byId('reference-verse'), items);
+ }
+
+ function fillReference() {
+ var picker = byId('reference');
+ if (!picker) return;
+ var book = document.querySelector('#content article.book');
+ picker.classList.toggle('d-none', !book);
+ picker.classList.toggle('d-flex', !!book);
+ if (!book) return;
+
+ byId('reference-book').textContent = book.getAttribute('data-book') || '';
+
+ var chapters = book.querySelectorAll('.chapter[data-chapter]');
+ var items = [];
+ for (var i = 0; i < chapters.length; i++) {
+ items.push({ value: chapters[i].id,
+ label: chapters[i].getAttribute('data-chapter') });
+ }
+ fillOptions(byId('reference-chapter'), items);
+ if (items.length) fillVerses(items[0].value);
+ }
+
+ // The URL is the whole mechanism: name the anchor, then land on it. Nothing
+ // is stored anywhere else, so a reload or a shared link ends up in the same
+ // place.
+ //
+ // replaceState rather than assigning location.hash: spinning through a
+ // select should not stack one history entry per option, and assigning the
+ // hash hands the scroll to the browser, which Bootstrap has set to smooth.
+ function goTo(id) {
+ var target = byId(id);
+ if (!target) return;
+ history.replaceState(null, '', '#' + id);
+ jumpTo(target);
+ }
+
+ // Delegated at the document, so it survives every swap and binds once.
+ // Changing the chapter rebuilds the verses and lands on the chapter heading,
+ // which is what 1 is directly below; changing the verse lands on the verse.
+ document.addEventListener('change', function (event) {
+ var target = event.target;
+ if (!target || !target.id) return;
+ if (target.id === 'reference-chapter') {
+ fillVerses(target.value);
+ goTo(target.value);
+ } else if (target.id === 'reference-verse') {
+ goTo(target.value);
+ }
});
// A history restore does not go through the request pipeline -- htmx swaps
@@ -137,6 +223,7 @@
// with dead verse numbers.
document.body.addEventListener('htmx:historyRestore', function () {
markVerses();
+ fillReference();
land();
});
@@ -202,6 +289,7 @@
// and it lands after htmx has processed the shell.
document.addEventListener('DOMContentLoaded', function () {
markVerses();
+ fillReference();
scrollToHash();
// Registered from the page it is going to serve, not from a page of its
diff --git a/assets/templates/base.njk b/assets/templates/base.njk
index f89d486..6c2c76f 100644
--- a/assets/templates/base.njk
+++ b/assets/templates/base.njk
@@ -76,6 +76,29 @@
data-bs-theme="dark">
Bible
+ {# The reference picker: which book you are in, and where in it to go.
+ Outside the collapse deliberately -- it is the main way of moving around
+ a book now that the chapter list is gone from the page, and a chapter
+ picker behind a hamburger is no use on a phone.
+
+ Empty here and filled by app.js from the book on the page. The verse
+ list has to be rebuilt in the browser every time the chapter changes, so
+ there is nothing for a template to usefully render first.
+
+ d-none for the empty state rather than the hidden attribute: Bootstrap
+ declares .d-flex after Reboot's [hidden] rule, both !important and at
+ the same specificity, so the utility would win. #}
+