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.
- checksums.yaml +7 -0
- data/LICENSE +28 -0
- data/README.md +114 -0
- data/VERSION +1 -0
- data/config/importmap.rb +3 -0
- data/css/cmu-app.css +2044 -0
- data/css/cmu-cms.css +1433 -0
- data/js/cmu-app.js +871 -0
- data/lib/cmu-web-kilt.rb +3 -0
- data/lib/cmu_web_kilt/engine.rb +25 -0
- data/lib/cmu_web_kilt/version.rb +21 -0
- data/lib/cmu_web_kilt.rb +4 -0
- metadata +78 -0
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">×</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
|
+
})();
|