search-widget.ts 6.9 KB

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