Skip to main content

max / goingson

6.8 KB · 159 lines History Blame Raw
1 // Folding a run's overflow into a menu, because only a browser can measure it.
2 //
3 // `makeover_layout::Fallback::Menu` says the members a `Shed` would drop move
4 // into one overflow control instead. That needs to know the run is out of room,
5 // and CSS cannot ask: `@container` compares against a `<length>`, and there is
6 // no `@container (inline-size < min-content)`. Every honest CSS spelling needs
7 // an authored breakpoint, which is the thing the room ruling exists to forbid.
8 // So the measuring is here, where the browser already knows.
9 //
10 // It reads what makeover-webview emits and nothing else: `.run-menu` for a run
11 // that asked, and `.cell-drops-first` / `.cell-drops-next` for what a member is
12 // worth. The control it builds carries that crate's `.run-overflow` classes, so
13 // the surface and the shadow are the design system's and only the measuring is
14 // this file's.
15 //
16 // Degrading is the point of the mark. A page that never runs this keeps
17 // `.run-menu`'s own `flex-wrap: wrap` and every member stays reachable, which is
18 // rule 1; `data-menu` is what switches the run back to `nowrap`, and it is
19 // written from here, so the fallback is the default rather than the failure.
20 //
21 // The MNW server carried a copy of this for its four Askama tab strips
22 // (`core/tabs.ts`'s `tabOverflow`), which looked for `.tabs` and never saw the
23 // described strips at all.
24 (() => {
25 "use strict";
26
27 // What a member is worth when room runs out, lowest first. Read off the
28 // classes rather than off an attribute of our own: these are what
29 // `Ranked::priority` already reaches the markup as, and inventing a second
30 // spelling is how two encodings of one fact stop agreeing.
31 const TIERS = ["cell-drops-first", "cell-drops-next"];
32
33 // A tab strip is one member of its run and its tabs are what fold. The five
34 // described MNW strips are all this shape: `across(Fallback::Menu)` on a
35 // tab group whose run holds no members of its own, so a reading that only
36 // looked at the run's children would find one strip that always fits and
37 // fold nothing.
38 const STRIP = '[role="tablist"]';
39
40 /**
41 * The element whose children fold, and the children in shedding order.
42 *
43 * Order is by priority tier and then last-first within a tier. Last-first
44 * is what "shed by priority, not by document order" leaves once priority is
45 * spent: among members worth the same, the ones at the end are the ones a
46 * reader reaches for least. For a strip, where no tab carries a priority at
47 * all, that is the whole rule and it is the one the description means --
48 * the last tabs fold away and the first stay.
49 */
50 const shedding = (run) => {
51 const strip = run.querySelector(STRIP);
52 const host = strip ?? run;
53 const members = [...host.children].filter(
54 (el) => !el.classList.contains("run-overflow"),
55 );
56 const order = [];
57 for (const tier of TIERS) {
58 order.push(...members.filter((el) => el.classList.contains(tier)).reverse());
59 }
60 order.push(
61 ...members
62 .filter((el) => !TIERS.some((tier) => el.classList.contains(tier)))
63 .reverse(),
64 );
65 return { host, order };
66 };
67
68 /** The control the shed members move into, built once per host. */
69 const control = (host) => {
70 const existing = host.querySelector(":scope > .run-overflow");
71 if (existing) return existing;
72
73 const wrap = document.createElement("div");
74 wrap.className = "run-overflow";
75
76 const button = document.createElement("button");
77 button.type = "button";
78 button.className = "button";
79 button.setAttribute("aria-expanded", "false");
80 button.setAttribute("aria-haspopup", "true");
81 // A word rather than a glyph. The strips this replaces said "More" and
82 // a screen reader has something to read either way.
83 button.textContent = "More";
84
85 const items = document.createElement("div");
86 items.className = "run-overflow-items";
87 items.hidden = true;
88
89 button.addEventListener("click", () => {
90 const open = items.hidden;
91 items.hidden = !open;
92 button.setAttribute("aria-expanded", String(open));
93 });
94
95 wrap.append(button, items);
96 host.append(wrap);
97 return wrap;
98 };
99
100 /** Put everything back, so a measurement starts from the whole run. */
101 const restore = (host, wrap) => {
102 const items = wrap.querySelector(".run-overflow-items");
103 while (items.firstChild) host.insertBefore(items.firstChild, wrap);
104 wrap.hidden = true;
105 items.hidden = true;
106 wrap.querySelector("button")?.setAttribute("aria-expanded", "false");
107 };
108
109 // The measurement. `scrollWidth > clientWidth` is the browser answering
110 // "does this overflow" directly, which is the question CSS could not be
111 // asked. It is read after a restore, so what is compared is always the
112 // whole run rather than a run already missing what a previous pass took.
113 const tight = (host) => host.scrollWidth > host.clientWidth + 1;
114
115 /** Fold one run until what is left fits. */
116 const fold = (run) => {
117 const { host, order } = shedding(run);
118 const wrap = control(host);
119 restore(host, wrap);
120
121 // Marked before measuring: the mark is what turns wrapping off, and a
122 // wrapped run always fits, so measuring first would always find room.
123 run.setAttribute("data-menu", "");
124
125 const items = wrap.querySelector(".run-overflow-items");
126 let shed = 0;
127 // Never the last one. A control that folded everything into itself
128 // would leave a strip with nothing on it, which is worse than an
129 // overflowing strip and is not what the description asks for.
130 while (tight(host) && shed < order.length - 1) {
131 items.prepend(order[shed]);
132 shed += 1;
133 }
134 wrap.hidden = shed === 0;
135 };
136
137 const all = () => {
138 for (const run of document.querySelectorAll(".run-menu")) fold(run);
139 };
140
141 // Three occasions, and each is a different way the answer changes: the room
142 // changed, the members changed, or the page just arrived. The walk is over
143 // the document every time rather than over a list captured at load, which is
144 // the selection script's reason -- a swap brings new runs and takes old ones
145 // away, and both are found by the next pass.
146 if (typeof ResizeObserver === "function") {
147 const observer = new ResizeObserver(() => all());
148 observer.observe(document.documentElement);
149 } else {
150 window.addEventListener("resize", all);
151 }
152 document.addEventListener("htmx:after:settle", all);
153 if (document.readyState === "loading") {
154 document.addEventListener("DOMContentLoaded", all);
155 } else {
156 all();
157 }
158 })();
159