// Folding a run's overflow into a menu, because only a browser can measure it. // // `makeover_layout::Fallback::Menu` says the members a `Shed` would drop move // into one overflow control instead. That needs to know the run is out of room, // and CSS cannot ask: `@container` compares against a ``, and there is // no `@container (inline-size < min-content)`. Every honest CSS spelling needs // an authored breakpoint, which is the thing the room ruling exists to forbid. // So the measuring is here, where the browser already knows. // // It reads what makeover-webview emits and nothing else: `.run-menu` for a run // that asked, and `.cell-drops-first` / `.cell-drops-next` for what a member is // worth. The control it builds carries that crate's `.run-overflow` classes, so // the surface and the shadow are the design system's and only the measuring is // this file's. // // Degrading is the point of the mark. A page that never runs this keeps // `.run-menu`'s own `flex-wrap: wrap` and every member stays reachable, which is // rule 1; `data-menu` is what switches the run back to `nowrap`, and it is // written from here, so the fallback is the default rather than the failure. // // The MNW server carried a copy of this for its four Askama tab strips // (`core/tabs.ts`'s `tabOverflow`), which looked for `.tabs` and never saw the // described strips at all. (() => { "use strict"; // What a member is worth when room runs out, lowest first. Read off the // classes rather than off an attribute of our own: these are what // `Ranked::priority` already reaches the markup as, and inventing a second // spelling is how two encodings of one fact stop agreeing. const TIERS = ["cell-drops-first", "cell-drops-next"]; // A tab strip is one member of its run and its tabs are what fold. The five // described MNW strips are all this shape: `across(Fallback::Menu)` on a // tab group whose run holds no members of its own, so a reading that only // looked at the run's children would find one strip that always fits and // fold nothing. const STRIP = '[role="tablist"]'; /** * The element whose children fold, and the children in shedding order. * * Order is by priority tier and then last-first within a tier. Last-first * is what "shed by priority, not by document order" leaves once priority is * spent: among members worth the same, the ones at the end are the ones a * reader reaches for least. For a strip, where no tab carries a priority at * all, that is the whole rule and it is the one the description means -- * the last tabs fold away and the first stay. */ const shedding = (run) => { const strip = run.querySelector(STRIP); const host = strip ?? run; const members = [...host.children].filter( (el) => !el.classList.contains("run-overflow"), ); const order = []; for (const tier of TIERS) { order.push(...members.filter((el) => el.classList.contains(tier)).reverse()); } order.push( ...members .filter((el) => !TIERS.some((tier) => el.classList.contains(tier))) .reverse(), ); return { host, order }; }; /** The control the shed members move into, built once per host. */ const control = (host) => { const existing = host.querySelector(":scope > .run-overflow"); if (existing) return existing; const wrap = document.createElement("div"); wrap.className = "run-overflow"; const button = document.createElement("button"); button.type = "button"; button.className = "button"; button.setAttribute("aria-expanded", "false"); button.setAttribute("aria-haspopup", "true"); // A word rather than a glyph. The strips this replaces said "More" and // a screen reader has something to read either way. button.textContent = "More"; const items = document.createElement("div"); items.className = "run-overflow-items"; items.hidden = true; button.addEventListener("click", () => { const open = items.hidden; items.hidden = !open; button.setAttribute("aria-expanded", String(open)); }); wrap.append(button, items); host.append(wrap); return wrap; }; /** Put everything back, so a measurement starts from the whole run. */ const restore = (host, wrap) => { const items = wrap.querySelector(".run-overflow-items"); while (items.firstChild) host.insertBefore(items.firstChild, wrap); wrap.hidden = true; items.hidden = true; wrap.querySelector("button")?.setAttribute("aria-expanded", "false"); }; // The measurement. `scrollWidth > clientWidth` is the browser answering // "does this overflow" directly, which is the question CSS could not be // asked. It is read after a restore, so what is compared is always the // whole run rather than a run already missing what a previous pass took. const tight = (host) => host.scrollWidth > host.clientWidth + 1; /** Fold one run until what is left fits. */ const fold = (run) => { const { host, order } = shedding(run); const wrap = control(host); restore(host, wrap); // Marked before measuring: the mark is what turns wrapping off, and a // wrapped run always fits, so measuring first would always find room. run.setAttribute("data-menu", ""); const items = wrap.querySelector(".run-overflow-items"); let shed = 0; // Never the last one. A control that folded everything into itself // would leave a strip with nothing on it, which is worse than an // overflowing strip and is not what the description asks for. while (tight(host) && shed < order.length - 1) { items.prepend(order[shed]); shed += 1; } wrap.hidden = shed === 0; }; const all = () => { for (const run of document.querySelectorAll(".run-menu")) fold(run); }; // Three occasions, and each is a different way the answer changes: the room // changed, the members changed, or the page just arrived. The walk is over // the document every time rather than over a list captured at load, which is // the selection script's reason -- a swap brings new runs and takes old ones // away, and both are found by the next pass. if (typeof ResizeObserver === "function") { const observer = new ResizeObserver(() => all()); observer.observe(document.documentElement); } else { window.addEventListener("resize", all); } document.addEventListener("htmx:after:settle", all); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", all); } else { all(); } })();