DocSearch.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import Hogan from "hogan.js";
  2. import LunrSearchAdapter from "./lunar-search";
  3. import autocomplete from "autocomplete.js";
  4. import templates from "./templates";
  5. import utils from "./utils";
  6. import $ from "autocomplete.js/zepto";
  7. class DocSearch {
  8. constructor({
  9. searchDocs,
  10. searchIndex,
  11. inputSelector,
  12. debug = false,
  13. baseUrl = '/',
  14. queryDataCallback = null,
  15. autocompleteOptions = {
  16. debug: false,
  17. hint: false,
  18. autoselect: true
  19. },
  20. transformData = false,
  21. queryHook = false,
  22. handleSelected = false,
  23. enhancedSearchInput = false,
  24. layout = "collumns"
  25. }) {
  26. this.input = DocSearch.getInputFromSelector(inputSelector);
  27. this.queryDataCallback = queryDataCallback || null;
  28. const autocompleteOptionsDebug =
  29. autocompleteOptions && autocompleteOptions.debug
  30. ? autocompleteOptions.debug
  31. : false;
  32. // eslint-disable-next-line no-param-reassign
  33. autocompleteOptions.debug = debug || autocompleteOptionsDebug;
  34. this.autocompleteOptions = autocompleteOptions;
  35. this.autocompleteOptions.cssClasses =
  36. this.autocompleteOptions.cssClasses || {};
  37. this.autocompleteOptions.cssClasses.prefix =
  38. this.autocompleteOptions.cssClasses.prefix || "ds";
  39. const inputAriaLabel =
  40. this.input &&
  41. typeof this.input.attr === "function" &&
  42. this.input.attr("aria-label");
  43. this.autocompleteOptions.ariaLabel =
  44. this.autocompleteOptions.ariaLabel || inputAriaLabel || "search input";
  45. this.isSimpleLayout = layout === "simple";
  46. this.client = new LunrSearchAdapter(searchDocs, searchIndex, baseUrl);
  47. if (enhancedSearchInput) {
  48. this.input = DocSearch.injectSearchBox(this.input);
  49. }
  50. this.autocomplete = autocomplete(this.input, autocompleteOptions, [
  51. {
  52. source: this.getAutocompleteSource(transformData, queryHook),
  53. templates: {
  54. suggestion: DocSearch.getSuggestionTemplate(this.isSimpleLayout),
  55. footer: templates.footer,
  56. empty: DocSearch.getEmptyTemplate()
  57. }
  58. }
  59. ]);
  60. const customHandleSelected = handleSelected;
  61. this.handleSelected = customHandleSelected || this.handleSelected;
  62. // We prevent default link clicking if a custom handleSelected is defined
  63. if (customHandleSelected) {
  64. $(".algolia-autocomplete").on("click", ".ds-suggestions a", event => {
  65. event.preventDefault();
  66. });
  67. }
  68. this.autocomplete.on(
  69. "autocomplete:selected",
  70. this.handleSelected.bind(null, this.autocomplete.autocomplete)
  71. );
  72. this.autocomplete.on(
  73. "autocomplete:shown",
  74. this.handleShown.bind(null, this.input)
  75. );
  76. if (enhancedSearchInput) {
  77. DocSearch.bindSearchBoxEvent();
  78. }
  79. }
  80. static injectSearchBox(input) {
  81. input.before(templates.searchBox);
  82. const newInput = input
  83. .prev()
  84. .prev()
  85. .find("input");
  86. input.remove();
  87. return newInput;
  88. }
  89. static bindSearchBoxEvent() {
  90. $('.searchbox [type="reset"]').on("click", function () {
  91. $("input#docsearch").focus();
  92. $(this).addClass("hide");
  93. autocomplete.autocomplete.setVal("");
  94. });
  95. $("input#docsearch").on("keyup", () => {
  96. const searchbox = document.querySelector("input#docsearch");
  97. const reset = document.querySelector('.searchbox [type="reset"]');
  98. reset.className = "searchbox__reset";
  99. if (searchbox.value.length === 0) {
  100. reset.className += " hide";
  101. }
  102. });
  103. }
  104. /**
  105. * Returns the matching input from a CSS selector, null if none matches
  106. * @function getInputFromSelector
  107. * @param {string} selector CSS selector that matches the search
  108. * input of the page
  109. * @returns {void}
  110. */
  111. static getInputFromSelector(selector) {
  112. const input = $(selector).filter("input");
  113. return input.length ? $(input[0]) : null;
  114. }
  115. /**
  116. * Returns the `source` method to be passed to autocomplete.js. It will query
  117. * the Algolia index and call the callbacks with the formatted hits.
  118. * @function getAutocompleteSource
  119. * @param {function} transformData An optional function to transform the hits
  120. * @param {function} queryHook An optional function to transform the query
  121. * @returns {function} Method to be passed as the `source` option of
  122. * autocomplete
  123. */
  124. getAutocompleteSource(transformData, queryHook) {
  125. return (query, callback) => {
  126. if (queryHook) {
  127. // eslint-disable-next-line no-param-reassign
  128. query = queryHook(query) || query;
  129. }
  130. this.client.search(query).then(hits => {
  131. if (
  132. this.queryDataCallback &&
  133. typeof this.queryDataCallback == "function"
  134. ) {
  135. this.queryDataCallback(hits);
  136. }
  137. if (transformData) {
  138. hits = transformData(hits) || hits;
  139. }
  140. callback(DocSearch.formatHits(hits));
  141. });
  142. };
  143. }
  144. // Given a list of hits returned by the API, will reformat them to be used in
  145. // a Hogan template
  146. static formatHits(receivedHits) {
  147. const clonedHits = utils.deepClone(receivedHits);
  148. const hits = clonedHits.map(hit => {
  149. if (hit._highlightResult) {
  150. // eslint-disable-next-line no-param-reassign
  151. hit._highlightResult = utils.mergeKeyWithParent(
  152. hit._highlightResult,
  153. "hierarchy"
  154. );
  155. }
  156. return utils.mergeKeyWithParent(hit, "hierarchy");
  157. });
  158. // Group hits by category / subcategory
  159. let groupedHits = utils.groupBy(hits, "lvl0");
  160. $.each(groupedHits, (level, collection) => {
  161. const groupedHitsByLvl1 = utils.groupBy(collection, "lvl1");
  162. const flattenedHits = utils.flattenAndFlagFirst(
  163. groupedHitsByLvl1,
  164. "isSubCategoryHeader"
  165. );
  166. groupedHits[level] = flattenedHits;
  167. });
  168. groupedHits = utils.flattenAndFlagFirst(groupedHits, "isCategoryHeader");
  169. // Translate hits into smaller objects to be send to the template
  170. return groupedHits.map(hit => {
  171. const url = DocSearch.formatURL(hit);
  172. const category = utils.getHighlightedValue(hit, "lvl0");
  173. const subcategory = utils.getHighlightedValue(hit, "lvl1") || category;
  174. const displayTitle = utils
  175. .compact([
  176. utils.getHighlightedValue(hit, "lvl2") || subcategory,
  177. utils.getHighlightedValue(hit, "lvl3"),
  178. utils.getHighlightedValue(hit, "lvl4"),
  179. utils.getHighlightedValue(hit, "lvl5"),
  180. utils.getHighlightedValue(hit, "lvl6")
  181. ])
  182. .join(
  183. '<span class="aa-suggestion-title-separator" aria-hidden="true"> › </span>'
  184. );
  185. const text = utils.getSnippetedValue(hit, "content");
  186. const isTextOrSubcategoryNonEmpty =
  187. (subcategory && subcategory !== "") ||
  188. (displayTitle && displayTitle !== "");
  189. const isLvl1EmptyOrDuplicate =
  190. !subcategory || subcategory === "" || subcategory === category;
  191. const isLvl2 =
  192. displayTitle && displayTitle !== "" && displayTitle !== subcategory;
  193. const isLvl1 =
  194. !isLvl2 &&
  195. (subcategory && subcategory !== "" && subcategory !== category);
  196. const isLvl0 = !isLvl1 && !isLvl2;
  197. return {
  198. isLvl0,
  199. isLvl1,
  200. isLvl2,
  201. isLvl1EmptyOrDuplicate,
  202. isCategoryHeader: hit.isCategoryHeader,
  203. isSubCategoryHeader: hit.isSubCategoryHeader,
  204. isTextOrSubcategoryNonEmpty,
  205. category,
  206. subcategory,
  207. title: displayTitle,
  208. text,
  209. url
  210. };
  211. });
  212. }
  213. static formatURL(hit) {
  214. const { url, anchor } = hit;
  215. if (url) {
  216. const containsAnchor = url.indexOf("#") !== -1;
  217. if (containsAnchor) return url;
  218. else if (anchor) return `${hit.url}#${hit.anchor}`;
  219. return url;
  220. } else if (anchor) return `#${hit.anchor}`;
  221. /* eslint-disable */
  222. console.warn("no anchor nor url for : ", JSON.stringify(hit));
  223. /* eslint-enable */
  224. return null;
  225. }
  226. static getEmptyTemplate() {
  227. return args => Hogan.compile(templates.empty).render(args);
  228. }
  229. static getSuggestionTemplate(isSimpleLayout) {
  230. const stringTemplate = isSimpleLayout
  231. ? templates.suggestionSimple
  232. : templates.suggestion;
  233. const template = Hogan.compile(stringTemplate);
  234. return suggestion => template.render(suggestion);
  235. }
  236. handleSelected(input, event, suggestion, datasetNumber, context = {}) {
  237. // Do nothing if click on the suggestion, as it's already a <a href>, the
  238. // browser will take care of it. This allow Ctrl-Clicking on results and not
  239. // having the main window being redirected as well
  240. if (context.selectionMethod === "click") {
  241. return;
  242. }
  243. input.setVal("");
  244. window.location.assign(suggestion.url);
  245. }
  246. handleShown(input) {
  247. const middleOfInput = input.offset().left + input.width() / 2;
  248. let middleOfWindow = $(document).width() / 2;
  249. if (isNaN(middleOfWindow)) {
  250. middleOfWindow = 900;
  251. }
  252. const alignClass =
  253. middleOfInput - middleOfWindow >= 0
  254. ? "algolia-autocomplete-right"
  255. : "algolia-autocomplete-left";
  256. const otherAlignClass =
  257. middleOfInput - middleOfWindow < 0
  258. ? "algolia-autocomplete-right"
  259. : "algolia-autocomplete-left";
  260. const autocompleteWrapper = $(".algolia-autocomplete");
  261. if (!autocompleteWrapper.hasClass(alignClass)) {
  262. autocompleteWrapper.addClass(alignClass);
  263. }
  264. if (autocompleteWrapper.hasClass(otherAlignClass)) {
  265. autocompleteWrapper.removeClass(otherAlignClass);
  266. }
  267. }
  268. }
  269. export default DocSearch;