rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1/* gallery.js: the gallery page's own script, the chrome's half, and no
2 part of the design system. It is served joined with view.js, the
3 preview widget's half, as the one gallery.js every page loads
4 blocking in <head>. It is Rastrillo's gallery script for the same
5 chrome: the rail filter, keeping your place across a theme or
6 language switch, and following a moved section's old link; and in
7 place of Rastrillo's own colour toggle, a bootstrap for the scheme
8 fiesta.js owns.
9
10 Same rules as the scripts it loads beside: first-party,
11 dependency-free, no network, and inert-safe: the page it enhances is
12 a complete document with scripts off.
13
14 Why it is a blocking <script> in <head> rather than a deferred one
15 at the foot: two of the things it does have to happen before the
16 first paint. A stored Dark applied after the body has parsed is a
17 visible flash of the system scheme on every load, and the marker
18 that hides the filter box and the unpainted frames has to be on the
19 root before either could paint. It does no work beyond setting two
20 attributes before DOMContentLoaded, so blocking on it costs one
21 parse.
22
23 The filter box is display: none until this file sets
24 data-gallery-js on <html>, which is the whole of the scriptless
25 story: with scripts off the control is never shown and the rail is
26 a complete list of every page. A visible control that cannot do
27 anything would be the misleading version. */
28(function () {
29 "use strict";
30
31 var root = document.documentElement;
32 root.setAttribute("data-gallery-js", "on");
33
34 function ready(fn) {
35 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn);
36 else fn();
37 }
38
39 // The scheme, before the first paint. fiesta.js owns the choice:
40 // its menu writes data-theme on <html> and, on a page that carries
41 // data-rst-scheme-store="local", remembers it under fiesta-scheme.
42 // It restores a stored choice itself when it runs, but it is
43 // deferred, which is after the first paint, and it skips its own
44 // restore, menu marking included, when data-theme is already on the
45 // root. So this file, at parse time, puts the stored scheme on the
46 // root; then at readiness, and again when a page comes back from
47 // the back/forward cache (where the stored choice may have changed
48 // since), marks every copy of the scheme menu on the page (the
49 // bar's and the display settings menu's) the way fiesta.js marks
50 // one: aria-current on the button whose form's scheme is the one on
51 // the root, System when the root carries none. The root, not
52 // storage: where storage refuses, a choice is applied but never
53 // stored, and the marks follow what the page shows. view.js repaints
54 // the frames from the root on pageshow.
55 var KEY = "fiesta-scheme";
56 var remembers = root.getAttribute("data-rst-scheme-store") === "local";
57
58 function stored() {
59 if (!remembers) return null;
60 try {
61 var v = localStorage.getItem(KEY);
62 return v === "light" || v === "dark" ? v : null;
63 } catch (e) {
64 return null;
65 }
66 }
67
68 function restore() {
69 var s = stored();
70 if (s) root.setAttribute("data-theme", s);
71 else if (remembers) root.removeAttribute("data-theme");
72 }
73
74 // Not toggleAttribute: aria-current="" reads as false.
75 function mark() {
76 var s = root.getAttribute("data-theme") || "system";
77 var menus = document.querySelectorAll("[rst-scheme]");
78 for (var i = 0; i < menus.length; i++) {
79 var buttons = menus[i].querySelectorAll("button");
80 for (var j = 0; j < buttons.length; j++) {
81 var f = buttons[j].form, v = f && f.elements.scheme && f.elements.scheme.value;
82 if (v === s) buttons[j].setAttribute("aria-current", "true");
83 else buttons[j].removeAttribute("aria-current");
84 }
85 }
86 }
87
88 if (!root.hasAttribute("data-theme")) restore();
89 ready(mark);
90 addEventListener("pageshow", function (event) {
91 if (!event.persisted) return;
92 restore();
93 mark();
94 });
95 // A choice made in one copy of the menu marks the other copy too;
96 // fiesta.js marks only the menu the form was in.
97 document.addEventListener("rst-scheme", function () { requestAnimationFrame(mark); });
98
99 // ── The nav filter ──────────────────────────────────────────────────
100 //
101 // The sidebar is a complete list of every page before this runs and
102 // stays one if it never does: the box is display:none until
103 // data-gallery-js is set, so scripts off gets the nav and no dead
104 // control.
105 //
106 // It says nothing of its own: the one sentence it can put on screen,
107 // No matches, is rendered by the page in the page's language and
108 // starts out hidden, and this file only takes the attribute off it.
109 ready(function () {
110 var input = document.querySelector("[data-ds-filter]");
111 var nav = input && document.getElementById(input.getAttribute("aria-controls"));
112 if (!input || !nav) return;
113 var empty = document.querySelector("[data-ds-filter-empty]");
114 var sections = nav.querySelectorAll("details");
115
116 // Folded both sides: "seccion" finds "Sección", and the rail is
117 // headings in twelve languages. NFD splits a letter from its
118 // accents; the range is the combining marks, and dropping them is
119 // the whole of it.
120 function fold(s) {
121 return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
122 }
123
124 var links = nav.querySelectorAll("a");
125 // An entry's synonyms (data-ds-terms) match beside its name, and
126 // the plain page links filter like every entry, so No matches and
127 // a visible Overview link never show together.
128 for (var i = 0; i < links.length; i++) links[i].dsText = fold(links[i].textContent + " " + (links[i].getAttribute("data-ds-terms") || ""));
129 var pages = nav.querySelectorAll(":scope > a");
130 // A section's own name is searchable too: "shells" has to land
131 // somewhere, and the reader typing it means the whole section.
132 for (var i = 0; i < sections.length; i++) {
133 var head = sections[i].querySelector("summary");
134 sections[i].dsText = head ? fold(head.textContent) : "";
135 }
136
137 // What the reader had open before they typed. A query opens
138 // whatever it found something in; clearing it hands the reader
139 // their own arrangement back.
140 var chosen = null;
141
142 function run(query) {
143 var q = fold(query.trim());
144 if (q && chosen === null) {
145 chosen = [];
146 for (var i = 0; i < sections.length; i++) chosen.push(sections[i].open);
147 }
148 var found = false;
149 for (var s = 0; s < sections.length; s++) {
150 var section = sections[s];
151 var kids = section.children;
152 var shown = 0;
153 // The section's own name matching stands for everything under
154 // it: a reader who types "form" means the Form page, not the
155 // one entry in it whose name happens to contain the word.
156 var whole = !q || section.dsText.indexOf(q) >= 0;
157 for (var k = 0; k < kids.length; k++) {
158 var el = kids[k];
159 if (el.tagName !== "A") continue;
160 var hit = whole || el.dsText.indexOf(q) >= 0;
161 el.hidden = !hit;
162 if (hit) shown++;
163 }
164 section.hidden = shown === 0;
165 if (shown) found = true;
166 section.open = q ? shown > 0 : chosen ? chosen[s] : section.open;
167 }
168 for (var p = 0; p < pages.length; p++) {
169 pages[p].hidden = q && pages[p].dsText.indexOf(q) < 0;
170 if (q && !pages[p].hidden) found = true;
171 }
172 if (!q) chosen = null;
173 if (empty) empty.hidden = !q || found;
174 }
175
176 input.addEventListener("input", function () {
177 run(input.value);
178 });
179
180 // Escape clears a query that is there and is left alone when there
181 // is not, so the key still belongs to whatever is around the box.
182 // Focus never moves: nothing here touches it.
183 input.addEventListener("keydown", function (event) {
184 if (event.key !== "Escape" || input.value === "") return;
185 event.preventDefault();
186 input.value = "";
187 run("");
188 });
189
190 // A back-navigation restores the box with a value already in it:
191 // filter to what it says, not to what the page was rendered saying.
192 if (input.value) run(input.value);
193 });
194
195 // ── Keeping your place ──────────────────────────────────────────────
196 //
197 // A theme or language link, in the pinned bar or in the phone's
198 // display settings menu, carries the section being read, worked out
199 // at the click; the address bar is never rewritten.
200 // 1. A fragment this document put in place, while its target has not
201 // moved (2px): the case geometry cannot answer, the last section
202 // of a page that clamps, or a glyph in a row of icons sharing a
203 // top.
204 // 2. Else the anchor at the reading line (the scroll padding plus a
205 // pixel, where a fragment lands): the greatest top at or above
206 // it, first in document order.
207 // 3. Above every anchor, nothing.
208 // The rule-1 record is written only when this document scrolls to a
209 // target: at load on a fresh, untouched navigation (redoing the
210 // parser's scroll, which late layout may have moved), and a frame
211 // after a plain, uncancelled click on a fragment link here, the only
212 // signal when that fragment is already in the address. History drops
213 // it, because Back restores where the reader had scrolled.
214 var record = null, touched = false;
215
216 function anchor(id) {
217 var el = id && document.getElementById(id);
218 return el && el.hasAttribute("data-ds-anchor") ? el : null;
219 }
220
221 function fragment(hash) {
222 try {
223 return decodeURIComponent(hash.slice(1));
224 } catch (e) {
225 return hash.slice(1);
226 }
227 }
228
229 function keep(el) {
230 record = { id: el.id, top: el.getBoundingClientRect().top };
231 }
232
233 ["wheel", "touchstart", "pointerdown", "keydown"].forEach(function (type) {
234 addEventListener(type, function () { touched = true; }, { capture: true, passive: true });
235 });
236
237 addEventListener("load", function () {
238 var nav = performance.getEntriesByType("navigation")[0], el = anchor(fragment(location.hash));
239 if (!el || touched || !nav || nav.type !== "navigate") return;
240 el.scrollIntoView({ block: "start" });
241 requestAnimationFrame(function () { keep(el); });
242 });
243
244 document.addEventListener("click", function (event) {
245 var a = event.target.closest && event.target.closest("a[href]"), el = a && anchor(fragment(a.hash));
246 if (!el || event.button || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || a.target || a.hasAttribute("download") ||
247 a.origin + a.pathname + a.search !== location.origin + location.pathname + location.search) return;
248 requestAnimationFrame(function () {
249 if (!event.defaultPrevented) keep(el);
250 });
251 });
252
253 addEventListener("popstate", function () { record = null; });
254 if (window.navigation) navigation.addEventListener("navigate", function (event) {
255 if (event.navigationType === "traverse") record = null;
256 });
257
258 function place() {
259 var el = record && fragment(location.hash) === record.id && anchor(record.id);
260 if (el && Math.abs(el.getBoundingClientRect().top - record.top) <= 2) return el.id;
261 var line = (parseFloat(getComputedStyle(root).scrollPaddingBlockStart) || 0) + 1;
262 var all = document.querySelectorAll("[data-ds-anchor]"), best = "", top = -Infinity;
263 for (var i = 0; i < all.length; i++) {
264 var r = all[i].getBoundingClientRect();
265 if ((r.width || r.height) && r.top <= line && r.top > top) {
266 best = all[i].id;
267 top = r.top;
268 }
269 }
270 return best;
271 }
272
273 // A link's address is restored by a timer scheduled before it is
274 // changed, so nothing failing between can leave it set. Not a
275 // microtask: that checkpoint comes before activation reads href.
276 // Again on pageshow, for a page back from the cache whose timer never
277 // ran.
278 ready(function () {
279 var links = document.querySelectorAll(".ds-top__controls a[href], .ds-prefs a[href]");
280 if (!links.length) return;
281 for (var i = 0; i < links.length; i++) links[i].dsHref = links[i].getAttribute("href");
282 function canonical() {
283 for (var i = 0; i < links.length; i++) links[i].setAttribute("href", links[i].dsHref);
284 }
285 addEventListener("pageshow", canonical);
286 document.addEventListener("click", function (event) {
287 var a = event.target.closest && event.target.closest("a[href]"), id;
288 if (!a || a.dsHref === undefined) return;
289 setTimeout(canonical, 0);
290 if ((id = place())) a.setAttribute("href", a.dsHref + "#" + encodeURIComponent(id));
291 });
292 });
293
294 // ── A section that moved pages ──────────────────────────────────────
295 //
296 // A fragment naming nothing here that the rail links on another
297 // page: the section moved, and the old link in a bookmark or a search
298 // result is followed to where it went. The rail is the lookup, so no
299 // page keeps a list and the next move works the same way. Two
300 // lookups: Rastrillo's, a rail link whose fragment is the one asked
301 // for; and Fiesta's own, a rail link whose page stem is the fragment,
302 // since part 3 kept every example on one page under its name
303 // (index.html#button is button.html now). Only a fragment with no
304 // element at all is looked up, so a hash that is on the page is left
305 // alone; only a link to another path is followed, so a page can
306 // never send a reader to itself; a link that opens a new tab is not
307 // followed in this one; and the address is replaced, so Back does
308 // not return to a page that would send them on again. On hashchange
309 // as well as at readiness: a reader already on the page who opens
310 // the old link changes only the fragment, and nothing is loaded.
311 function moved() {
312 var id = fragment(location.hash), nav = id && !document.getElementById(id) && document.getElementById("ds-nav");
313 var links = nav ? nav.querySelectorAll("a[href]") : [];
314 for (var i = 0; i < links.length; i++) {
315 var a = links[i];
316 if (a.target || a.pathname === location.pathname) continue;
317 var stem = a.pathname.slice(a.pathname.lastIndexOf("/") + 1);
318 if (fragment(a.hash) === id || (!a.hash && stem === id + ".html")) {
319 location.replace(a.href);
320 return;
321 }
322 }
323 }
324 ready(moved);
325 addEventListener("hashchange", moved);
326})();
327