rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

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