1/* 2 * Custom search controller for tabulator.info. 3 * 4 * Implements the redesigned input + dropdown ("Variation C" â see the design 5 * handoff in the chat), driven by Pagefind's headless API rather than its 6 * default UI widget. Wires up: 7 * 8 * - debounced search() on type, with loading / results / no-results states 9 * - an idle state showing recent searches (localStorage) + popular pages 10 * - keyboard navigation: â â âµ esc, plus â/Ctrl+K to focus 11 * - click-outside to close 12 * 13 * Hooks: every element matching `[data-search]` becomes a self-contained 14 * search instance, so the navbar (desktop) and sidebar (mobile) inputs each 15 * have their own dropdown but share the loaded Pagefind index. 16 */ 17(function () { 18 "use strict"; 19 20 // ---- icons & static config ------------------------------------------------- 21 22 var TYPE_ICON = { 23 doc: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"/><path d="M9 9h1M9 13h6M9 17h6"/></svg>', 24 example: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 18l6-6-6-6"/><path d="M8 6l-6 6 6 6"/></svg>', 25 news: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2zm0 0a2 2 0 0 1-2-2v-9h4"/><path d="M18 14h-8M15 18h-5M10 6h8v4h-8z"/></svg>', 26 }; 27 28 var POPULAR_PAGES = [ 29 { type: "doc", title: "Quickstart", section: "Documentation", url: "/docs/6.x/quickstart" }, 30 { type: "doc", title: "Setup Options", section: "Documentation", url: "/docs/6.x/options" }, 31 { type: "doc", title: "Formatting Data", section: "Documentation", url: "/docs/6.x/format" }, 32 { type: "example", title: "Example Library", section: "Examples", url: "/examples/6.x/" }, 33 ]; 34 35 var RECENT_KEY = "tabulator-search-recent"; 36 var RECENT_MAX = 6; 37 38 // â on Mac, Ctrl everywhere else. 39 var IS_MAC = /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform || ""); 40 var KBD_LABEL = IS_MAC ? "âK" : "Ctrl K"; 41 42 // ---- helpers --------------------------------------------------------------- 43 44 function escapeHTML(s) { 45 return String(s).replace(/[&<>"']/g, function (c) { 46 return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; 47 }); 48 } 49 50 // Strip the version segment & extension so "/docs/6.4/install/" â "Install". 51 // Used only as a fallback when Pagefind didn't pick up a title. 52 function titleFromURL(url) { 53 var slug = (url || "").replace(/\/$/, "").split("/").pop() || "Page"; 54 return slug.replace(/[-_]/g, " ").replace(/\b\w/g, function (c) { return c.toUpperCase(); }); 55 } 56 57 // The current page's version, so we can filter the Pagefind query down â 58 // otherwise /docs/6.x/ pages would surface 5.6 results too. Pages stamp 59 // `data-pagefind-filter="version:6.x"` (or 5.6, 4.9, ...) on <main> in 60 // baseof.html; pages without a version (homepage) fall back to the current 61 // major slug from the <meta name="release-slug">. 62 function currentVersion() { 63 var m = location.pathname.match(/^\/(?:docs|examples)\/([\dx.]+)/); 64 if (m) return m[1]; 65 var slug = document.querySelector('meta[name="release-slug"]'); 66 return slug ? slug.content : null; 67 } 68 69 // Map URL to a (type, breadcrumb) pair for the chip + uppercase trail. 70 function deriveCrumb(url) { 71 if (/^\/docs\//.test(url)) { 72 var m = url.match(/^\/docs\/([^\/]+)/); 73 return { type: "doc", crumb: ["Docs", m ? m[1] : ""] }; 74 } 75 if (/^\/examples\//.test(url)) { 76 var m2 = url.match(/^\/examples\/([^\/]+)/); 77 return { type: "example", crumb: ["Examples", m2 ? m2[1] : ""] }; 78 } 79 if (/^\/news/.test(url)) return { type: "news", crumb: ["News"] }; 80 if (/^\/community/.test(url)) return { type: "doc", crumb: ["Community"] }; 81 return { type: "doc", crumb: ["Site"] }; 82 } 83 84 function loadRecent() { 85 try { return JSON.parse(localStorage.getItem(RECENT_KEY) || "[]"); } 86 catch (e) { return []; } 87 } 88 function pushRecent(q) { 89 if (!q) return; 90 var list = loadRecent().filter(function (x) { return x !== q; }); 91 list.unshift(q); 92 if (list.length > RECENT_MAX) list.length = RECENT_MAX; 93 try { localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch (e) {} 94 } 95 96 // ---- Pagefind loader (lazy, shared) ---------------------------------------- 97 98 var pagefindPromise = null; 99 function loadPagefind() { 100 if (pagefindPromise) return pagefindPromise; 101 pagefindPromise = import("/pagefind/pagefind.js") 102 .then(function (m) { 103 // termSimilarity boosts pages where matched terms appear close 104 // together â so "redrawing the table" prefers a page where that 105 // exact phrase exists over a page where each word is sprinkled 106 // around. termSaturation pulled back from default 1.4 to 1.0 to 107 // stop pages with many body-level repetitions of common words 108 // ("table", "row") from drowning out heading-level matches. 109 return m.options ? m.options({ 110 excerptLength: 30, 111 ranking: { termSimilarity: 5.0, termSaturation: 1.0, pageLength: 0.6 }, 112 }).then(function () { return m; }) : m; 113 }) 114 .catch(function (err) { 115 console.warn("[search] Pagefind not loaded â run `bin/build` to generate the index.", err); 116 pagefindPromise = null; 117 throw err; 118 }); 119 return pagefindPromise; 120 } 121 122 // ---- render helpers (return HTML strings) ---------------------------------- 123 124 function tplIdle() { 125 var recent = loadRecent(); 126 var chips = recent.length 127 ? recent.map(function (s) { 128 return '<button class="dd-chip" data-recent="' + escapeHTML(s) + '">' + 129 '<svg viewBox="0 0 24 24" width="13" height="13" aria-hidden="true">' + 130 '<path d="M12 7v5l3 2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>' + 131 '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/></svg>
131' + 132 escapeHTML(s) + 133 "</button>"; 134 }).join("") 135 : '<span class="dd-pop-sec" style="padding:6px 2px;">No recent searches yet â start typing.</span>'; 136 137 var pop = POPULAR_PAGES.map(function (p) { 138 return '<li><a class="dd-pop-row" href="' + escapeHTML(p.url) + '">' + 139 '<span class="dd-pop-ico">' + TYPE_ICON[p.type] + "</span>" + 140 '<span class="pop-title">' + escapeHTML(p.title) + "</span>" + 141 '<span class="pop-sec">' + escapeHTML(p.section) + "</span>" + 142 "</a></li>"; 143 }).join(""); 144 145 return '<div class="dd"><div class="dd-idle">' + 146 '<div class="dd-section-label">Recent</div>' + 147 '<div class="dd-chips">' + chips + "</div>" + 148 '<div class="dd-section-label">Popular pages</div>' + 149 '<ul class="dd-pop">' + pop + "</ul>" + 150 "</div></div>"; 151 } 152 153 function tplLoading() { 154 var row = 155 '<div class="sk-row"><div class="sk-chip"></div><div class="sk-body">' + 156 '<div class="sk-line sk-crumb"></div>' + 157 '<div class="sk-line sk-title"></div>' + 158 '<div class="sk-line sk-text"></div>' + 159 '<div class="sk-line sk-text short"></div>' + 160 "</div></div>"; 161 return '<div class="dd"><div class="dd-loading">' + row + row + row + "</div>" + tplFooter() + "</div>"; 162 } 163 164 function tplNoResults(query) { 165 return '<div class="dd"><div class="dd-none">
165' + 166 '<svg viewBox="0 0 24 24" width="34" height="34" aria-hidden="true">' + 167 '<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="1.8"/>' + 168 '<path d="M21 21l-4.3-4.3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>' + 169 '<div class="dd-none-title">No results for “' + escapeHTML(query) + '”</div>' + 170 '<div class="dd-none-sub">Try a broader term, or browse the docs.</div>' + 171 '<div class="dd-chips center">' + 172 '<a class="dd-chip" href="/docs/">Browse Documentation</a>' + 173 '<a class="dd-chip" href="https://discord.gg/G2DV8udCeB" target="_blank" rel="noopener">Ask on Discord</a>' + 174 "</div>" + 175 "</div>" + tplFooter() + "</div>"; 176 } 177 178 function tplResults(query, items, active) { 179 var count = '<div class="dd-count">' + items.length + ' results for <b>“' + escapeHTML(query) + '”</b></div>'; 180 var rows = items.map(function (r, i) { 181 var meta = deriveCrumb(r.url); 182 // Section hits append the parent page title to the breadcrumb so the 183 // user can tell which page they're landing in â e.g. "Docs ⺠6.4 ⺠Table Layout". 184 var crumb = meta.crumb.slice(); 185 if (r.isSection && r.pageTitle && r.pageTitle !== r.title) crumb.push(r.pageTitle); 186 return '<li><a class="ddC-row' + (r.isSection ? " sub" : "") + (i === active ? " active" : "") + '"' + 187 ' href="' + escapeHTML(r.url) + '" data-i="' + i + '" role="option" aria-selected="' + (i === active) + '">' + 188 '<span class="ddC-chip">' + TYPE_ICON[meta.type] + "</span>" + 189 '<span class="ddC-body">' + 190 '<span class="ddC-crumb">' + crumb.map(escapeHTML).join(" ⺠") + "</span>" + 191 '<span class="ddC-title">' + escapeHTML(r.title) + "</span>" + 192 '<span class="ddC-excerpt">' + r.excerpt + "</span>" + 193 "</span>" +
194 '<svg class="ddC-arrow" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">' + 195 '<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>' + 196 "</a></li>"; 197 }).join(""); 198 199 return '<div class="dd" role="listbox" aria-label="Search results">' + 200 count + '<ul class="ddC-list">' + rows + "</ul>" + tplFooter() + 201 "</div>"; 202 } 203 204 function tplFooter() { 205 return '<div class="dd-foot">' + 206 '<span class="kbd-row"><kbd>â</kbd><kbd>â</kbd> navigate</span>' + 207 '<span class="kbd-row"><kbd>âµ</kbd> open</span>' + 208 '<span class="kbd-row"><kbd>esc</kbd> close</span>' + 209 '<span class="dd-foot-brand">Search by <b>Pagefind</b></span>' + 210 "</div>"; 211 } 212 213 // ---- per-instance controller ---------------------------------------------- 214 215 function initSearch(host) { 216 var shell = host.querySelector("[data-shell]"); 217 var input = host.querySelector(".s-input"); 218 var kbd = host.querySelector("[data-kbd]"); 219 var clear = host.querySelector("[data-clear]"); 220 var anchor = host.querySelector("[data-anchor]"); 221 222 if (kbd) kbd.textContent = KBD_LABEL; 223 224 var state = "closed"; // closed | idle | loading | results | none 225 var query = ""; 226 var results = []; 227 var active = 0; 228 var searchToken = 0; // ignore late responses 229 var debounceTimer = null; 230 231 function showInputDecor(typing) { 232 if (typing) { kbd.hidden = true; clear.hidden = false; } 233 else { kbd.hidden = false; clear.hidden = true; } 234 } 235 236 function render() { 237 if (state === "closed") { anchor.hidden = true; anchor.innerHTML = ""; return; } 238 anchor.hidden = false; 239 if (state === "idle") anchor.innerHTML = tplIdle(); 240 else if (state === "loading") anchor.innerHTML = tplLoading(); 241 else if (state === "none") anchor.innerHTML = tplNoResults(query); 242 else if (state === "results") anchor.innerHTML = tplResults(query, results, active); 243 244 // re-bind hover/click handlers for rendered rows + recent chips 245 Array.prototype.forEach.call(anchor.querySelectorAll(".ddC-row"), function (el) { 246 el.addEventListener("mouseenter", function () { 247 var i = +el.getAttribute("data-i"); 248 if (i === active) return; 249 active = i; updateActive(); 250 }); 251 el.addEventListener("click", function () { 252 pushRecent(query); 253 }); 254 }); 255 Array.prototype.forEach.call(anchor.querySelectorAll("[data-recent]"), function (el) { 256 el.addEventListener("click", function (e) { 257 e.preventDefault(); 258 input.value = el.getAttribute("data-recent"); 259 input.focus(); 260 onType(); 261 }); 262 }); 263 } 264 265 // Cheap re-paint of just the active row (avoids re-rendering the whole list 266 // while the user is arrow-keying). 267 function updateActive() { 268 var rows = anchor.querySelectorAll(".ddC-row"); 269 for (var i = 0; i < rows.length; i++) { 270 var on = (+rows[i].getAttribute("data-i") === active); 271 rows[i].classList.toggle("active", on); 272 rows[i].setAttribute("aria-selected", on ? "true" : "false"); 273 if (on) rows[i].scrollIntoView({ block: "nearest" }); 274 } 275 } 276 277 function open(initial) { 278 shell.classList.add("focus"); 279 if (state !== "closed") return; 280 if (initial) { state = initial; render(); return; } 281 // Re-opening a box that already has text: if we still have cached 282 // results from the previous search, show them; otherwise re-run the 283 // search. Without this we'd flip to "loading" and never kick a search 284 // off, so the spinner would hang. 285 if (query && results.length) { state = "results"; render(); return; } 286 if (query) { runSearch(query); return; } 287 state = "idle"; render(); 288 } 289 function close() { 290 shell.classList.remove("focus"); 291 state = "closed"; 292 render(); 293 } 294 295 function runSearch(q) { 296 var token = ++searchToken; 297 state = "loading"; 298 render(); 299 var ver = currentVersion(); 300 var opts = ver ? { filters: { version: ver } } : {}; 301 loadPagefind().then(function (pf) { 302 return pf.debouncedSearch ? pf.debouncedSearch(q, opts, 100) : pf.search(q, opts); 303 }).then(function (raw) { 304 if (token !== searchToken) return; // a newer query already in flight 305 if (!raw || !raw.results) { state = "none"; results = []; render(); return; } 306 return Promise.all(raw.results.slice(0, 5).map(function (r) { return r.data(); })) 307 .then(function (data) { 308 if (token !== searchToken) return; 309 // Pagefind segments each page by headings and returns ranked 310 // sub_results â surface those as their own rows so a search for 311 // a section heading ("Redrawing the table") lands on the anch
311or 312 // (`/docs/6.4/layout/#redraw`) instead of the page top. First 313 // sub_result is conventionally the page itself; the rest are 314 // heading-scoped. Cap at 2 sub-sections per page so one big 315 // page can't crowd the dropdown. 316 var rows = []; 317 data.forEach(function (d) { 318 var pageTitle = (d.meta && d.meta.title) || titleFromURL(d.url); 319 var pageUrl = d.url || d.raw_url || "#"; 320 var subs = Array.isArray(d.sub_results) ? d.sub_results : []; 321 if (subs.length) { 322 subs.slice(0, 3).forEach(function (sr, i) { 323 rows.push({ 324 url: sr.url || pageUrl, 325 title: sr.title || pageTitle, 326 excerpt: sr.excerpt || d.excerpt || "", 327 pageTitle: pageTitle, 328 isSection: i > 0, // 0 is the page row 329 }); 330 }); 331 } else { 332 rows.push({ 333 url: pageUrl, title: pageTitle, excerpt: d.excerpt || "", 334 pageTitle: pageTitle, isSection: false, 335 }); 336 } 337 }); 338 results = rows.slice(0, 8); 339 active = 0; 340 state = results.length ? "results" : "none"; 341 render(); 342 }); 343 }).catch(function () { 344 if (token !== searchToken) return; 345 state = "none"; results = []; render(); 346 }); 347 } 348 349 function onType() { 350 query = input.value.trim(); 351 showInputDecor(query.length > 0); 352 if (!query) { 353 clearTimeout(debounceTimer); 354 searchToken++; // cancel any in-flight search 355 state = "idle"; results = []; active = 0; render(); 356 return; 357 } 358 clearTimeout(debounceTimer); 359 debounceTimer = setTimeout(function () { runSearch(query); }, 140); 360 } 361 362 // ---- events ---- 363 input.addEventListener("focus", function () { open(); }); 364 input.addEventListener("input", onType); 365 366 clear.addEventListener("click", function () { 367 input.value = ""; onType(); input.focus(); 368 }); 369 370 document.addEventListener("click", function (e) { 371 if (!host.contains(e.target)) close(); 372 }); 373 374 input.addEventListener("keydown", function (e) { 375 if (e.key === "Escape") { close(); input.blur(); return; } 376 if (state !== "results" || !results.length) return; 377 if (e.key === "ArrowDown") { e.preventDefault(); active = (active + 1) % results.length; updateActive(); } 378 else if (e.key === "ArrowUp") { e.preventDefault(); active = (active - 1 + results.length) % results.length; updateActive(); } 379 else if (e.key === "Enter") { 380 e.preventDefault(); 381 var r = results[active]; if (!r) return; 382 pushRecent(query); 383 location.href = r.url; 384 } 385 }); 386 } 387 388 // ---- bootstrap ------------------------------------------------------------- 389 390 function boot() { 391 var hosts = document.querySelectorAll("[data-search]"); 392 if (!hosts.length) return; 393 Array.prototype.forEach.call(hosts, initSearch); 394 395 // Global âK / Ctrl+K â focus the visible search input. 396 document.addEventListener("keydown", function (e) { 397 if ((e.metaKey || e.ctrlKey) && e.key && e.key.toLowerCase() === "k") { 398 e.preventDefault(); 399 var visible = null; 400 Array.prototype.forEach.call(document.querySelectorAll(".t-search-wrap .s-input"), function (el) { 401 if (!visible && el.offsetParent) visible = el; 402 }); 403 (visible || hosts[0].querySelector(".s-input")).focus(); 404 } 405 }); 406 } 407 408 if (document.readyState === "loading") { 409 document.addEventListener("DOMContentLoaded", boot); 410 } else { 411 boot(); 412 } 413})();
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.