1/* ========================================================================== 2 The command palette 3 -------------------------------------------------------------------------- 4 Cmd-K, or Ctrl-K, or "/" from anywhere that is not a text field. Type a few 5 letters, hit Enter, go. It indexes the page's own sections and links at 6 load, so there is no list to keep in step with the navigation - add a 7 section and it is in the palette. 8 9 WHY IT IS WORTH THE KILOBYTES ON A MARKETING SITE. Most visitors will never 10 press it, and that is fine: it costs them nothing, because the whole thing 11 binds one keydown listener and builds no DOM until it is opened. The ones 12 who do press it are developers, and they are the audience. 13 14 Matching is a subsequence test, not a substring one: "vbs" finds "Village 15 Banking System" the way a fuzzy finder does. Ranked by how tightly the 16 letters landed, so an exact prefix beats letters scattered across a phrase. 17 ========================================================================== */ 18 19(function () { 20 "use strict"; 21 22 var overlay = null, input = null, list = null, items = [], active = 0, lastFocus = null; 23 24 /* ------------------------------------------------------------- the index */ 25 function build() { 26 var seen = {}, out = []; 27 28 function add(label, href, group) { 29 var key = group + "|" + label.toLowerCase(); 30 if (!label || seen[key]) return; 31 seen[key] = 1; 32 out.push({ label: label.replace(/\s+/g, " ").trim(), href: href, group: group }); 33 } 34 35 document.querySelectorAll("main section[id], main div[id]").forEach(function (s) { 36 var h = s.querySelector("h1, h2, .statement__title, .section__title"); 37 if (h) add(h.textContent, "#" + s.id, "On this page"); 38 }); 39 document.querySelectorAll(".nav a[href], .footer a[href]").forEach(function (a) { 40 var href = a.getAttribute("href"); 41 if (!href || href.charAt(0) === "#" || /^(mailto|tel):/.test(href)) return; 42 add(a.textContent, href, /blog/.test(href) ? "Writing" : "Pages"); 43 }); 44 document.querySelectorAll("a[href^='/blog/']").forEach(function (a) { 45 var t = a.querySelector("h2, h3"); 46 if (t) add(t.textContent, a.getAttribute("href"), "Writing"); 47 }); 48 49 add("Switch theme", "#theme", "Actions"); 50 return out; 51 } 52 53 /* SUBSEQUENCE, NOT SUBSTRING. Returns null for no match, otherwise a score 54 where lower is tighter - consecutive letters cost nothing, gaps cost. */ 55 function score(needle, hay) { 56 if (!needle) return 0; 57 var n = needle.toLowerCase(), h = hay.toLowerCase(); 58 var i = 0, gaps = 0, lastHit = -1; 59 for (var j = 0; j < h.length && i < n.length; j++) { 60 if (h[j] === n[i]) { 61 if (lastHit >= 0) gaps += j - lastHit - 1; 62 lastHit = j; i++; 63 } 64 } 65 if (i < n.length) return null; 66 return gaps + lastHit * 0.1; 67 } 68 69 /* -------------------------------------------------------------- the view */ 70 function open() { 71 if (overlay) return; 72 lastFocus = document.activeElement; 73 items = build(); 74 75 overlay = document.createElement("div"); 76 overlay.className = "palette"; 77 overlay.setAttribute("role", "dialog"); 78 overlay.setAttribute("aria-modal", "true"); 79 overlay.setAttribute("aria-label", "Command palette"); 80 overlay.innerHTML = 81 '<div class="palette__panel">' + 82 '<div class="palette__bar">' + 83 '<span class="palette__prompt" aria-hidden="true">›</span>' + 84 '<input class="palette__input" type="text" placeholder="Jump toâ¦" ' + 85 'autocomplete="off" spellcheck="false" aria-label="Search the site" ' + 86 'role="combobox" aria-expanded="false" aria-autocomplete="list" ' + 87 'aria-controls="palette-list">' + 88 '<kbd class="palette__esc">esc</kbd>' + 89 '</div>' + 90 '<div class="palette__list" id="palette-list" role="listbox" ' + 91 'aria-label="Results"></div>' + 92 '</div>'; 93 document.body.appendChild(overlay); 94 document.documentElement.style.overflow = "hidden"; 95 96 input = overlay.querySelector(".palette__input"); 97 list = overlay.querySelector(".palette__list"); 98 input.addEventListener("input", render); 99 input.addEventListener("keydown", onKey); 100 overlay.addEventListener("mousedown", function (e) { if (e.target === overlay) close(); }); 101 102 render(); 103 input.focus(); 104 } 105 106 function close() { 107 if (!overlay) return; 108 overlay.remove();
109 overlay = null; 110 document.documentElement.style.overflow = ""; 111 if (lastFocus && lastFocus.focus) lastFocus.focus(); 112 } 113 114 function matches() { 115 var q = input.value.trim(); 116 return items 117 .map(function (it) { var s = score(q, it.label); return s === null ? null : { it: it, s: s }; }) 118 .filter(Boolean) 119 .sort(function (a, b) { return a.s - b.s; }) 120 .slice(0, 9) 121 .map(function (x) { return x.it; }); 122 } 123 124 var shown = []; 125 function render() { 126 shown = matches(); 127 active = 0; 128 list.textContent = ""; 129 if (!shown.length) { 130 var none = document.createElement("p"); 131 none.className = "palette__empty"; 132 none.textContent = "Nothing matches that."; 133 list.appendChild(none); 134 input.removeAttribute("aria-activedescendant"); 135 input.setAttribute("aria-expanded", "false"); 136 return; 137 } 138 shown.forEach(function (it, i) { 139 var row = document.createElement("button"); 140 row.type = "button"; 141 row.className = "palette__item" + (i === 0 ? " is-active" : ""); 142 row.setAttribute("role", "option"); 143 /* The rows are <button>, so they are in the tab order by default - which 144 means Tab walks THROUGH the results instead of the arrow keys driving 145 them, and the is-active highlight and the real focus ring drift apart. 146 Focus stays on the input; the rows are pointed at, not tabbed to. */ 147 row.id = "palette-opt-" + i; 148 row.tabIndex = -1; 149 row.setAttribute("aria-selected", i === 0 ? "true" : "false"); 150 row.innerHTML = '<span class="palette__label"></span><span class="palette__group"></span>'; 151 row.querySelector(".palette__label").textContent = it.label; 152 row.querySelector(".palette__group").textContent = it.group; 153 row.addEventListener("click", function () { go(it); }); 154 row.addEventListener("mousemove", function () { setActive(i); }); 155 list.appendChild(row); 156 }); 157 input.setAttribute("aria-expanded", "true"); 158 input.setAttribute("aria-activedescendant", "palette-opt-0"); 159 } 160 161 function setActive(i) { 162 var rows = list.querySelectorAll(".palette__item"); 163 if (!rows.length) return; 164 active = (i + rows.length) % rows.length; 165 rows.forEach(function (r, n) { 166 r.classList.toggle("is-active", n === active); 167 r.setAttribute("aria-selected", n === active ? "true" : "false"); 168 }); 169 /* Without this the highlight is purely visual: focus never leaves the 170 input, so a screen reader announces nothing as the arrow keys move. */ 171 input.setAttribute("aria-activedescendant", rows[active].id); 172 rows[active].scrollIntoView({ block: "nearest" }); 173 } 174 175 function go(it) { 176 close(); 177 if (it.href === "#theme") { 178 var t = document.querySelector("[data-theme-toggle]"); 179 if (t) t.click(); 180 return; 181 } 182 if (it.href.charAt(0) === "#") { 183 var target = document.querySelector(it.href); 184 if (target) { 185 target.scrollIntoView({ behavior: "smooth", block: "start" }); 186 history.replaceState(null, "", it.href); 187 } 188 return; 189 } 190 window.location.href = it.href; 191 } 192 193 function onKey(e) { 194 /* The overlay says aria-modal="true", which tells assistive technology that 195 nothing behind it exists. Tab did not agree: it walked straight out to the 196 page underneath, leaving focus somewhere invisible behind the scrim with 197 no way back except the mouse. Nothing outside is reachable, so the only 198 correct destination for Tab is where focus already is. */ 199 if (e.key === "Tab") { e.preventDefault(); return; } 200 if (e.key === "Escape") { e.preventDefault(); close(); } 201 else if (e.key === "ArrowDown") { e.preventDefault(); setActive(active + 1); } 202 else if (e.key === "ArrowUp") { e.preventDefault(); setActive(active - 1); } 203 else if (e.key === "Enter") { e.preventDefault(); if (shown[active]) go(shown[active]); } 204 } 205 206 /* ------------------------------------------------------------ the trigger */ 207 document.addEventListener("keydown", function (e) { 208 var typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName) || 209 document.activeElement.isContentEditable; 210 if ((e.key === "k" || e.key === "K") && (e.metaKey || e.ctrlKey)) { 211 e.preventDefault(); 212 overlay ? close() : open(); 213 return; 214 } 215 /* "/" is the other muscle memory, and it must not fire while somebody is 216 typing a slash into the contact form. */ 217 if (e.key === "/" && !typing && !overlay) { e.preventDefault(); open(); } 218 }); 219
220 /* Something visible has to say it exists, or nobody finds it. */ 221 document.querySelectorAll("[data-palette-open]").forEach(function (b) { 222 b.addEventListener("click", open); 223 b.hidden = false; 224 }); 225})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.