search-widget.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import fuzzy, { FilterResult } from 'fuzzy';
  2. type Section = 'guides' | 'config' | 'gql';
  3. interface IndexItem {
  4. section: Section;
  5. title: string;
  6. parent: string;
  7. headings: string[];
  8. url: string;
  9. }
  10. interface DenormalizedItem {
  11. section: Section;
  12. title: string;
  13. parent: string;
  14. heading: string;
  15. url: string;
  16. }
  17. const KeyCode = {
  18. UP: 38,
  19. DOWN: 40,
  20. ENTER: 13,
  21. ESCAPE: 27,
  22. };
  23. /**
  24. * This class implements the auto-suggest search box for searching the docs.
  25. */
  26. export class SearchWidget {
  27. private readonly MAX_RESULTS = 7;
  28. private searchIndex: Promise<DenormalizedItem[]> | undefined;
  29. private results: Array<FilterResult<DenormalizedItem>> = [];
  30. private selectedIndex = -1;
  31. private autocompleteDiv: HTMLDivElement;
  32. private wrapperDiv: HTMLDivElement;
  33. private listElement: HTMLUListElement;
  34. constructor(private inputElement: HTMLInputElement) {
  35. this.attachAutocomplete();
  36. inputElement.addEventListener('input', (e) => {
  37. this.handleInput(e as KeyboardEvent);
  38. });
  39. inputElement.addEventListener('keydown', (e: KeyboardEvent) => {
  40. switch (e.keyCode) {
  41. case KeyCode.UP:
  42. this.selectedIndex = this.selectedIndex === 0 ? this.results.length - 1 : this.selectedIndex - 1;
  43. e.preventDefault();
  44. break;
  45. case KeyCode.DOWN:
  46. this.selectedIndex = this.selectedIndex === (this.results.length - 1) ? 0 : this.selectedIndex + 1;
  47. e.preventDefault();
  48. break;
  49. case KeyCode.ENTER:
  50. const selected = this.autocompleteDiv.querySelector('li.selected a') as HTMLAnchorElement;
  51. if (selected) {
  52. selected.click();
  53. this.results = [];
  54. }
  55. break;
  56. case KeyCode.ESCAPE:
  57. this.results = [];
  58. this.inputElement.blur();
  59. break;
  60. }
  61. this.render();
  62. });
  63. this.wrapperDiv.addEventListener('click', () => {
  64. this.results = [];
  65. this.render();
  66. });
  67. }
  68. toggleActive() {
  69. this.wrapperDiv.classList.toggle('focus');
  70. if (this.wrapperDiv.classList.contains('focus')) {
  71. this.inputElement.focus();
  72. }
  73. }
  74. /**
  75. * Groups the results by section and renders as a list
  76. */
  77. private render() {
  78. const sections: Section[] = ['guides', 'gql', 'config'];
  79. let html = '';
  80. let i = 0;
  81. for (const sec of sections) {
  82. const matches = this.results.filter(r => r.original.section === sec);
  83. if (matches.length) {
  84. const sectionName = sec === 'guides' ? 'Guides' : sec === 'gql' ? 'GraphQL API' : 'Configuration';
  85. html += `<li class="section">${sectionName}</li>`;
  86. }
  87. html += matches.map((result) => {
  88. const { section, title, parent, heading, url } = result.original;
  89. const anchor = heading !== title ? '#' + this.headingToAnchor(heading) : '';
  90. const inner = `<div class="title"><span class="parent">${parent}</span> › ${title}</div><div class="heading">${result.string}</div>`;
  91. const selected = i === this.selectedIndex ? 'selected' : '';
  92. i++;
  93. return `<li class="${selected}"><a href="${url + anchor}">${inner}</a></li>`;
  94. }).join('\n');
  95. }
  96. this.listElement.innerHTML = html;
  97. }
  98. private attachAutocomplete() {
  99. this.autocompleteDiv = document.createElement('div');
  100. this.autocompleteDiv.classList.add('autocomplete');
  101. this.listElement = document.createElement('ul');
  102. this.autocompleteDiv.appendChild(this.listElement);
  103. this.wrapperDiv = document.createElement('div');
  104. this.wrapperDiv.classList.add('autocomplete-wrapper');
  105. const parent = this.inputElement.parentElement;
  106. if (parent) {
  107. parent.insertBefore(this.wrapperDiv, this.inputElement);
  108. this.wrapperDiv.appendChild(this.inputElement);
  109. this.wrapperDiv.appendChild(this.autocompleteDiv);
  110. }
  111. }
  112. private async handleInput(e: KeyboardEvent) {
  113. const term = (e.target as HTMLInputElement).value.trim();
  114. this.results = term ? await this.getResults(term) : [];
  115. this.selectedIndex = 0;
  116. this.render();
  117. }
  118. private async getResults(term: string) {
  119. const items = await this.getSearchIndex();
  120. const results = fuzzy.filter(
  121. term,
  122. items,
  123. {
  124. pre: '<span class="hl">',
  125. post: '</span>',
  126. extract(input: DenormalizedItem): string {
  127. return input.heading;
  128. },
  129. },
  130. );
  131. if (this.MAX_RESULTS < results.length) {
  132. // limit the maximum number of results from a particular
  133. // section to prevent other possibly relevant results getting
  134. // buried.
  135. const guides = results.filter(r => r.original.section === 'guides');
  136. const gql = results.filter(r => r.original.section === 'gql');
  137. const config = results.filter(r => r.original.section === 'config');
  138. let pool = [guides, gql, config].filter(p => p.length);
  139. const balancedResults = [];
  140. for (let i = 0; i < this.MAX_RESULTS; i ++) {
  141. const next = pool[i % pool.length].shift();
  142. if (next) {
  143. balancedResults.push(next);
  144. }
  145. pool = [guides, gql, config].filter(p => p.length);
  146. }
  147. return balancedResults;
  148. }
  149. return results;
  150. }
  151. private getSearchIndex(): Promise<DenormalizedItem[]> {
  152. if (!this.searchIndex) {
  153. // tslint:disable:no-eval
  154. this.searchIndex = fetch('/searchindex/index.html')
  155. .then(res => res.text())
  156. .then(res => eval(res))
  157. .then((items: IndexItem[]) => {
  158. const denormalized: DenormalizedItem[] = [];
  159. for (const { section, title, parent, headings, url } of items) {
  160. denormalized.push({
  161. section,
  162. title,
  163. parent,
  164. heading: title,
  165. url,
  166. });
  167. if (headings.length) {
  168. for (const heading of headings) {
  169. denormalized.push({
  170. section,
  171. title,
  172. parent,
  173. heading,
  174. url,
  175. });
  176. }
  177. }
  178. }
  179. return denormalized;
  180. });
  181. }
  182. return this.searchIndex;
  183. }
  184. private headingToAnchor(heading: string): string {
  185. return heading.toLowerCase()
  186. .replace(/\s/g, '-')
  187. .replace(/[:]/g, '');
  188. }
  189. }