search-widget.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. import fuzzy, { FilterResult } from 'fuzzy';
  2. interface IndexItem {
  3. title: string;
  4. headings: string[];
  5. url: string;
  6. }
  7. interface DenormalizedItem {
  8. title: string;
  9. heading: string;
  10. url: string;
  11. }
  12. const KeyCode = {
  13. UP: 38,
  14. DOWN: 40,
  15. ENTER: 13,
  16. ESCAPE: 27,
  17. };
  18. /**
  19. * This class implements the auto-suggest search box for searching the docs.
  20. */
  21. export class SearchWidget {
  22. private readonly MAX_RESULTS = 8;
  23. private searchIndex: Promise<DenormalizedItem[]> | undefined;
  24. private results: Array<FilterResult<DenormalizedItem>> = [];
  25. private selectedIndex = -1;
  26. private autocompleteDiv: HTMLDivElement;
  27. private wrapperDiv: HTMLDivElement;
  28. private listElement: HTMLUListElement;
  29. constructor(private inputElement: HTMLInputElement) {
  30. this.attachAutocomplete();
  31. inputElement.addEventListener('input', (e) => {
  32. this.handleInput(e as KeyboardEvent);
  33. });
  34. inputElement.addEventListener('keydown', (e: KeyboardEvent) => {
  35. switch (e.keyCode) {
  36. case KeyCode.UP:
  37. this.selectedIndex = this.selectedIndex === 0 ? this.results.length - 1 : this.selectedIndex - 1;
  38. e.preventDefault();
  39. break;
  40. case KeyCode.DOWN:
  41. this.selectedIndex = this.selectedIndex === (this.results.length - 1) ? 0 : this.selectedIndex + 1;
  42. e.preventDefault();
  43. break;
  44. case KeyCode.ENTER:
  45. const selected = this.autocompleteDiv.querySelector('li.selected a') as HTMLAnchorElement;
  46. if (selected) {
  47. selected.click();
  48. return;
  49. }
  50. break;
  51. case KeyCode.ESCAPE:
  52. this.results = [];
  53. this.inputElement.blur();
  54. break;
  55. }
  56. this.render();
  57. });
  58. }
  59. toggleActive() {
  60. this.wrapperDiv.classList.toggle('focus');
  61. if (this.wrapperDiv.classList.contains('focus')) {
  62. this.inputElement.focus();
  63. }
  64. }
  65. private render() {
  66. const listItems = this.results
  67. .map((result, i) => {
  68. const { title, heading, url } = result.original;
  69. const anchor = heading !== title ? '#' + heading.toLowerCase().replace(/\s/g, '-') : '';
  70. const inner = `<div class="title">${title}</div><div class="heading">${result.string}</div>`;
  71. return `<li class="${i === this.selectedIndex ? 'selected' : ''}"><a href="${url + anchor}">${inner}</a></li>`;
  72. });
  73. this.listElement.innerHTML = listItems.join('\n');
  74. }
  75. private attachAutocomplete() {
  76. this.autocompleteDiv = document.createElement('div');
  77. this.autocompleteDiv.classList.add('autocomplete');
  78. this.listElement = document.createElement('ul');
  79. this.autocompleteDiv.appendChild(this.listElement);
  80. this.wrapperDiv = document.createElement('div');
  81. this.wrapperDiv.classList.add('autocomplete-wrapper');
  82. const parent = this.inputElement.parentElement;
  83. if (parent) {
  84. parent.insertBefore(this.wrapperDiv, this.inputElement);
  85. this.wrapperDiv.appendChild(this.inputElement);
  86. this.wrapperDiv.appendChild(this.autocompleteDiv);
  87. }
  88. }
  89. private async handleInput(e: KeyboardEvent) {
  90. const term = (e.target as HTMLInputElement).value.trim();
  91. this.results = term ? await this.getResults(term) : [];
  92. this.selectedIndex = 0;
  93. this.render();
  94. }
  95. private async getResults(term: string) {
  96. const items = await this.getSearchIndex();
  97. return fuzzy.filter(
  98. term,
  99. items,
  100. {
  101. pre: '<span class="hl">',
  102. post: '</span>',
  103. extract(input: DenormalizedItem): string {
  104. return input.heading;
  105. },
  106. },
  107. ).slice(0, this.MAX_RESULTS);
  108. }
  109. private getSearchIndex(): Promise<DenormalizedItem[]> {
  110. if (!this.searchIndex) {
  111. // tslint:disable:no-eval
  112. this.searchIndex = fetch('/searchindex/index.html')
  113. .then(res => res.text())
  114. .then(res => eval(res))
  115. .then((items: IndexItem[]) => {
  116. const denormalized: DenormalizedItem[] = [];
  117. for (const { title, headings, url } of items) {
  118. denormalized.push({
  119. title,
  120. heading: title,
  121. url,
  122. });
  123. if (headings.length) {
  124. for (const heading of headings) {
  125. denormalized.push({
  126. title,
  127. heading,
  128. url,
  129. });
  130. }
  131. }
  132. }
  133. return denormalized;
  134. });
  135. }
  136. return this.searchIndex;
  137. }
  138. }