PageSourceSearch

https://www.tabulator.info/js/search.js

js tabulator.info collected 2026-09-25 14:26:16 UTC 18,331 bytes, 413 lines download raw bytes

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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 &ldquo;' + escapeHTML(query) + '&rdquo;</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>&ldquo;' + escapeHTML(query) + '&rdquo;</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.