| 1 | /* fiesta.js: the browser half of Fiesta. First-party, dependency-free, |
| 2 | and on by default, since nothing in it has a per-instance decision |
| 3 | to make. Everything it enhances also works with scripts disabled. |
| 4 | |
| 5 | Menus. An open <details rst-dropdown>, <details rst-row-menu>, a |
| 6 | nested <details rst-menu-group>, or the topbar's and console's narrow |
| 7 | <details rst-shell-menu> card (whose content is its next sibling, the |
| 8 | tail) closes on an outside click and on Escape, in either spelling. |
| 9 | Exclusivity stays native. |
| 10 | |
| 11 | Switch. A switch's knob can be dragged along its track, and snaps to |
| 12 | the nearer end. |
| 13 | |
| 14 | Touch. A touchstart listener, so iOS applies :active on a tap. |
| 15 | |
| 16 | Scheme. The colour-scheme menu's forms ([rst-scheme]) apply in place: |
| 17 | data-theme on <html> is what the themes read. Who remembers the |
| 18 | choice is the page's to say, on <html>, before any script runs. |
| 19 | |
| 20 | data-rst-scheme-store="local" |
| 21 | The page remembers: localStorage["fiesta-scheme"] holds "light" |
| 22 | or "dark", and System removes the key. On load a stored choice |
| 23 | is applied when the root has no data-theme; in <head> without |
| 24 | defer that is before first paint. |
| 25 | no attribute |
| 26 | The server remembers, or nobody does: storage is neither read |
| 27 | nor written, and no data-theme still means System. |
| 28 | |
| 29 | Either way a submit sets or removes data-theme, lands running |
| 30 | transitions, moves aria-current, closes the menu and dispatches |
| 31 | rst-scheme from the form (bubbles; detail.scheme is "light", "dark" |
| 32 | or "system"), so a server's own script can write its cookie from |
| 33 | it. A storage that throws never stops the switch. |
| 34 | |
| 35 | A second copy of this file does nothing. Running beside rastrillo's |
| 36 | rastrillo.js is not supported: both answer the same elements. */ |
| 37 | (function () { |
| 38 | "use strict"; |
| 39 | if (window.fiestaShim) return; |
| 40 | window.fiestaShim = true; |
| 41 | |
| 42 | var root = document.documentElement, KEY = "fiesta-scheme", SCHEME = "[rst-scheme],.rst-scheme"; |
| 43 | var remembers = root.getAttribute("data-rst-scheme-store") === "local"; |
| 44 | |
| 45 | // Storage is touched only on a page that said so, and never trusted: |
| 46 | // with storage off a browser throws on the first touch. |
| 47 | function stored() { |
| 48 | if (!remembers) return null; |
| 49 | try { return localStorage.getItem(KEY); } catch (e) { return null; } |
| 50 | } |
| 51 | function remember(s) { |
| 52 | if (!remembers) return; |
| 53 | try { if (s === "system") localStorage.removeItem(KEY); else localStorage.setItem(KEY, s); } catch (e) {} |
| 54 | } |
| 55 | function apply(s) { |
| 56 | if (s === "system") root.removeAttribute("data-theme"); else root.setAttribute("data-theme", s); |
| 57 | } |
| 58 | // Not toggleAttribute: aria-current="" reads as false. |
| 59 | function mark(m, s) { |
| 60 | m.querySelectorAll("button").forEach(function (b) { |
| 61 | var f = b.form, v = f && f.elements.scheme && f.elements.scheme.value; |
| 62 | if (v === s) b.setAttribute("aria-current", "true"); else b.removeAttribute("aria-current"); |
| 63 | }); |
| 64 | } |
| 65 | function ready(fn) { |
| 66 | if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); else fn(); |
| 67 | } |
| 68 | |
| 69 | // The restore, before anything else. A root that already carries |
| 70 | // data-theme is the server's choice and is left alone. |
| 71 | var kept = root.hasAttribute("data-theme") ? null : stored(); |
| 72 | if (kept === "light" || kept === "dark") { |
| 73 | apply(kept); |
| 74 | ready(function () { document.querySelectorAll(SCHEME).forEach(function (m) { mark(m, kept); }); }); |
| 75 | } |
| 76 | |
| 77 | // Light dismiss, the one thing the native disclosure cannot do. Two |
| 78 | // delegated listeners on the document, never one per element, so a |
| 79 | // menu that arrives later is covered; capture phase, so a handler |
| 80 | // that stops propagation cannot leave a menu stuck open. The nested |
| 81 | // rst-menu-group is in MENUS though it is outside its parent's |
| 82 | // <details name> group: dismissal is not exclusivity, and a submenu |
| 83 | // left open behind its closing parent is a state nobody can see. The |
| 84 | // sidebar drawer and the toggle block are absent: neither is a menu. |
| 85 | var MENUS = "[rst-dropdown][open],[rst-menu-group][open],[rst-row-menu][open]"; |
| 86 | MENUS += "," + MENUS.replace(/\[(rst[-\w]+)\]/g, ".$1"); |
| 87 | // Written out: the class spelling of rst-shell-menu is |
| 88 | // .rst-shell__menu, which the rewrite above would spell wrong. Only |
| 89 | // in a topbar or a console that names its view: an older console |
| 90 | // gates its rail on this [open], and dismissing it closed the rail. |
| 91 | MENUS += ",[rst-shell-topbar] [rst-shell-menu][open],[rst-shell-console~=page] [rst-shell-menu][open],[rst-shell-console~=index] [rst-shell-menu][open]"; |
| 92 | MENUS += ",.rst-shell-topbar .rst-shell__menu[open],.rst-shell-console--page .rst-shell__menu[open],.rst-shell-console--index .rst-shell__menu[open]"; |
| 93 | var TAIL = "[rst-shell-tail],.rst-shell__tail"; |
| 94 | |
| 95 | // menuAround is closest(MENUS) plus one logical parent: the card's |
| 96 | // content (the tail) is the shell menu's next SIBLING, so a click in |
| 97 | // it must not close the card. Only while the Menu summary is |
| 98 | // rendered: widened, a <details> keeps [open] under a hidden summary. |
| 99 | function menuAround(node) { |
| 100 | var d = node.closest(MENUS), t, s; |
| 101 | if (d) return d; |
| 102 | t = node.closest(TAIL); |
| 103 | d = t && t.previousElementSibling; |
| 104 | s = d && d.matches(MENUS) && d.querySelector("summary"); |
| 105 | return s && s.getClientRects().length ? d : null; |
| 106 | } |
| 107 | |
| 108 | // except is the clicked node: every menu around it stays open, so a |
| 109 | // click on a menu item, or on a summary being opened, closes nothing. |
| 110 | function closeMenus(except) { |
| 111 | var keep = [], m; |
| 112 | for (m = except && except.closest && menuAround(except); m; m = m.parentElement && menuAround(m.parentElement)) keep.push(m); |
| 113 | document.querySelectorAll(MENUS).forEach(function (d) { |
| 114 | if (keep.indexOf(d) < 0 && !(except && d.contains(except))) d.open = false; |
| 115 | }); |
| 116 | } |
| 117 | |
| 118 | function dismissMenus(e) { |
| 119 | if (e.type === "click") { closeMenus(e.target); return; } |
| 120 | if (e.key !== "Escape") return; |
| 121 | // Focus is about to sit in a subtree no longer rendered, which |
| 122 | // strands a keyboard user. Hand it to the OUTERMOST open menu's |
| 123 | // summary: an inner one is inside the thing about to close. |
| 124 | var el = document.activeElement; |
| 125 | var host = el && el.closest ? menuAround(el) : null; |
| 126 | while (host && host.parentElement && menuAround(host.parentElement)) { |
| 127 | host = menuAround(host.parentElement); |
| 128 | } |
| 129 | closeMenus(null); |
| 130 | if (host) { |
| 131 | var summary = host.querySelector("summary"); |
| 132 | if (summary) summary.focus(); |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | document.addEventListener("click", dismissMenus, true); |
| 137 | document.addEventListener("keydown", dismissMenus, true); |
| 138 | |
| 139 | // A switch slides. A press on its track that moves 4px drags the knob |
| 140 | // (the CSS reads its share of the travel, --rst-switch-p, and the on |
| 141 | // colour's share, --rst-switch-on, off the track); on release it goes |
| 142 | // to the nearer end with the firm release curve, keyed off |
| 143 | // data-rst-settle on the label for the 0.18s it takes, and if that |
| 144 | // end is the other state the checkbox is clicked, so input and change |
| 145 | // fire as for a tap. After a drag the label's own click is cancelled |
| 146 | // (never the checkbox's), so nothing toggles twice. |
| 147 | var TRACK = "[rst-switch-track],.rst-switch__track", drag = null, after = null, settling = null, settleT = 0, releasing = false; |
| 148 | function unsettle() { |
| 149 | if (settling) settling.removeAttribute("data-rst-settle"); |
| 150 | settling = null; |
| 151 | clearTimeout(settleT); |
| 152 | } |
| 153 | function settle(l) { |
| 154 | unsettle(); |
| 155 | settling = l; |
| 156 | l.setAttribute("data-rst-settle", ""); |
| 157 | settleT = setTimeout(unsettle, 300); |
| 158 | } |
| 159 | // A toggle while a switch settles takes a toggle's own curve: the |
| 160 | // mark goes on change, bar the release's own click. |
| 161 | document.addEventListener("change", function (e) { if (settling && !releasing && settling.contains(e.target)) unsettle(); }, true); |
| 162 | // One drag at a time: a second finger waits for the first to lift, |
| 163 | // and a drag ends however its pointer goes, committed only on |
| 164 | // pointerup. A press by the same pointer means its release was missed. |
| 165 | document.addEventListener("pointerdown", function (e) { |
| 166 | if (drag && e.pointerId !== drag.id) return; |
| 167 | // A toggle block's head is a switch too; its own input is first. |
| 168 | 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"); |
| 169 | if (drag) drop({ type: "lost", pointerId: drag.id }); |
| 170 | after = null; |
| 171 | // :disabled, not .disabled: a disabled fieldset disables the box |
| 172 | // without setting its property, and a click on it does nothing. |
| 173 | if (!box || box.matches(":disabled") || e.button) return; |
| 174 | var r = t.getBoundingClientRect(), max = r.width - r.height; |
| 175 | drag = { l: l, t: t, box: box, id: e.pointerId, x: e.clientX, max: max, at: box.checked ? max : 0, pos: -1, |
| 176 | dir: getComputedStyle(t).direction === "rtl" ? -1 : 1 }; |
| 177 | }); |
| 178 | document.addEventListener("pointermove", function (e) { |
| 179 | var g = drag, d; |
| 180 | if (!g || e.pointerId !== g.id) return; |
| 181 | d = (e.clientX - g.x) * g.dir; |
| 182 | if (g.pos < 0) { |
| 183 | if (Math.abs(d) < 4) return; |
| 184 | // A drag on a settling switch takes over: no mark. |
| 185 | if (settling === g.l) unsettle(); |
| 186 | g.l.setAttribute("data-rst-drag", ""); |
| 187 | g.t.setPointerCapture(e.pointerId); |
| 188 | } |
| 189 | g.pos = Math.min(g.max, Math.max(0, g.at + d)); |
| 190 | d = g.pos / g.max; |
| 191 | g.t.style.setProperty("--rst-switch-p", d); |
| 192 | g.t.style.setProperty("--rst-switch-on", d * 100 + "%"); |
| 193 | }); |
| 194 | function drop(e) { |
| 195 | var g = drag; |
| 196 | if (!g || e.pointerId !== g.id) return; |
| 197 | drag = null; |
| 198 | if (g.pos < 0) return; |
| 199 | g.l.removeAttribute("data-rst-drag"); |
| 200 | settle(g.l); |
| 201 | g.t.style.removeProperty("--rst-switch-p"); |
| 202 | g.t.style.removeProperty("--rst-switch-on"); |
| 203 | after = g.l; |
| 204 | releasing = true; |
| 205 | if (e.type === "pointerup" && !g.box.matches(":disabled") && g.pos >= g.max / 2 !== g.box.checked) g.box.click(); |
| 206 | releasing = false; |
| 207 | } |
| 208 | document.addEventListener("pointerup", drop); |
| 209 | document.addEventListener("pointercancel", drop); |
| 210 | document.addEventListener("lostpointercapture", drop); |
| 211 | document.addEventListener("click", function (e) { |
| 212 | if (after && after.contains(e.target) && e.target.tagName !== "INPUT") { |
| 213 | e.preventDefault(); |
| 214 | after = null; |
| 215 | } |
| 216 | }, true); |
| 217 | |
| 218 | // iOS applies :active on a tap only when a touch listener exists. This |
| 219 | // file owns the listener, not light.js, so an app that deletes |
| 220 | // light.js keeps its pressed states. |
| 221 | document.addEventListener("touchstart", function () {}, { passive: true }); |
| 222 | |
| 223 | // The scheme switch. Anything but light or dark is System, as on the |
| 224 | // server, so the key only ever holds a scheme a theme can read. |
| 225 | document.addEventListener("submit", function (e) { |
| 226 | var f = e.target, m = f.closest(SCHEME), s = m && f.elements.scheme && f.elements.scheme.value; |
| 227 | if (!s) return; |
| 228 | e.preventDefault(); |
| 229 | if (s !== "light" && s !== "dark") s = "system"; |
| 230 | apply(s); |
| 231 | land(); |
| 232 | remember(s); |
| 233 | mark(m, s); |
| 234 | m.open = false; |
| 235 | f.dispatchEvent(new CustomEvent("rst-scheme", { bubbles: true, detail: { scheme: s } })); |
| 236 | }, true); |
| 237 | |
| 238 | // A scheme change lands in the frame it happens in: an app's own |
| 239 | // colour transition, or a switch track mid-toggle, would otherwise |
| 240 | // be retargeted while the text changes at once. getAnimations() |
| 241 | // flushes style, which starts the transitions, and cancelling one |
| 242 | // lands it on its end value. Transitions only, told by |
| 243 | // transitionProperty: cancelling an animation would stop a spinner. |
| 244 | function land() { |
| 245 | if (!document.getAnimations) return; |
| 246 | document.getAnimations().forEach(function (a) { if (typeof a.transitionProperty === "string") a.cancel(); }); |
| 247 | } |
| 248 | |
| 249 | // The OS flipping under a page on System is the same change with no |
| 250 | // click; the event arrives a frame after the style update started |
| 251 | // the fade, so this lands it a frame late rather than 200ms late. |
| 252 | var osScheme = window.matchMedia && matchMedia("(prefers-color-scheme: dark)"); |
| 253 | if (osScheme) osScheme.onchange = function () { if (!root.hasAttribute("data-theme")) land(); }; |
| 254 | })(); |
| 255 | |