cmu-web-kilt 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/js/cmu-app.js ADDED
@@ -0,0 +1,871 @@
1
+ /* Carnegie Mellon Web Kilt: behaviors for the kit's components. Load it after
2
+ the template's scripts, at the end of <body>. Each behavior hooks onto a
3
+ data-cmu-* attribute and works on markup added later too. */
4
+ (function () {
5
+ "use strict";
6
+
7
+ // Copy button: <button class="btn" type="button" data-cmu-copy="#id">.
8
+ // Copies the target's text, says "Copied" on the button for two seconds
9
+ // and tells screen readers. Without the clipboard (an http page, an old
10
+ // browser) it selects the text and asks for the keyboard instead. The icon
11
+ // version (button.cmu-copy, named by aria-label and title) shows a check
12
+ // and puts the message in its label and tooltip instead. A text button's
13
+ // words change in place, in the text it already has, so a framework that
14
+ // renders the label keeps its hold on it.
15
+ var status;
16
+ function announce(text) {
17
+ // When the page's body was replaced (as Turbo does), use the one in the
18
+ // new body, which a cached page brings back, or add one
19
+ if (!status || !status.isConnected) {
20
+ status = document.querySelector("[data-cmu-announce]");
21
+ if (!status) {
22
+ status = document.createElement("p");
23
+ status.className = "sr-only";
24
+ status.setAttribute("aria-live", "polite");
25
+ status.setAttribute("data-cmu-announce", "");
26
+ document.body.appendChild(status);
27
+ }
28
+ }
29
+ status.textContent = "";
30
+ setTimeout(function () {
31
+ status.textContent = text;
32
+ }, 50);
33
+ }
34
+ function selectText(el) {
35
+ if (el.select) return el.select();
36
+ var range = document.createRange();
37
+ range.selectNodeContents(el);
38
+ var sel = window.getSelection();
39
+ sel.removeAllRanges();
40
+ sel.addRange(range);
41
+ }
42
+ // The button's own words: its first text that isn't just space
43
+ function buttonText(button) {
44
+ var walk = document.createTreeWalker(button, NodeFilter.SHOW_TEXT);
45
+ while (walk.nextNode()) if (walk.currentNode.nodeValue.trim()) return walk.currentNode;
46
+ return button.appendChild(document.createTextNode(""));
47
+ }
48
+ function flash(button, label, state) {
49
+ var icon = button.classList.contains("cmu-copy");
50
+ if (!button.hasAttribute("data-cmu-copy-label"))
51
+ button.setAttribute(
52
+ "data-cmu-copy-label",
53
+ icon ? button.getAttribute("aria-label") || "" : button.textContent,
54
+ );
55
+ var original = button.getAttribute("data-cmu-copy-label");
56
+ if (icon) {
57
+ button.setAttribute("data-cmu-copy-state", state);
58
+ button.setAttribute("aria-label", label);
59
+ button.title = label;
60
+ } else {
61
+ var words = buttonText(button);
62
+ if (!button.cmuCopyWords) button.cmuCopyWords = { node: words, original: words.nodeValue };
63
+ words.nodeValue = label;
64
+ button.cmuCopyWords.shown = label;
65
+ }
66
+ clearTimeout(button.cmuCopyTimer);
67
+ button.cmuCopyTimer = setTimeout(function () {
68
+ if (icon) {
69
+ button.removeAttribute("data-cmu-copy-state");
70
+ button.setAttribute("aria-label", original);
71
+ button.title = original;
72
+ } else {
73
+ var w = button.cmuCopyWords;
74
+ // Put the words back, unless the page changed them meanwhile
75
+ if (w.node.nodeValue === w.shown) w.node.nodeValue = w.original;
76
+ button.cmuCopyWords = null;
77
+ }
78
+ }, 2000);
79
+ }
80
+ document.addEventListener("click", function (e) {
81
+ var button = e.target.closest && e.target.closest("[data-cmu-copy]");
82
+ if (!button) return;
83
+ var target = document.querySelector(button.getAttribute("data-cmu-copy"));
84
+ if (!target) return;
85
+ var text = "value" in target ? target.value : target.textContent;
86
+ var fallback = function () {
87
+ selectText(target);
88
+ var keys = /Mac|iPhone|iPad/.test(navigator.platform) ? "Command-C" : "Control-C";
89
+ flash(button, "Press " + keys, "keys");
90
+ announce("Selected. Press " + keys + " to copy.");
91
+ };
92
+ if (!navigator.clipboard || !window.isSecureContext) return fallback();
93
+ navigator.clipboard.writeText(text).then(function () {
94
+ flash(button, "Copied", "copied");
95
+ announce("Copied.");
96
+ }, fallback);
97
+ });
98
+
99
+ // In-page links: the template's script turns href="#id" into
100
+ // javascript:void(0) (keeping the target in data-jump) and scrolls with its
101
+ // own animation, so the address never shows the section and Back doesn't
102
+ // return. Put the real links back once it has run, and add the section to
103
+ // the address as the template scrolls to it.
104
+ function restoreJumps() {
105
+ document.querySelectorAll("a[data-jump]").forEach(function (a) {
106
+ var target = a.getAttribute("data-jump");
107
+ if (target.length > 1 && /^javascript:/.test(a.getAttribute("href")))
108
+ a.setAttribute("href", target);
109
+ });
110
+ }
111
+ if (document.readyState === "loading")
112
+ document.addEventListener("DOMContentLoaded", function () {
113
+ setTimeout(restoreJumps);
114
+ });
115
+ else setTimeout(restoreJumps);
116
+ document.addEventListener(
117
+ "click",
118
+ function (e) {
119
+ var a = e.target.closest && e.target.closest("a[data-jump]");
120
+ if (!a || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
121
+ var target = a.getAttribute("data-jump");
122
+ if (target.length < 2) return;
123
+ e.preventDefault(); // the template does the scrolling
124
+ if (location.hash !== target) history.pushState(null, "", target);
125
+ },
126
+ true,
127
+ );
128
+
129
+ // Enlarge on click: <a href="big.jpg" data-cmu-zoom><img alt="..."></a> opens
130
+ // the image over the page, with the page dimmed behind it; Escape, the close
131
+ // button or a click anywhere closes it, and focus goes back to the link. An
132
+ // image much taller than the window opens at full width and scrolls.
133
+ // Without the script (or a browser without <dialog>), the link opens the image.
134
+ // See Enlarge on Click in the docs (content.html).
135
+ var zoom, zoomOpener, zoomByPointer;
136
+ document.addEventListener("click", function (e) {
137
+ var a = e.target.closest && e.target.closest("a[data-cmu-zoom]");
138
+ if (!a || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
139
+ if (!window.HTMLDialogElement) return;
140
+ e.preventDefault();
141
+ if (!zoom || !zoom.isConnected) {
142
+ zoom = document.createElement("dialog");
143
+ zoom.className = "cmu-zoom";
144
+ zoom.innerHTML =
145
+ '<button type="button" class="cmu-zoom-close" aria-label="Close">&times;</button>' +
146
+ '<div class="cmu-zoom-frame"><img alt=""></div>';
147
+ zoom.addEventListener("click", function (ev) {
148
+ // A click or tap, not Enter on the close button
149
+ zoomByPointer = ev.detail > 0;
150
+ zoom.close();
151
+ });
152
+ // Focus goes back to the link. Closed with the mouse, it goes back
153
+ // without a focus ring (Firefox would draw one); with the keyboard, the
154
+ // ring stays to show where focus is
155
+ zoom.addEventListener("close", function () {
156
+ if (zoomByPointer && zoomOpener && zoomOpener.isConnected) {
157
+ zoomOpener.blur();
158
+ zoomOpener.focus({ preventScroll: true, focusVisible: false });
159
+ }
160
+ zoomByPointer = false;
161
+ });
162
+ document.body.appendChild(zoom);
163
+ }
164
+ zoomOpener = a;
165
+ var small = a.querySelector("img"),
166
+ big = zoom.querySelector("img"),
167
+ frame = zoom.querySelector(".cmu-zoom-frame");
168
+ // Tall: at the widest it can show, the image is over a quarter taller
169
+ // than the window, so fitting it whole would shrink it too far
170
+ function fit() {
171
+ var w = Math.min(big.naturalWidth, window.innerWidth * 0.92, 1400),
172
+ tall =
173
+ big.naturalWidth > 0 &&
174
+ (big.naturalHeight * w) / big.naturalWidth > window.innerHeight * 0.92 * 1.25;
175
+ zoom.classList.toggle("cmu-zoom--tall", tall);
176
+ if (tall) frame.tabIndex = 0;
177
+ else frame.removeAttribute("tabindex");
178
+ frame.scrollTop = 0;
179
+ }
180
+ big.onload = fit;
181
+ big.src = a.href;
182
+ big.alt = small ? small.alt : "";
183
+ if (big.complete) fit();
184
+ zoom.showModal();
185
+ });
186
+
187
+ // Countdown: <div class="cmu-countdown" data-cmu-countdown="2027-04-08T12:00:00-04:00">
188
+ // with a sentence inside that gives the same moment. Shows days, hours,
189
+ // minutes and seconds on flip cards and stops at zero; screen readers get
190
+ // the sentence instead of the ticking cards. An optional
191
+ // <div class="cmu-countdown-done" hidden> inside shows once it reaches zero,
192
+ // when the countdown also gets cmu-countdown-ended; with
193
+ // cmu-countdown--replace, the cards then go. See Countdown in the docs
194
+ // (content.html). Adapted from a CodePen by Shaw
195
+ // (codepen.io/shshaw/pen/vKzoLL, MIT).
196
+ var UNITS = [
197
+ ["Days", 864e5, Infinity],
198
+ ["Hours", 36e5, 24],
199
+ ["Minutes", 6e4, 60],
200
+ ["Seconds", 1e3, 60],
201
+ ];
202
+ function card(unit) {
203
+ var el = document.createElement("div");
204
+ el.className = "cmu-countdown-unit";
205
+ el.innerHTML =
206
+ '<b class="cmu-countdown-card"><b class="cmu-countdown-top"></b>' +
207
+ '<b class="cmu-countdown-bottom"></b><b class="cmu-countdown-back">' +
208
+ '<b class="cmu-countdown-bottom"></b></b></b>' +
209
+ '<span class="cmu-countdown-label">' +
210
+ unit +
211
+ "</span>";
212
+ return el;
213
+ }
214
+ function flip(el, value) {
215
+ if (el.cmuValue === value) return;
216
+ var top = el.querySelector(".cmu-countdown-top");
217
+ var bottom = el.querySelector(".cmu-countdown-bottom");
218
+ var back = el.querySelector(".cmu-countdown-back");
219
+ // The bottom half shows the old value until the new one flips down over
220
+ // it; the first time there's no old value, so it starts with the new one
221
+ var old = el.cmuValue === undefined ? value : el.cmuValue;
222
+ back.setAttribute("data-value", old);
223
+ bottom.setAttribute("data-value", old);
224
+ el.cmuValue = value;
225
+ top.textContent = value;
226
+ back.querySelector(".cmu-countdown-bottom").setAttribute("data-value", value);
227
+ el.classList.remove("cmu-countdown-flip");
228
+ void el.offsetWidth; // restart the animation
229
+ el.classList.add("cmu-countdown-flip");
230
+ // Once the flip is done, the front catches up, so a narrower old value
231
+ // (02 before 155) doesn't show at the edge
232
+ clearTimeout(el.cmuSync);
233
+ el.cmuSync = setTimeout(function () {
234
+ back.setAttribute("data-value", value);
235
+ bottom.setAttribute("data-value", value);
236
+ }, 650);
237
+ }
238
+ function countdown(root) {
239
+ var end = new Date(root.getAttribute("data-cmu-countdown"));
240
+ if (isNaN(end)) return;
241
+ clearInterval(root.cmuCountdown);
242
+ var clock = root.querySelector(".cmu-countdown-clock");
243
+ if (!clock) {
244
+ clock = document.createElement("div");
245
+ clock.className = "cmu-countdown-clock";
246
+ clock.setAttribute("aria-hidden", "true");
247
+ UNITS.forEach(function (u) {
248
+ clock.appendChild(card(u[0]));
249
+ });
250
+ // Before the done message, if there is one, so it shows under the cards
251
+ root.insertBefore(clock, root.querySelector(":scope > .cmu-countdown-done"));
252
+ root.classList.add("cmu-countdown-on");
253
+ }
254
+ var units = clock.children,
255
+ done = root.querySelector(":scope > .cmu-countdown-done");
256
+ function tick() {
257
+ var left = Math.max(end - new Date(), 0);
258
+ UNITS.forEach(function (u, i) {
259
+ var n = Math.floor(left / u[1]);
260
+ if (u[2] !== Infinity) n %= u[2];
261
+ flip(units[i], String(n).padStart(2, "0"));
262
+ });
263
+ if (done) done.hidden = left > 0;
264
+ root.classList.toggle("cmu-countdown-ended", !left);
265
+ if (!left) clearInterval(root.cmuCountdown);
266
+ }
267
+ root.cmuCountdown = setInterval(tick, 1000);
268
+ tick();
269
+ }
270
+ function startCountdowns(scope) {
271
+ if (scope.querySelectorAll) scope.querySelectorAll("[data-cmu-countdown]").forEach(countdown);
272
+ if (scope.matches && scope.matches("[data-cmu-countdown]")) countdown(scope);
273
+ }
274
+ // Email links: <a data-cmu-email="ac00(through)andrew.cmu.edu">Email Andrew
275
+ // Carnegie</a>. The address isn't in the page's HTML, and the link isn't
276
+ // built until someone points at it, focuses it or taps it, so scrapers that
277
+ // only read or load the page don't find it. Until then it's announced and
278
+ // focusable as a link. The link keeps its own words. See Email Links in the
279
+ // docs (content.html).
280
+ function emailReady(a) {
281
+ if (a.hasAttribute("href")) return;
282
+ a.setAttribute("role", "link");
283
+ a.tabIndex = 0;
284
+ }
285
+ function emailOpen(a) {
286
+ if (a.hasAttribute("href")) return;
287
+ a.href = "mailto:" + a.getAttribute("data-cmu-email").replace("(through)", "@");
288
+ a.removeAttribute("role");
289
+ a.removeAttribute("tabindex");
290
+ }
291
+ function startEmails(scope) {
292
+ if (scope.querySelectorAll) scope.querySelectorAll("a[data-cmu-email]").forEach(emailReady);
293
+ if (scope.matches && scope.matches("a[data-cmu-email]")) emailReady(scope);
294
+ }
295
+ startEmails(document);
296
+ ["mouseover", "focusin", "pointerdown", "touchstart"].forEach(function (type) {
297
+ document.addEventListener(
298
+ type,
299
+ function (e) {
300
+ var a = e.target.closest && e.target.closest("a[data-cmu-email]");
301
+ if (a) emailOpen(a);
302
+ },
303
+ { passive: true },
304
+ );
305
+ });
306
+ // Enter on the focused link: build it (if focus didn't already) and follow it
307
+ document.addEventListener("keydown", function (e) {
308
+ var a = e.key === "Enter" && e.target.closest && e.target.closest("a[data-cmu-email]");
309
+ if (a && !a.hasAttribute("href")) {
310
+ emailOpen(a);
311
+ a.click();
312
+ }
313
+ });
314
+
315
+ // Sorting: <ol class="cmu-sort" data-cmu-sort> whose items people put in
316
+ // order. Adds up and down arrow buttons to each item (named for the item, for
317
+ // screen readers), keeps focus on the moved item and says where it went. A
318
+ // hidden input in each item moves with it, so a form sends the new order.
319
+ // Without the script the list keeps its first order. See Sorting in the
320
+ // docs (forms.html).
321
+ //
322
+ // A page restored from a cache (Turbo) already has the kit's buttons, and
323
+ // gets no second set.
324
+ //
325
+ // In a framework (React, Svelte, Vue), which owns the list: render the
326
+ // buttons yourself (<button data-cmu-move="-1"> and "1" in each item), and
327
+ // the script adds none and leaves their disabled state to you. Each move
328
+ // first fires a cancelable cmu-sort-move event on the list, with detail
329
+ // { item, from, to } (0-based). Call preventDefault(), reorder your own
330
+ // data and let the framework re-render; the script then doesn't move
331
+ // anything, but still announces the new place and keeps focus on the
332
+ // button.
333
+ function sortItems(list) {
334
+ return Array.prototype.filter.call(list.children, function (el) {
335
+ return el.tagName === "LI";
336
+ });
337
+ }
338
+ // The item's words, without its buttons'
339
+ function sortName(li) {
340
+ var out = "",
341
+ walk = document.createTreeWalker(li, NodeFilter.SHOW_TEXT);
342
+ while (walk.nextNode())
343
+ if (!walk.currentNode.parentElement.closest("[data-cmu-move]"))
344
+ out += walk.currentNode.nodeValue;
345
+ return out.trim().replace(/\s+/g, " ");
346
+ }
347
+ function sortable(list) {
348
+ if (list.cmuSort) return;
349
+ list.cmuSort = true;
350
+ sortItems(list).forEach(function (li) {
351
+ // The page brought its own buttons
352
+ if (li.querySelector("[data-cmu-move]")) return;
353
+ var name = sortName(li),
354
+ moves = document.createElement("span");
355
+ moves.className = "cmu-sort-moves";
356
+ // Arrows, like the sortable table headers; the label names the item
357
+ [
358
+ ["-1", "up", "\u25B2"],
359
+ ["1", "down", "\u25BC"],
360
+ ].forEach(function (m) {
361
+ var b = document.createElement("button");
362
+ b.type = "button";
363
+ b.className = "btn ghost small";
364
+ b.setAttribute("data-cmu-move", m[0]);
365
+ b.setAttribute("aria-label", "Move " + name + " " + m[1]);
366
+ b.title = "Move " + m[1];
367
+ b.textContent = m[2];
368
+ // An attribute, not a property, so a cached copy of the page (Turbo)
369
+ // still marks these as the kit's
370
+ b.setAttribute("data-cmu-kit", "");
371
+ moves.appendChild(b);
372
+ });
373
+ li.appendChild(moves);
374
+ });
375
+ sortEnds(list);
376
+ }
377
+ // The first item can't go up, and the last can't go down (only the
378
+ // buttons the script added; the page sets its own)
379
+ function sortEnds(list) {
380
+ var items = sortItems(list);
381
+ items.forEach(function (li, i) {
382
+ li.querySelectorAll("[data-cmu-move]").forEach(function (b) {
383
+ if (!b.hasAttribute("data-cmu-kit")) return;
384
+ b.disabled = b.getAttribute("data-cmu-move") === "-1" ? i === 0 : i === items.length - 1;
385
+ });
386
+ });
387
+ }
388
+ function startSorts(scope) {
389
+ if (scope.querySelectorAll) scope.querySelectorAll("[data-cmu-sort]").forEach(sortable);
390
+ if (scope.matches && scope.matches("[data-cmu-sort]")) sortable(scope);
391
+ }
392
+ startSorts(document);
393
+ document.addEventListener("click", function (e) {
394
+ var b = e.target.closest && e.target.closest("[data-cmu-sort] [data-cmu-move]");
395
+ if (!b || b.disabled) return;
396
+ var li = b.closest("li"),
397
+ list = li.parentElement,
398
+ items = sortItems(list),
399
+ dir = Number(b.getAttribute("data-cmu-move")),
400
+ from = items.indexOf(li),
401
+ to = from + dir;
402
+ if (to < 0 || to >= items.length) return;
403
+ var name = sortName(li),
404
+ move = new CustomEvent("cmu-sort-move", {
405
+ bubbles: true,
406
+ cancelable: true,
407
+ detail: { item: li, from: from, to: to },
408
+ });
409
+ if (list.dispatchEvent(move)) {
410
+ var other = items[to];
411
+ list.insertBefore(dir < 0 ? li : other, dir < 0 ? other : li);
412
+ sortEnds(list);
413
+ // At the top or bottom now, focus the other button
414
+ (b.disabled ? li.querySelector('[data-cmu-move="' + -dir + '"]') : b).focus();
415
+ } else {
416
+ // The page moves it. Once it has re-rendered, keep focus on the moved
417
+ // item's button (the other one if this one is now disabled), even if
418
+ // the page rebuilt the list. Frameworks re-render on their own
419
+ // schedule, so check a few times; focus only comes back if it was lost
420
+ // (on the page itself, or on a now-disabled button), never taken from
421
+ // where someone moved it.
422
+ var keep = function () {
423
+ var item = b.isConnected ? li : sortItems(list)[to];
424
+ if (!item) return;
425
+ var now = document.activeElement,
426
+ lost = !now || now === document.body || now === b || (now.disabled && list.contains(now));
427
+ if (!lost && now !== b) return;
428
+ var same = b.isConnected ? b : item.querySelector('[data-cmu-move="' + dir + '"]'),
429
+ stay =
430
+ same && !same.disabled ? same : item.querySelector('[data-cmu-move="' + -dir + '"]');
431
+ if (stay && now !== stay) stay.focus();
432
+ };
433
+ requestAnimationFrame(keep);
434
+ [50, 150, 300].forEach(function (ms) {
435
+ setTimeout(keep, ms);
436
+ });
437
+ }
438
+ announce(name + ", " + (to + 1) + " of " + items.length);
439
+ });
440
+
441
+ // Row menu: <div class="cmu-row-menu"> holds a <button class="btn small
442
+ // ghost" data-cmu-menu aria-controls="list-id" aria-expanded="false"
443
+ // aria-label="More actions for ..."> and the list, <div
444
+ // class="cmu-row-menu-list" id="list-id" hidden>, of the row's other
445
+ // actions. The button opens the list under it, one at a time; a click
446
+ // elsewhere or Escape closes it. See Tables With Actions in the docs
447
+ // (lists.html).
448
+ function closeMenus(except) {
449
+ document.querySelectorAll('[data-cmu-menu][aria-expanded="true"]').forEach(function (b) {
450
+ if (b === except) return;
451
+ b.setAttribute("aria-expanded", "false");
452
+ var list = document.getElementById(b.getAttribute("aria-controls"));
453
+ if (list) list.hidden = true;
454
+ });
455
+ }
456
+ document.addEventListener("click", function (e) {
457
+ var toggle = e.target.closest && e.target.closest("[data-cmu-menu]");
458
+ if (toggle) {
459
+ var open = toggle.getAttribute("aria-expanded") !== "true";
460
+ closeMenus(toggle);
461
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
462
+ var list = document.getElementById(toggle.getAttribute("aria-controls"));
463
+ if (list) list.hidden = !open;
464
+ return;
465
+ }
466
+ if (!(e.target.closest && e.target.closest(".cmu-row-menu-list"))) closeMenus();
467
+ });
468
+ document.addEventListener("keydown", function (e) {
469
+ if (e.key !== "Escape") return;
470
+ var open = document.querySelector('[data-cmu-menu][aria-expanded="true"]');
471
+ if (open) {
472
+ closeMenus();
473
+ open.focus();
474
+ }
475
+ });
476
+
477
+ // ID tap: <div class="cmu-tap-target"> holds a label and a hidden <input
478
+ // data-cmu-tap>, where an NFC card reader types an ID card's number and a
479
+ // carriage return (Enter). When it's the only tap on the page, the field
480
+ // takes focus when the page loads, so the reader is listening without a click;
481
+ // selecting the target focuses it again, and so does sending its form,
482
+ // ready for the next card. With data-cmu-tap="keep",
483
+ // on a page that's only for tapping, it also takes focus whenever nothing
484
+ // else has it. With data-cmu-tap="click", it waits to be selected, for a
485
+ // page where something else should have focus first. See ID Tap in the docs
486
+ // (forms.html).
487
+ function focusTap(field) {
488
+ if (field) field.focus({ preventScroll: true });
489
+ }
490
+ // Nothing has focus, or only the template's menu does, which it takes by
491
+ // itself when it opens on a wide screen
492
+ function idle() {
493
+ var active = document.activeElement;
494
+ return !active || active === document.body || active.id === "nav";
495
+ }
496
+ function keepTap() {
497
+ var field = document.querySelector('[data-cmu-tap="keep"]');
498
+ if (field && idle()) focusTap(field);
499
+ }
500
+ // The only tap on the page starts ready; with several, each waits to be
501
+ // selected, since a reader can only type into one
502
+ function startTap() {
503
+ // A tap in a dialog doesn't count unless the dialog is open as one: it
504
+ // focuses when the dialog opens, through its autofocus
505
+ var fields = Array.prototype.filter.call(
506
+ document.querySelectorAll("[data-cmu-tap]"),
507
+ function (f) {
508
+ var d = f.closest("dialog");
509
+ if (!d) return true;
510
+ try {
511
+ return d.matches(":modal");
512
+ } catch {
513
+ // A browser without :modal: wait for the autofocus
514
+ return false;
515
+ }
516
+ },
517
+ );
518
+ if (fields.length === 1 && fields[0].getAttribute("data-cmu-tap") !== "click" && idle())
519
+ focusTap(fields[0]);
520
+ }
521
+ document.addEventListener("click", function (e) {
522
+ var target = e.target.closest && e.target.closest(".cmu-tap-target");
523
+ if (target) focusTap(target.querySelector("[data-cmu-tap]"));
524
+ });
525
+ document.addEventListener("submit", function (e) {
526
+ var field = e.target.querySelector && e.target.querySelector("[data-cmu-tap]");
527
+ if (field)
528
+ setTimeout(function () {
529
+ focusTap(field);
530
+ }, 0);
531
+ });
532
+ setInterval(keepTap, 2000);
533
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", startTap);
534
+ else startTap();
535
+ // Turbo's page visits replace the body without a new page load
536
+ document.addEventListener("turbo:load", startTap);
537
+
538
+ // File drop zone / picker: <label class="cmu-drop"> wrapping an <input
539
+ // type="file">, its words and an empty <span class="cmu-drop-file">.
540
+ // Choosing works without the script; with it, a file dragged onto the zone
541
+ // is chosen too, the zone gets cmu-drop-over while one is over it and
542
+ // cmu-drop-has-file once one is chosen, and cmu-drop-file names it. See File
543
+ // Drop Zone / Picker in the docs (forms.html).
544
+ function dropZone(e) {
545
+ return e.target.closest && e.target.closest("label.cmu-drop");
546
+ }
547
+ function showFiles(zone) {
548
+ var input = zone.querySelector('input[type="file"]');
549
+ var shown = zone.querySelector(".cmu-drop-file");
550
+ var names = Array.prototype.map.call(input.files || [], function (f) {
551
+ return f.name;
552
+ });
553
+ zone.classList.toggle("cmu-drop-has-file", names.length > 0);
554
+ if (shown) {
555
+ shown.textContent = names.join(", ");
556
+ shown.title = names.join(", ");
557
+ shown.hidden = false; // an empty line keeps its place, so nothing moves
558
+ }
559
+ }
560
+ ["dragenter", "dragover"].forEach(function (type) {
561
+ document.addEventListener(type, function (e) {
562
+ var zone = dropZone(e);
563
+ if (!zone) return;
564
+ e.preventDefault();
565
+ zone.classList.add("cmu-drop-over");
566
+ });
567
+ });
568
+ document.addEventListener("dragleave", function (e) {
569
+ var zone = dropZone(e);
570
+ if (zone && !zone.contains(e.relatedTarget)) zone.classList.remove("cmu-drop-over");
571
+ });
572
+ document.addEventListener("drop", function (e) {
573
+ var zone = dropZone(e);
574
+ if (!zone) return;
575
+ e.preventDefault();
576
+ zone.classList.remove("cmu-drop-over");
577
+ var input = zone.querySelector('input[type="file"]');
578
+ if (!input || !e.dataTransfer || !e.dataTransfer.files.length) return;
579
+ input.files = e.dataTransfer.files;
580
+ input.dispatchEvent(new Event("change", { bubbles: true }));
581
+ });
582
+ document.addEventListener("change", function (e) {
583
+ var zone = dropZone(e);
584
+ if (zone && e.target.type === "file") showFiles(zone);
585
+ });
586
+
587
+ // Tab panels: tabs that switch content in place, in the same row and look as
588
+ // the link tabs. <div class="cmu-tabs" role="tablist" aria-label="...">
589
+ // holds <button type="button" role="tab" aria-controls="panel-id"> tabs, and
590
+ // each panel is a <div role="tabpanel" id="panel-id">. Mark the open tab
591
+ // aria-selected="true" (else the first opens) and the other panels hidden.
592
+ // Click or arrow keys switch. With data-cmu-tabs="name" on the row and
593
+ // data-cmu-tab="value" on each tab, every row with that name switches
594
+ // together, and the choice is remembered in this browser across pages.
595
+ // Without the script, every panel shows. See Tabs in the docs (layout.html).
596
+ function tabsOf(list) {
597
+ return Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
598
+ }
599
+ function selectTab(tab) {
600
+ tabsOf(tab.closest('[role="tablist"]')).forEach(function (t) {
601
+ var on = t === tab;
602
+ t.setAttribute("aria-selected", on ? "true" : "false");
603
+ t.tabIndex = on ? 0 : -1;
604
+ var panel = document.getElementById(t.getAttribute("aria-controls"));
605
+ if (panel) panel.hidden = !on;
606
+ });
607
+ }
608
+ function pickTabs(name, value) {
609
+ document
610
+ .querySelectorAll('.cmu-tabs[role="tablist"][data-cmu-tabs="' + name + '"]')
611
+ .forEach(function (list) {
612
+ var tabs = tabsOf(list);
613
+ var tab =
614
+ tabs.filter(function (t) {
615
+ return t.getAttribute("data-cmu-tab") === value;
616
+ })[0] || tabs[0];
617
+ if (tab) selectTab(tab);
618
+ });
619
+ }
620
+ // The chosen tab is kept in a cookie, not localStorage: the template's own
621
+ // script clears all of localStorage on every page (for its list filters).
622
+ // For this site only, for a year.
623
+ function storedTab(name) {
624
+ var m = document.cookie.match(new RegExp("(?:^|; )cmu-tabs-" + name + "=([^;]*)"));
625
+ return m ? decodeURIComponent(m[1]) : null;
626
+ }
627
+ function storeTab(name, value) {
628
+ document.cookie =
629
+ "cmu-tabs-" +
630
+ name +
631
+ "=" +
632
+ encodeURIComponent(value) +
633
+ "; path=/; max-age=31536000; SameSite=Lax";
634
+ }
635
+ function startTabs(root) {
636
+ if (!root.querySelectorAll) return;
637
+ root.querySelectorAll('.cmu-tabs[role="tablist"]').forEach(function (list) {
638
+ if (list.cmuTabs) return;
639
+ list.cmuTabs = true;
640
+ var tabs = tabsOf(list);
641
+ var name = list.getAttribute("data-cmu-tabs");
642
+ var saved = name && storedTab(name);
643
+ var tab =
644
+ (saved &&
645
+ tabs.filter(function (t) {
646
+ return t.getAttribute("data-cmu-tab") === saved;
647
+ })[0]) ||
648
+ tabs.filter(function (t) {
649
+ return t.getAttribute("aria-selected") === "true";
650
+ })[0] ||
651
+ tabs[0];
652
+ if (tab) selectTab(tab);
653
+ });
654
+ }
655
+ document.addEventListener("click", function (e) {
656
+ var tab = e.target.closest && e.target.closest('.cmu-tabs[role="tablist"] [role="tab"]');
657
+ if (!tab) return;
658
+ var list = tab.closest('[role="tablist"]');
659
+ var name = list.getAttribute("data-cmu-tabs");
660
+ var value = tab.getAttribute("data-cmu-tab");
661
+ if (!name || !value) return selectTab(tab);
662
+ // Every row with this name switches; keep the clicked one still on screen
663
+ var before = tab.getBoundingClientRect().top;
664
+ pickTabs(name, value);
665
+ storeTab(name, value);
666
+ window.scrollBy(0, tab.getBoundingClientRect().top - before);
667
+ });
668
+ document.addEventListener("keydown", function (e) {
669
+ var list = e.target.closest && e.target.closest('.cmu-tabs[role="tablist"]');
670
+ if (!list || e.target.getAttribute("role") !== "tab") return;
671
+ var tabs = tabsOf(list);
672
+ var i = tabs.indexOf(e.target);
673
+ var to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key];
674
+ if (to === undefined) return;
675
+ e.preventDefault();
676
+ var tab = tabs[(to + tabs.length) % tabs.length];
677
+ tab.focus();
678
+ tab.click();
679
+ });
680
+
681
+ // Scroll gallery: <div class="cmu-scroll-gallery" data-cmu-scroll-gallery>
682
+ // holds steps, <div class="cmu-scroll-gallery-step">, each with its text and
683
+ // one <figure>. On a wide screen, the text scrolls on the left while a sticky
684
+ // stage on the right shows the figure of the step in the middle of the
685
+ // window, changing as the next step arrives: fading up, or with
686
+ // cmu-scroll-gallery--slide, sliding in from the side in the direction of
687
+ // scrolling. The stage holds copies, hidden from screen readers; the steps
688
+ // keep their own figures, which show in place on a narrow screen or without
689
+ // the script. See Scroll Gallery in the docs (sections.html).
690
+ function startScrollGalleries(root) {
691
+ if (!root.querySelectorAll || !window.IntersectionObserver) return;
692
+ root.querySelectorAll("[data-cmu-scroll-gallery]").forEach(function (gallery) {
693
+ if (gallery.cmuGallery) return;
694
+ gallery.cmuGallery = true;
695
+ // A page restored from a cache (Turbo) has the old stage, which nothing
696
+ // watches; start over
697
+ gallery.querySelectorAll(":scope > .cmu-scroll-gallery-stage").forEach(function (old) {
698
+ old.remove();
699
+ });
700
+ var steps = Array.prototype.slice.call(gallery.querySelectorAll(".cmu-scroll-gallery-step"));
701
+ if (!steps.length) return;
702
+ var stage = document.createElement("div");
703
+ stage.className = "cmu-scroll-gallery-stage";
704
+ stage.setAttribute("aria-hidden", "true");
705
+ stage.style.gridRow = "1 / span " + steps.length;
706
+ var shown = steps.map(function (step) {
707
+ var figure = step.querySelector("figure");
708
+ var copy = figure ? figure.cloneNode(true) : document.createElement("figure");
709
+ copy.querySelectorAll("a, button, [tabindex]").forEach(function (el) {
710
+ el.setAttribute("tabindex", "-1");
711
+ });
712
+ stage.appendChild(copy);
713
+ return copy;
714
+ });
715
+ gallery.appendChild(stage);
716
+ gallery.classList.add("cmu-scroll-gallery--on");
717
+ // Figures before the current one wait on one side and those after it
718
+ // on the other, so each moves the way the page is scrolling
719
+ function show(i) {
720
+ shown.forEach(function (fig, n) {
721
+ fig.classList.toggle("cmu-scroll-gallery-past", n < i);
722
+ fig.classList.toggle("cmu-scroll-gallery-current", n === i);
723
+ fig.classList.toggle("cmu-scroll-gallery-next", n > i);
724
+ });
725
+ }
726
+ show(0);
727
+ // A step is current once it crosses the middle of the window
728
+ var watch = new IntersectionObserver(
729
+ function (entries) {
730
+ entries.forEach(function (entry) {
731
+ if (entry.isIntersecting) show(steps.indexOf(entry.target));
732
+ });
733
+ },
734
+ { rootMargin: "-50% 0px -50% 0px" },
735
+ );
736
+ steps.forEach(function (step) {
737
+ watch.observe(step);
738
+ });
739
+ });
740
+ }
741
+
742
+ // Search suggestions: <div class="cmu-suggest"> holding the field,
743
+ // <input role="combobox" aria-controls="(list id)" aria-expanded="false"
744
+ // aria-autocomplete="list">, and <ul class="cmu-suggest-list" id="(list id)"
745
+ // role="listbox" hidden>. The page fills the list with <li> options as
746
+ // people type, from its own data or a search service; this shows the list
747
+ // under the field while it has focus and options, bolds the typed text in
748
+ // each option and handles the keyboard: Up and Down move, Enter picks,
749
+ // Escape closes. Picking puts the option's data-value (or its text) in the
750
+ // field and fires input, change and cmu-suggest-pick (detail: the option).
751
+ // See Search Suggestions in the docs (forms.html).
752
+ function suggestParts(el) {
753
+ var box = el && el.closest && el.closest(".cmu-suggest");
754
+ if (!box) return null;
755
+ var input = box.querySelector("input"),
756
+ list = box.querySelector(".cmu-suggest-list");
757
+ return input && list ? { input: input, list: list } : null;
758
+ }
759
+ function suggestOptions(list) {
760
+ return Array.prototype.slice.call(list.querySelectorAll(":scope > li"));
761
+ }
762
+ function suggestActive(parts, i) {
763
+ var options = suggestOptions(parts.list);
764
+ options.forEach(function (option, n) {
765
+ option.setAttribute("aria-selected", n === i ? "true" : "false");
766
+ });
767
+ if (options[i]) {
768
+ parts.input.setAttribute("aria-activedescendant", options[i].id);
769
+ options[i].scrollIntoView({ block: "nearest" });
770
+ } else parts.input.removeAttribute("aria-activedescendant");
771
+ }
772
+ function suggestOpen(parts, open) {
773
+ open = open && suggestOptions(parts.list).length > 0 && document.activeElement === parts.input;
774
+ parts.list.hidden = !open;
775
+ parts.input.setAttribute("aria-expanded", open ? "true" : "false");
776
+ if (!open) suggestActive(parts, -1);
777
+ }
778
+ function suggestPick(parts, option) {
779
+ parts.input.value = option.getAttribute("data-value") || option.textContent;
780
+ suggestOpen(parts, false);
781
+ ["input", "change"].forEach(function (type) {
782
+ parts.input.dispatchEvent(new Event(type, { bubbles: true }));
783
+ });
784
+ parts.input.dispatchEvent(
785
+ new CustomEvent("cmu-suggest-pick", { bubbles: true, detail: option }),
786
+ );
787
+ }
788
+ // New options: give each an id and role, bold the typed text in plain
789
+ // ones, and show them
790
+ function suggestRefresh(list) {
791
+ var parts = suggestParts(list);
792
+ if (!parts) return;
793
+ var typed = parts.input.value.trim().toLowerCase();
794
+ suggestOptions(list).forEach(function (option, n) {
795
+ option.setAttribute("role", "option");
796
+ if (!option.id) option.id = list.id + "-" + n;
797
+ var text = option.textContent,
798
+ at = typed ? text.toLowerCase().indexOf(typed) : -1;
799
+ if (option.children.length || at < 0) return;
800
+ var strong = document.createElement("strong");
801
+ strong.textContent = text.slice(at, at + typed.length);
802
+ option.replaceChildren(text.slice(0, at), strong, text.slice(at + typed.length));
803
+ });
804
+ suggestOpen(parts, true);
805
+ }
806
+ document.addEventListener("keydown", function (e) {
807
+ var parts =
808
+ e.target.matches && e.target.matches(".cmu-suggest input") && suggestParts(e.target);
809
+ if (!parts) return;
810
+ var options = suggestOptions(parts.list),
811
+ i = options.findIndex(function (option) {
812
+ return option.getAttribute("aria-selected") === "true";
813
+ });
814
+ if ((e.key === "ArrowDown" || e.key === "ArrowUp") && options.length) {
815
+ e.preventDefault();
816
+ suggestOpen(parts, true);
817
+ suggestActive(
818
+ parts,
819
+ e.key === "ArrowDown" ? (i + 1) % options.length : i <= 0 ? options.length - 1 : i - 1,
820
+ );
821
+ } else if (e.key === "Enter" && !parts.list.hidden && i >= 0) {
822
+ e.preventDefault();
823
+ suggestPick(parts, options[i]);
824
+ } else if (e.key === "Escape" && !parts.list.hidden) {
825
+ e.preventDefault();
826
+ suggestOpen(parts, false);
827
+ }
828
+ });
829
+ // mousedown, not click, so the field keeps focus
830
+ document.addEventListener("mousedown", function (e) {
831
+ var option = e.target.closest && e.target.closest(".cmu-suggest-list > li");
832
+ if (!option) return;
833
+ e.preventDefault();
834
+ suggestPick(suggestParts(option), option);
835
+ });
836
+ ["focusin", "focusout"].forEach(function (type) {
837
+ document.addEventListener(type, function (e) {
838
+ var parts =
839
+ e.target.matches && e.target.matches(".cmu-suggest input") && suggestParts(e.target);
840
+ if (parts) suggestOpen(parts, type === "focusin");
841
+ });
842
+ });
843
+
844
+ startCountdowns(document);
845
+ startTabs(document);
846
+ startScrollGalleries(document);
847
+ // Countdowns, sorting lists, email links and tabs added later start, a
848
+ // countdown whose date changes restarts, and suggestion lists show new options
849
+ new MutationObserver(function (changes) {
850
+ changes.forEach(function (c) {
851
+ if (c.type === "attributes") countdown(c.target);
852
+ else {
853
+ // A suggestion list's options changed
854
+ if (c.target.classList && c.target.classList.contains("cmu-suggest-list"))
855
+ suggestRefresh(c.target);
856
+ c.addedNodes.forEach(function (n) {
857
+ startCountdowns(n);
858
+ startSorts(n);
859
+ startEmails(n);
860
+ startTabs(n);
861
+ startScrollGalleries(n);
862
+ });
863
+ }
864
+ });
865
+ }).observe(document.documentElement, {
866
+ childList: true,
867
+ subtree: true,
868
+ attributes: true,
869
+ attributeFilter: ["data-cmu-countdown"],
870
+ });
871
+ })();