// The shell's behaviour: fold a section, open the sidebar on a narrow screen,
// and the account menu.
//
// The sidebar itself is rendered by the server (templates/shell.html), so this
// file never paints navigation - it only toggles classes and reports the result.
// That is what keeps a page load from flashing an empty rail.
(() => {
const sidebar = document.getElementById("sidebar");
const scrim = document.getElementById("scrim");
const navToggle = document.getElementById("navToggle");
// ---- folding ----
//
// The whole folded set is sent on every toggle, not a delta. The sidebar knows
// its complete state after each click, so sending it costs one small request
// and cannot drift out of step with the screen the way an add and a remove can.
//
// Writes are debounced: folding three sections in a row is one request, and a
// burst of clicks does not queue a burst of writes.
let saveTimer = null;
// Sections and categories together. Both kinds of key live in one list on the
// account (see app/core/nav_state.py), so collecting them separately and
// sending two requests would be two ways for the same list to be written.
const foldedKeys = () =>
[...document.querySelectorAll(".navsec.folded, .navcat.folded")]
.map((n) => n.dataset.key)
.filter(Boolean);
function save() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
// Deliberately unhandled beyond a log. A fold is a preference; failing to
// persist one must not put an error in front of somebody who was only
// tidying their sidebar. It comes back on the next successful toggle.
api.put("/api/nav/collapsed", { collapsed: foldedKeys() })
.catch((err) => console.debug("nav fold not saved:", err));
}, 400);
}
for (const section of document.querySelectorAll(".navsec")) {
const twisty = section.querySelector(".navsec-tw");
const heading = section.querySelector(".h");
if (!twisty || !heading) continue;
const toggle = () => {
const folded = section.classList.toggle("folded");
twisty.setAttribute("aria-expanded", String(!folded));
save();
};
twisty.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
toggle();
});
// The rest of the heading is a generous click target for the mouse. No role
// and no tabindex: the keyboard path is the button above, and duplicating it
// here would only add a second tab stop that does the same thing.
heading.addEventListener("click", (e) => {
if (!e.target.closest("button")) toggle();
});
}
// Categories fold the same way one level down. `.navcat-fixed` - the Ungrouped
// group - has no twisty and is skipped: it is not a category, so there is
// nothing on the server to remember about it.
for (const category of document.querySelectorAll(".navcat")) {
const twisty = category.querySelector(".navcat-tw");
const heading = category.querySelector(".navcat-h");
if (!twisty || !heading || category.classList.contains("navcat-fixed")) continue;
const toggle = () => {
const folded = category.classList.toggle("folded");
twisty.setAttribute("aria-expanded", String(!folded));
save();
};
twisty.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
toggle();
});
heading.addEventListener("click", (e) => {
if (!e.target.closest("button")) toggle();
});
}
// ---- adding a category ----
//
// A prompt() rather than an inline editor, deliberately. The skeleton's job is
// to show that the endpoint exists and what it takes; an inline row with its
// own escape handling, blur semantics and optimistic paint is the application's
// design decision, not the chassis's. Replace it.
const addCategory = document.getElementById("addCategory");
if (addCategory) {
addCategory.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const name = window.prompt("Name the category");
if (!name) return;
try {
await api.post("/api/categories", { name });
// Reloaded rather than painted here: the sidebar is server-rendered, so
// the server is the one thing that knows how it looks. Repainting it in
// JavaScript would mean maintaining that markup twice.
window.location.reload();
} catch (err) {
window.alert(err.message);
}
});
}
// ---- the sidebar on a narrow screen ----
if (navToggle && sidebar) {
const setOpen = (open) => {
sidebar.classList.toggle("open", open);
navToggle.setAttribute("aria-expanded", String(open));
if (scrim) scrim.hidden = !open;
};
navToggle.addEventListener("click", () => {
setOpen(!sidebar.classList.contains("open"));
});
if (scrim) scrim.addEventListener("click", () => setOpen(false));
// Escape closes it, because the scrim is a mouse affordance and a keyboard
// user needs a way out that is not tabbing to it.
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && sidebar.classList.contains("open")) setOpen(false);
});
}
// ---- account menu ----
const acctBtn = document.getElementById("acctBtn");
const acctDrop = document.getElementById("acctDrop");
if (acctBtn && acctDrop) {
const setMenu = (open) => {
acctDrop.hidden = !open;
acctBtn.setAttribute("aria-expanded", String(open));
};
acctBtn.addEventListener("click", (e) => {
e.stopPropagation();
setMenu(acctDrop.hidden);
});
document.addEventListener("click", () => setMenu(false));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") setMenu(false);
});
}
})();