form-mini.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. /**
  2. * Copyright © Magento, Inc. All rights reserved.
  3. * See COPYING.txt for license details.
  4. */
  5. /**
  6. * @api
  7. */
  8. define([
  9. 'jquery',
  10. 'underscore',
  11. 'mage/template',
  12. 'matchMedia',
  13. 'jquery/ui',
  14. 'mage/translate'
  15. ], function ($, _, mageTemplate, mediaCheck) {
  16. 'use strict';
  17. /**
  18. * Check whether the incoming string is not empty or if doesn't consist of spaces.
  19. *
  20. * @param {String} value - Value to check.
  21. * @returns {Boolean}
  22. */
  23. function isEmpty(value) {
  24. return value.length === 0 || value == null || /^\s+$/.test(value);
  25. }
  26. $.widget('mage.quickSearch', {
  27. options: {
  28. autocomplete: 'off',
  29. minSearchLength: 2,
  30. responseFieldElements: 'ul li',
  31. selectClass: 'selected',
  32. template:
  33. '<li class="<%- data.row_class %>" id="qs-option-<%- data.index %>" role="option">' +
  34. '<span class="qs-option-name">' +
  35. ' <%- data.title %>' +
  36. '</span>' +
  37. '<span aria-hidden="true" class="amount">' +
  38. '<%- data.num_results %>' +
  39. '</span>' +
  40. '</li>',
  41. submitBtn: 'button[type="submit"]',
  42. searchLabel: '[data-role=minisearch-label]',
  43. isExpandable: null,
  44. suggestionDelay: 300
  45. },
  46. /** @inheritdoc */
  47. _create: function () {
  48. this.responseList = {
  49. indexList: null,
  50. selected: null
  51. };
  52. this.autoComplete = $(this.options.destinationSelector);
  53. this.searchForm = $(this.options.formSelector);
  54. this.submitBtn = this.searchForm.find(this.options.submitBtn)[0];
  55. this.searchLabel = this.searchForm.find(this.options.searchLabel);
  56. this.isExpandable = this.options.isExpandable;
  57. _.bindAll(this, '_onKeyDown', '_onPropertyChange', '_onSubmit');
  58. this.submitBtn.disabled = true;
  59. this.element.attr('autocomplete', this.options.autocomplete);
  60. mediaCheck({
  61. media: '(max-width: 768px)',
  62. entry: function () {
  63. this.isExpandable = true;
  64. }.bind(this),
  65. exit: function () {
  66. this.isExpandable = false;
  67. this.element.removeAttr('aria-expanded');
  68. }.bind(this)
  69. });
  70. this.searchLabel.on('click', function (e) {
  71. // allow input to lose its' focus when clicking on label
  72. if (this.isExpandable && this.isActive()) {
  73. e.preventDefault();
  74. }
  75. }.bind(this));
  76. this.element.on('blur', $.proxy(function () {
  77. if (!this.searchLabel.hasClass('active')) {
  78. return;
  79. }
  80. setTimeout($.proxy(function () {
  81. if (this.autoComplete.is(':hidden')) {
  82. this.setActiveState(false);
  83. } else {
  84. this.element.trigger('focus');
  85. }
  86. this.autoComplete.hide();
  87. this._updateAriaHasPopup(false);
  88. }, this), 250);
  89. }, this));
  90. if (this.element.get(0) === document.activeElement) {
  91. this.setActiveState(true);
  92. }
  93. this.element.on('focus', this.setActiveState.bind(this, true));
  94. this.element.on('keydown', this._onKeyDown);
  95. // Prevent spamming the server with requests by waiting till the user has stopped typing for period of time
  96. this.element.on('input propertychange', _.debounce(this._onPropertyChange, this.options.suggestionDelay));
  97. this.searchForm.on('submit', $.proxy(function (e) {
  98. this._onSubmit(e);
  99. this._updateAriaHasPopup(false);
  100. }, this));
  101. },
  102. /**
  103. * Checks if search field is active.
  104. *
  105. * @returns {Boolean}
  106. */
  107. isActive: function () {
  108. return this.searchLabel.hasClass('active');
  109. },
  110. /**
  111. * Sets state of the search field to provided value.
  112. *
  113. * @param {Boolean} isActive
  114. */
  115. setActiveState: function (isActive) {
  116. this.searchForm.toggleClass('active', isActive);
  117. this.searchLabel.toggleClass('active', isActive);
  118. if (this.isExpandable) {
  119. this.element.attr('aria-expanded', isActive);
  120. }
  121. },
  122. /**
  123. * @private
  124. * @return {Element} The first element in the suggestion list.
  125. */
  126. _getFirstVisibleElement: function () {
  127. return this.responseList.indexList ? this.responseList.indexList.first() : false;
  128. },
  129. /**
  130. * @private
  131. * @return {Element} The last element in the suggestion list.
  132. */
  133. _getLastElement: function () {
  134. return this.responseList.indexList ? this.responseList.indexList.last() : false;
  135. },
  136. /**
  137. * @private
  138. * @param {Boolean} show - Set attribute aria-haspopup to "true/false" for element.
  139. */
  140. _updateAriaHasPopup: function (show) {
  141. if (show) {
  142. this.element.attr('aria-haspopup', 'true');
  143. } else {
  144. this.element.attr('aria-haspopup', 'false');
  145. }
  146. },
  147. /**
  148. * Clears the item selected from the suggestion list and resets the suggestion list.
  149. * @private
  150. * @param {Boolean} all - Controls whether to clear the suggestion list.
  151. */
  152. _resetResponseList: function (all) {
  153. this.responseList.selected = null;
  154. if (all === true) {
  155. this.responseList.indexList = null;
  156. }
  157. },
  158. /**
  159. * Executes when the search box is submitted. Sets the search input field to the
  160. * value of the selected item.
  161. * @private
  162. * @param {Event} e - The submit event
  163. */
  164. _onSubmit: function (e) {
  165. var value = this.element.val();
  166. if (isEmpty(value)) {
  167. e.preventDefault();
  168. }
  169. if (this.responseList.selected) {
  170. this.element.val(this.responseList.selected.find('.qs-option-name').text());
  171. }
  172. },
  173. /**
  174. * Executes when keys are pressed in the search input field. Performs specific actions
  175. * depending on which keys are pressed.
  176. * @private
  177. * @param {Event} e - The key down event
  178. * @return {Boolean} Default return type for any unhandled keys
  179. */
  180. _onKeyDown: function (e) {
  181. var keyCode = e.keyCode || e.which;
  182. switch (keyCode) {
  183. case $.ui.keyCode.HOME:
  184. if (this._getFirstVisibleElement()) {
  185. this._getFirstVisibleElement().addClass(this.options.selectClass);
  186. this.responseList.selected = this._getFirstVisibleElement();
  187. }
  188. break;
  189. case $.ui.keyCode.END:
  190. if (this._getLastElement()) {
  191. this._getLastElement().addClass(this.options.selectClass);
  192. this.responseList.selected = this._getLastElement();
  193. }
  194. break;
  195. case $.ui.keyCode.ESCAPE:
  196. this._resetResponseList(true);
  197. this.autoComplete.hide();
  198. break;
  199. case $.ui.keyCode.ENTER:
  200. this.searchForm.trigger('submit');
  201. e.preventDefault();
  202. break;
  203. case $.ui.keyCode.DOWN:
  204. if (this.responseList.indexList) {
  205. if (!this.responseList.selected) { //eslint-disable-line max-depth
  206. this._getFirstVisibleElement().addClass(this.options.selectClass);
  207. this.responseList.selected = this._getFirstVisibleElement();
  208. } else if (!this._getLastElement().hasClass(this.options.selectClass)) {
  209. this.responseList.selected = this.responseList.selected
  210. .removeClass(this.options.selectClass).next().addClass(this.options.selectClass);
  211. } else {
  212. this.responseList.selected.removeClass(this.options.selectClass);
  213. this._getFirstVisibleElement().addClass(this.options.selectClass);
  214. this.responseList.selected = this._getFirstVisibleElement();
  215. }
  216. this.element.val(this.responseList.selected.find('.qs-option-name').text());
  217. this.element.attr('aria-activedescendant', this.responseList.selected.attr('id'));
  218. }
  219. break;
  220. case $.ui.keyCode.UP:
  221. if (this.responseList.indexList !== null) {
  222. if (!this._getFirstVisibleElement().hasClass(this.options.selectClass)) {
  223. this.responseList.selected = this.responseList.selected
  224. .removeClass(this.options.selectClass).prev().addClass(this.options.selectClass);
  225. } else {
  226. this.responseList.selected.removeClass(this.options.selectClass);
  227. this._getLastElement().addClass(this.options.selectClass);
  228. this.responseList.selected = this._getLastElement();
  229. }
  230. this.element.val(this.responseList.selected.find('.qs-option-name').text());
  231. this.element.attr('aria-activedescendant', this.responseList.selected.attr('id'));
  232. }
  233. break;
  234. default:
  235. return true;
  236. }
  237. },
  238. /**
  239. * Executes when the value of the search input field changes. Executes a GET request
  240. * to populate a suggestion list based on entered text. Handles click (select), hover,
  241. * and mouseout events on the populated suggestion list dropdown.
  242. * @private
  243. */
  244. _onPropertyChange: function () {
  245. var searchField = this.element,
  246. clonePosition = {
  247. position: 'absolute',
  248. // Removed to fix display issues
  249. // left: searchField.offset().left,
  250. // top: searchField.offset().top + searchField.outerHeight(),
  251. width: searchField.outerWidth()
  252. },
  253. source = this.options.template,
  254. template = mageTemplate(source),
  255. dropdown = $('<ul role="listbox"></ul>'),
  256. value = this.element.val();
  257. this.submitBtn.disabled = isEmpty(value);
  258. if (value.length >= parseInt(this.options.minSearchLength, 10)) {
  259. $.getJSON(this.options.url, {
  260. q: value
  261. }, $.proxy(function (data) {
  262. if (data.length) {
  263. $.each(data, function (index, element) {
  264. var html;
  265. element.index = index;
  266. html = template({
  267. data: element
  268. });
  269. dropdown.append(html);
  270. });
  271. this._resetResponseList(true);
  272. this.responseList.indexList = this.autoComplete.html(dropdown)
  273. .css(clonePosition)
  274. .show()
  275. .find(this.options.responseFieldElements + ':visible');
  276. this.element.removeAttr('aria-activedescendant');
  277. if (this.responseList.indexList.length) {
  278. this._updateAriaHasPopup(true);
  279. } else {
  280. this._updateAriaHasPopup(false);
  281. }
  282. this.responseList.indexList
  283. .on('click', function (e) {
  284. this.responseList.selected = $(e.currentTarget);
  285. this.searchForm.trigger('submit');
  286. }.bind(this))
  287. .on('mouseenter mouseleave', function (e) {
  288. this.responseList.indexList.removeClass(this.options.selectClass);
  289. $(e.target).addClass(this.options.selectClass);
  290. this.responseList.selected = $(e.target);
  291. this.element.attr('aria-activedescendant', $(e.target).attr('id'));
  292. }.bind(this))
  293. .on('mouseout', function (e) {
  294. if (!this._getLastElement() &&
  295. this._getLastElement().hasClass(this.options.selectClass)) {
  296. $(e.target).removeClass(this.options.selectClass);
  297. this._resetResponseList(false);
  298. }
  299. }.bind(this));
  300. } else {
  301. this._resetResponseList(true);
  302. this.autoComplete.hide();
  303. this._updateAriaHasPopup(false);
  304. this.element.removeAttr('aria-activedescendant');
  305. }
  306. }, this));
  307. } else {
  308. this._resetResponseList(true);
  309. this.autoComplete.hide();
  310. this._updateAriaHasPopup(false);
  311. this.element.removeAttr('aria-activedescendant');
  312. }
  313. }
  314. });
  315. return $.mage.quickSearch;
  316. });