agilentics / boiler
// 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);
    });
  }
})();