Skip to main content

max / goingson

4.9 KB · 111 lines History Blame Raw
1 // Folding a branch of an outline, which is the reader tidying their own view.
2 //
3 // `Row::depth` and `Row::open` describe a hierarchy as a flat list of rows,
4 // each saying how deep it sits. Which rows a shut branch hides falls out of
5 // that reading, and the renderer already did it once: every row is in the
6 // document and the ones under a shut branch arrived `hidden`. What is left is
7 // the gesture, and the gesture is a browser's.
8 //
9 // Nothing here asks the app anything. Folding changes what the reader is
10 // looking at and nothing anyone else can observe -- see `Row::open` for why
11 // that is not a write -- so a round trip would be a request for rows the page
12 // is already holding.
13 //
14 // It reads two hooks the renderer emits and nothing else: `[data-disclose]` on
15 // a branch's chevron, and `data-depth` on the rows. A page that never runs this
16 // keeps the outline in the shape the description gave it, with every row still
17 // reachable -- the shut branches simply stay shut.
18 (() => {
19 "use strict";
20
21 const DISCLOSE = "[data-disclose]";
22 const ROW = "[data-row]";
23
24 /** How deep a row sits. Absent means top level, which is what it means. */
25 const depth = (row) => Number(row.getAttribute("data-depth") ?? 0);
26
27 /** The row a chevron belongs to. */
28 const rowOf = (el) => el.closest(ROW);
29
30 /**
31 * The rows under `row`: the ones after it that are deeper, up to the first
32 * one no deeper than it.
33 *
34 * The same reading `quasi_router::folded` makes on the other side, and it
35 * has to be: a reader who folds a branch here and reloads the page must see
36 * the list the server would have folded.
37 */
38 const under = (row) => {
39 const rows = [...row.parentElement.querySelectorAll(`:scope > ${ROW}`)];
40 const start = rows.indexOf(row);
41 const level = depth(row);
42 const kin = [];
43 for (const next of rows.slice(start + 1)) {
44 if (depth(next) <= level) break;
45 kin.push(next);
46 }
47 return kin;
48 };
49
50 /**
51 * Draw the outline under `row` as its chevrons say it should be.
52 *
53 * Walked rather than toggled row by row: a branch inside a shut branch
54 * keeps its own state and stays shut when the outer one opens, which is
55 * what a reader who left it shut expects to come back to. `folded` is the
56 * same one-variable walk the Rust side makes.
57 */
58 const settle = (row) => {
59 // Seeded from the row that was just pressed, which is the whole of
60 // `d131a14c`: this started at `null` and only ever learned about a
61 // *descendant's* chevron, so shutting a branch hid nothing at all.
62 // Every row under a shut one is hidden, and the row's own state is the
63 // only thing that says whether it is shut.
64 let shut = row.querySelector(DISCLOSE)?.getAttribute("aria-expanded") === "false"
65 ? depth(row)
66 : null;
67 for (const next of under(row)) {
68 const level = depth(next);
69 if (shut !== null && level <= shut) shut = null;
70 const hidden = shut !== null;
71 next.hidden = hidden;
72 const chevron = next.querySelector(DISCLOSE);
73 if (!hidden && chevron?.getAttribute("aria-expanded") === "false") {
74 shut = level;
75 }
76 }
77 };
78
79 // Capture, so this runs before anything bound on the way down.
80 //
81 // Found 2026-08-30 by the first test written against this file. Bubbling,
82 // `stopPropagation` here stopped nothing that had already run: a listener
83 // on the row is *below* the document in the bubble path, so it fires
84 // first. That is not hypothetical -- `node.rs` hangs a table row's
85 // `activate` on the row element (`Fires::ClickBeside`) and filters it with
86 // `click[!event.target.closest('[data-act]')]`, which a chevron is not, so
87 // pressing the chevron on a row that both discloses and activates folded
88 // the branch *and* navigated away from it. The emitter's own comment
89 // already claimed otherwise: "filters by it, pressing its chevron does
90 // not."
91 //
92 // Fixed here rather than by widening that filter, because this is the one
93 // place that knows what a chevron is.
94 document.addEventListener("click", (event) => {
95 const chevron = event.target.closest(DISCLOSE);
96 if (!chevron) return;
97 const row = rowOf(chevron);
98 if (!row) return;
99 // Never the row's own act. A chevron is a separate hit target from the
100 // label precisely so that pressing it is not pressing the row, and a
101 // row that activates on click would otherwise navigate underneath the
102 // fold.
103 event.preventDefault();
104 event.stopPropagation();
105 const open = chevron.getAttribute("aria-expanded") === "true";
106 chevron.setAttribute("aria-expanded", String(!open));
107 chevron.setAttribute("aria-label", open ? "Expand" : "Collapse");
108 settle(row);
109 }, true);
110 })();
111