/* fiesta.js: the browser half of Fiesta. First-party, dependency-free, and on by default, since nothing in it has a per-instance decision to make. Everything it enhances also works with scripts disabled. Menus. An open
,
, a nested
, or the topbar's and console's narrow
card (whose content is its next sibling, the tail) closes on an outside click and on Escape, in either spelling. Exclusivity stays native. Switch. A switch's knob can be dragged along its track, and snaps to the nearer end. Touch. A touchstart listener, so iOS applies :active on a tap. Scheme. The colour-scheme menu's forms ([rst-scheme]) apply in place: data-theme on is what the themes read. Who remembers the choice is the page's to say, on , before any script runs. data-rst-scheme-store="local" The page remembers: localStorage["fiesta-scheme"] holds "light" or "dark", and System removes the key. On load a stored choice is applied when the root has no data-theme; in without defer that is before first paint. no attribute The server remembers, or nobody does: storage is neither read nor written, and no data-theme still means System. Either way a submit sets or removes data-theme, lands running transitions, moves aria-current, closes the menu and dispatches rst-scheme from the form (bubbles; detail.scheme is "light", "dark" or "system"), so a server's own script can write its cookie from it. A storage that throws never stops the switch. A second copy of this file does nothing. Running beside rastrillo's rastrillo.js is not supported: both answer the same elements. */ (function () { "use strict"; if (window.fiestaShim) return; window.fiestaShim = true; var root = document.documentElement, KEY = "fiesta-scheme", SCHEME = "[rst-scheme],.rst-scheme"; var remembers = root.getAttribute("data-rst-scheme-store") === "local"; // Storage is touched only on a page that said so, and never trusted: // with storage off a browser throws on the first touch. function stored() { if (!remembers) return null; try { return localStorage.getItem(KEY); } catch (e) { return null; } } function remember(s) { if (!remembers) return; try { if (s === "system") localStorage.removeItem(KEY); else localStorage.setItem(KEY, s); } catch (e) {} } function apply(s) { if (s === "system") root.removeAttribute("data-theme"); else root.setAttribute("data-theme", s); } // Not toggleAttribute: aria-current="" reads as false. function mark(m, s) { m.querySelectorAll("button").forEach(function (b) { var f = b.form, v = f && f.elements.scheme && f.elements.scheme.value; if (v === s) b.setAttribute("aria-current", "true"); else b.removeAttribute("aria-current"); }); } function ready(fn) { if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); else fn(); } // The restore, before anything else. A root that already carries // data-theme is the server's choice and is left alone. var kept = root.hasAttribute("data-theme") ? null : stored(); if (kept === "light" || kept === "dark") { apply(kept); ready(function () { document.querySelectorAll(SCHEME).forEach(function (m) { mark(m, kept); }); }); } // Light dismiss, the one thing the native disclosure cannot do. Two // delegated listeners on the document, never one per element, so a // menu that arrives later is covered; capture phase, so a handler // that stops propagation cannot leave a menu stuck open. The nested // rst-menu-group is in MENUS though it is outside its parent's //
group: dismissal is not exclusivity, and a submenu // left open behind its closing parent is a state nobody can see. The // sidebar drawer and the toggle block are absent: neither is a menu. var MENUS = "[rst-dropdown][open],[rst-menu-group][open],[rst-row-menu][open]"; MENUS += "," + MENUS.replace(/\[(rst[-\w]+)\]/g, ".$1"); // Written out: the class spelling of rst-shell-menu is // .rst-shell__menu, which the rewrite above would spell wrong. Only // in a topbar or a console that names its view: an older console // gates its rail on this [open], and dismissing it closed the rail. MENUS += ",[rst-shell-topbar] [rst-shell-menu][open],[rst-shell-console~=page] [rst-shell-menu][open],[rst-shell-console~=index] [rst-shell-menu][open]"; MENUS += ",.rst-shell-topbar .rst-shell__menu[open],.rst-shell-console--page .rst-shell__menu[open],.rst-shell-console--index .rst-shell__menu[open]"; var TAIL = "[rst-shell-tail],.rst-shell__tail"; // menuAround is closest(MENUS) plus one logical parent: the card's // content (the tail) is the shell menu's next SIBLING, so a click in // it must not close the card. Only while the Menu summary is // rendered: widened, a
keeps [open] under a hidden summary. function menuAround(node) { var d = node.closest(MENUS), t, s; if (d) return d; t = node.closest(TAIL); d = t && t.previousElementSibling; s = d && d.matches(MENUS) && d.querySelector("summary"); return s && s.getClientRects().length ? d : null; } // except is the clicked node: every menu around it stays open, so a // click on a menu item, or on a summary being opened, closes nothing. function closeMenus(except) { var keep = [], m; for (m = except && except.closest && menuAround(except); m; m = m.parentElement && menuAround(m.parentElement)) keep.push(m); document.querySelectorAll(MENUS).forEach(function (d) { if (keep.indexOf(d) < 0 && !(except && d.contains(except))) d.open = false; }); } function dismissMenus(e) { if (e.type === "click") { closeMenus(e.target); return; } if (e.key !== "Escape") return; // Focus is about to sit in a subtree no longer rendered, which // strands a keyboard user. Hand it to the OUTERMOST open menu's // summary: an inner one is inside the thing about to close. var el = document.activeElement; var host = el && el.closest ? menuAround(el) : null; while (host && host.parentElement && menuAround(host.parentElement)) { host = menuAround(host.parentElement); } closeMenus(null); if (host) { var summary = host.querySelector("summary"); if (summary) summary.focus(); } } document.addEventListener("click", dismissMenus, true); document.addEventListener("keydown", dismissMenus, true); // A switch slides. A press on its track that moves 4px drags the knob // (the CSS reads its share of the travel, --rst-switch-p, and the on // colour's share, --rst-switch-on, off the track); on release it goes // to the nearer end with the firm release curve, keyed off // data-rst-settle on the label for the 0.18s it takes, and if that // end is the other state the checkbox is clicked, so input and change // fire as for a tap. After a drag the label's own click is cancelled // (never the checkbox's), so nothing toggles twice. var TRACK = "[rst-switch-track],.rst-switch__track", drag = null, after = null, settling = null, settleT = 0, releasing = false; function unsettle() { if (settling) settling.removeAttribute("data-rst-settle"); settling = null; clearTimeout(settleT); } function settle(l) { unsettle(); settling = l; l.setAttribute("data-rst-settle", ""); settleT = setTimeout(unsettle, 300); } // A toggle while a switch settles takes a toggle's own curve: the // mark goes on change, bar the release's own click. document.addEventListener("change", function (e) { if (settling && !releasing && settling.contains(e.target)) unsettle(); }, true); // One drag at a time: a second finger waits for the first to lift, // and a drag ends however its pointer goes, committed only on // pointerup. A press by the same pointer means its release was missed. document.addEventListener("pointerdown", function (e) { if (drag && e.pointerId !== drag.id) return; // A toggle block's head is a switch too; its own input is first. var t = e.target.closest && e.target.closest(TRACK), l = t && t.closest("[rst-switch],.rst-switch,[rst-tblock-head],.rst-tblock__head"), box = l && l.querySelector("input"); if (drag) drop({ type: "lost", pointerId: drag.id }); after = null; // :disabled, not .disabled: a disabled fieldset disables the box // without setting its property, and a click on it does nothing. if (!box || box.matches(":disabled") || e.button) return; var r = t.getBoundingClientRect(), max = r.width - r.height; drag = { l: l, t: t, box: box, id: e.pointerId, x: e.clientX, max: max, at: box.checked ? max : 0, pos: -1, dir: getComputedStyle(t).direction === "rtl" ? -1 : 1 }; }); document.addEventListener("pointermove", function (e) { var g = drag, d; if (!g || e.pointerId !== g.id) return; d = (e.clientX - g.x) * g.dir; if (g.pos < 0) { if (Math.abs(d) < 4) return; // A drag on a settling switch takes over: no mark. if (settling === g.l) unsettle(); g.l.setAttribute("data-rst-drag", ""); g.t.setPointerCapture(e.pointerId); } g.pos = Math.min(g.max, Math.max(0, g.at + d)); d = g.pos / g.max; g.t.style.setProperty("--rst-switch-p", d); g.t.style.setProperty("--rst-switch-on", d * 100 + "%"); }); function drop(e) { var g = drag; if (!g || e.pointerId !== g.id) return; drag = null; if (g.pos < 0) return; g.l.removeAttribute("data-rst-drag"); settle(g.l); g.t.style.removeProperty("--rst-switch-p"); g.t.style.removeProperty("--rst-switch-on"); after = g.l; releasing = true; if (e.type === "pointerup" && !g.box.matches(":disabled") && g.pos >= g.max / 2 !== g.box.checked) g.box.click(); releasing = false; } document.addEventListener("pointerup", drop); document.addEventListener("pointercancel", drop); document.addEventListener("lostpointercapture", drop); document.addEventListener("click", function (e) { if (after && after.contains(e.target) && e.target.tagName !== "INPUT") { e.preventDefault(); after = null; } }, true); // iOS applies :active on a tap only when a touch listener exists. This // file owns the listener, not light.js, so an app that deletes // light.js keeps its pressed states. document.addEventListener("touchstart", function () {}, { passive: true }); // The scheme switch. Anything but light or dark is System, as on the // server, so the key only ever holds a scheme a theme can read. document.addEventListener("submit", function (e) { var f = e.target, m = f.closest(SCHEME), s = m && f.elements.scheme && f.elements.scheme.value; if (!s) return; e.preventDefault(); if (s !== "light" && s !== "dark") s = "system"; apply(s); land(); remember(s); mark(m, s); m.open = false; f.dispatchEvent(new CustomEvent("rst-scheme", { bubbles: true, detail: { scheme: s } })); }, true); // A scheme change lands in the frame it happens in: an app's own // colour transition, or a switch track mid-toggle, would otherwise // be retargeted while the text changes at once. getAnimations() // flushes style, which starts the transitions, and cancelling one // lands it on its end value. Transitions only, told by // transitionProperty: cancelling an animation would stop a spinner. function land() { if (!document.getAnimations) return; document.getAnimations().forEach(function (a) { if (typeof a.transitionProperty === "string") a.cancel(); }); } // The OS flipping under a page on System is the same change with no // click; the event arrives a frame after the style update started // the fade, so this lands it a frame late rather than 200ms late. var osScheme = window.matchMedia && matchMedia("(prefers-color-scheme: dark)"); if (osScheme) osScheme.onchange = function () { if (!root.hasAttribute("data-theme")) land(); }; })();