import fuzzy, { FilterResult } from 'fuzzy'; type Section = 'guides' | 'config' | 'gql'; interface IndexItem { section: Section; title: string; parent: string; headings: string[]; url: string; } interface DenormalizedItem { section: Section; title: string; parent: string; heading: string; url: string; } const KeyCode = { UP: 38, DOWN: 40, ENTER: 13, ESCAPE: 27, }; /** * This class implements the auto-suggest search box for searching the docs. */ export class SearchWidget { private readonly MAX_RESULTS = 7; private searchIndex: Promise | undefined; private results: Array> = []; private selectedIndex = -1; private autocompleteDiv: HTMLDivElement; private wrapperDiv: HTMLDivElement; private listElement: HTMLUListElement; constructor(private inputElement: HTMLInputElement) { this.attachAutocomplete(); inputElement.addEventListener('input', (e) => { this.handleInput(e as KeyboardEvent); }); inputElement.addEventListener('keydown', (e: KeyboardEvent) => { switch (e.keyCode) { case KeyCode.UP: this.selectedIndex = this.selectedIndex === 0 ? this.results.length - 1 : this.selectedIndex - 1; e.preventDefault(); break; case KeyCode.DOWN: this.selectedIndex = this.selectedIndex === (this.results.length - 1) ? 0 : this.selectedIndex + 1; e.preventDefault(); break; case KeyCode.ENTER: const selected = this.autocompleteDiv.querySelector('li.selected a') as HTMLAnchorElement; if (selected) { selected.click(); this.results = []; } break; case KeyCode.ESCAPE: this.results = []; this.inputElement.blur(); break; } this.render(); }); this.wrapperDiv.addEventListener('click', () => { this.results = []; this.render(); }); } toggleActive() { this.wrapperDiv.classList.toggle('focus'); if (this.wrapperDiv.classList.contains('focus')) { this.inputElement.focus(); } } /** * Groups the results by section and renders as a list */ private render() { const sections: Section[] = ['guides', 'gql', 'config']; let html = ''; let i = 0; for (const sec of sections) { const matches = this.results.filter(r => r.original.section === sec); if (matches.length) { const sectionName = sec === 'guides' ? 'Guides' : sec === 'gql' ? 'GraphQL API' : 'Configuration'; html += `
  • ${sectionName}
  • `; } html += matches.map((result) => { const { section, title, parent, heading, url } = result.original; const anchor = heading !== title ? '#' + this.headingToAnchor(heading) : ''; const inner = `
    ${parent} › ${title}
    ${result.string}
    `; const selected = i === this.selectedIndex ? 'selected' : ''; i++; return `
  • ${inner}
  • `; }).join('\n'); } this.listElement.innerHTML = html; } private attachAutocomplete() { this.autocompleteDiv = document.createElement('div'); this.autocompleteDiv.classList.add('autocomplete'); this.listElement = document.createElement('ul'); this.autocompleteDiv.appendChild(this.listElement); this.wrapperDiv = document.createElement('div'); this.wrapperDiv.classList.add('autocomplete-wrapper'); const parent = this.inputElement.parentElement; if (parent) { parent.insertBefore(this.wrapperDiv, this.inputElement); this.wrapperDiv.appendChild(this.inputElement); this.wrapperDiv.appendChild(this.autocompleteDiv); } } private async handleInput(e: KeyboardEvent) { const term = (e.target as HTMLInputElement).value.trim(); this.results = term ? await this.getResults(term) : []; this.selectedIndex = 0; this.render(); } private async getResults(term: string) { const items = await this.getSearchIndex(); const results = fuzzy.filter( term, items, { pre: '', post: '', extract(input: DenormalizedItem): string { return input.heading; }, }, ); if (this.MAX_RESULTS < results.length) { // limit the maximum number of results from a particular // section to prevent other possibly relevant results getting // buried. const guides = results.filter(r => r.original.section === 'guides'); const gql = results.filter(r => r.original.section === 'gql'); const config = results.filter(r => r.original.section === 'config'); let pool = [guides, gql, config].filter(p => p.length); const balancedResults = []; for (let i = 0; i < this.MAX_RESULTS; i ++) { const next = pool[i % pool.length].shift(); if (next) { balancedResults.push(next); } pool = [guides, gql, config].filter(p => p.length); } return balancedResults; } return results; } private getSearchIndex(): Promise { if (!this.searchIndex) { // tslint:disable:no-eval this.searchIndex = fetch('/searchindex/index.html') .then(res => res.text()) .then(res => eval(res)) .then((items: IndexItem[]) => { const denormalized: DenormalizedItem[] = []; for (const { section, title, parent, headings, url } of items) { denormalized.push({ section, title, parent, heading: title, url, }); if (headings.length) { for (const heading of headings) { denormalized.push({ section, title, parent, heading, url, }); } } } return denormalized; }); } return this.searchIndex; } private headingToAnchor(heading: string): string { return heading.toLowerCase() .replace(/\s/g, '-') .replace(/[:]/g, ''); } }