DocSearch.js 11 KB

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