You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

658 lines
21 KiB

  1. /* ******************************
  2. Search Prompt
  3. Author: Jack Lukic
  4. Notes: First Commit July 19, 2012
  5. Designed to be used as an autocomplete
  6. or to deliver quick inline search results
  7. ****************************** */
  8. ;(function ($, window, document, undefined) {
  9. $.fn.searchPrompt = function(source, parameters) {
  10. var
  11. settings = $.extend(true, {}, $.fn.searchPrompt.settings, parameters),
  12. // make arguments available
  13. query = arguments[0],
  14. passedArguments = [].slice.call(arguments, 1),
  15. invokedResponse
  16. ;
  17. $(this)
  18. .each(function() {
  19. var
  20. $module = $(this),
  21. $searchPrompt = $module.find(settings.selector.searchPrompt),
  22. $searchButton = $module.find(settings.selector.searchButton),
  23. $searchResults = $module.find(settings.selector.searchResults),
  24. $result = $module.find(settings.selector.result),
  25. $category = $module.find(settings.selector.category),
  26. $emptyResult = $module.find(settings.selector.emptyResult),
  27. $resultPage = $module.find(settings.selector.resultPage),
  28. element = this,
  29. selector = $module.selector || '',
  30. instance = $module.data('module-' + settings.namespace),
  31. methodInvoked = (instance !== undefined && typeof query == 'string'),
  32. className = settings.className,
  33. namespace = settings.namespace,
  34. errors = settings.errors,
  35. module
  36. ;
  37. module = {
  38. initialize: function() {
  39. var
  40. searchPrompt = $searchPrompt[0],
  41. inputEvent = (searchPrompt.oninput !== undefined)
  42. ? 'input'
  43. : (searchPrompt.onpropertychange !== undefined)
  44. ? 'propertychange'
  45. : 'keyup'
  46. ;
  47. // attach events
  48. $searchPrompt
  49. .on('focus.' + namespace, module.event.focus)
  50. .on('blur.' + namespace, module.event.blur)
  51. .on('keydown.' + namespace, module.handleKeyboard)
  52. ;
  53. if(settings.automatic) {
  54. $searchPrompt
  55. .on(inputEvent + '.' + namespace, module.search.throttle)
  56. ;
  57. }
  58. $searchButton
  59. .on('click.' + namespace, module.search.query)
  60. ;
  61. $searchResults
  62. .on('click.' + namespace, settings.selector.result, module.results.select)
  63. ;
  64. $module
  65. .data('module-' + namespace, module)
  66. ;
  67. },
  68. event: {
  69. focus: function() {
  70. $module
  71. .addClass(className.focus)
  72. ;
  73. module.results.show();
  74. },
  75. blur: function() {
  76. module.search.cancel();
  77. $module
  78. .removeClass(className.focus)
  79. ;
  80. module.results.hide();
  81. }
  82. },
  83. handleKeyboard: function(event) {
  84. var
  85. // force latest jq dom
  86. $result = $module.find(settings.selector.result),
  87. $category = $module.find(settings.selector.category),
  88. keyCode = event.which,
  89. keys = {
  90. backspace : 8,
  91. enter : 13,
  92. escape : 27,
  93. upArrow : 38,
  94. downArrow : 40
  95. },
  96. activeClass = className.active,
  97. currentIndex = $result.index( $result.filter('.' + activeClass) ),
  98. resultSize = $result.size(),
  99. newIndex
  100. ;
  101. // search shortcuts
  102. if(keyCode == keys.escape) {
  103. $searchPrompt
  104. .trigger('blur')
  105. ;
  106. }
  107. // result shortcuts
  108. if($searchResults.filter(':visible').size() > 0) {
  109. if(keyCode == keys.enter) {
  110. if( $result.filter('.' + activeClass).exists() ) {
  111. $.proxy(module.results.select, $result.filter('.' + activeClass) )();
  112. event.preventDefault();
  113. return false;
  114. }
  115. }
  116. else if(keyCode == keys.upArrow) {
  117. newIndex = (currentIndex - 1 < 0)
  118. ? currentIndex
  119. : currentIndex - 1
  120. ;
  121. $category
  122. .removeClass(activeClass)
  123. ;
  124. $result
  125. .removeClass(activeClass)
  126. .eq(newIndex)
  127. .addClass(activeClass)
  128. .closest($category)
  129. .addClass(activeClass)
  130. ;
  131. event.preventDefault();
  132. }
  133. else if(keyCode == keys.downArrow) {
  134. newIndex = (currentIndex + 1 >= resultSize)
  135. ? currentIndex
  136. : currentIndex + 1
  137. ;
  138. $category
  139. .removeClass(activeClass)
  140. ;
  141. $result
  142. .removeClass(activeClass)
  143. .eq(newIndex)
  144. .addClass(activeClass)
  145. .closest($category)
  146. .addClass(activeClass)
  147. ;
  148. event.preventDefault();
  149. }
  150. }
  151. else {
  152. // query shortcuts
  153. if(keyCode == keys.enter) {
  154. module.search.query();
  155. $searchButton
  156. .addClass(className.down)
  157. ;
  158. $searchPrompt
  159. .one('keyup', function(){
  160. $searchButton
  161. .removeClass(className.down)
  162. ;
  163. })
  164. ;
  165. }
  166. }
  167. },
  168. search: {
  169. cancel: function() {
  170. var
  171. xhr = $module.data('xhr') || false
  172. ;
  173. if( xhr && xhr.state() != 'resolved') {
  174. xhr.abort();
  175. }
  176. },
  177. throttle: function(event) {
  178. var
  179. searchTerm = $searchPrompt.val(),
  180. numCharacters = searchTerm.length,
  181. timer
  182. ;
  183. clearTimeout($module.data('timer'));
  184. if(numCharacters >= settings.minCharacters) {
  185. timer = setTimeout(module.search.query, settings.searchThrottle);
  186. $module
  187. .data('timer', timer)
  188. ;
  189. }
  190. else {
  191. module.results.hide();
  192. }
  193. },
  194. query: function() {
  195. var
  196. searchTerm = $searchPrompt.val(),
  197. cachedHTML = module.search.cache.read(searchTerm)
  198. ;
  199. if(cachedHTML) {
  200. module.debug("Reading result for '" + searchTerm + "' from cache");
  201. module.results.add(cachedHTML);
  202. }
  203. else {
  204. module.debug("Querying for '" + searchTerm + "'");
  205. if(typeof source == 'object') {
  206. module.search.local(searchTerm);
  207. }
  208. else {
  209. module.search.remote(searchTerm);
  210. }
  211. $.proxy(settings.onSearchQuery, $module)(searchTerm);
  212. }
  213. },
  214. local: function(searchTerm) {
  215. var
  216. searchResults = [],
  217. fullTextResults = [],
  218. searchFields = $.isArray(settings.searchFields)
  219. ? settings.searchFields
  220. : [settings.searchFields],
  221. searchRegExp = new RegExp('(?:\s|^)' + searchTerm, 'i'),
  222. fullTextRegExp = new RegExp(searchTerm, 'i'),
  223. searchHTML
  224. ;
  225. $module
  226. .addClass(className.loading)
  227. ;
  228. // iterate through search fields in array order
  229. $.each(searchFields, function(index, field) {
  230. $.each(source, function(label, thing) {
  231. if(typeof thing[field] == 'string' && ($.inArray(thing, searchResults) == -1) && ($.inArray(thing, fullTextResults) == -1) ) {
  232. if( searchRegExp.test( thing[field] ) ) {
  233. searchResults.push(thing);
  234. }
  235. else if( fullTextRegExp.test( thing[field] ) ) {
  236. fullTextResults.push(thing);
  237. }
  238. }
  239. });
  240. });
  241. searchHTML = module.results.generate({
  242. results: $.merge(searchResults, fullTextResults)
  243. });
  244. $module
  245. .removeClass(className.loading)
  246. ;
  247. module.search.cache.write(searchTerm, searchHTML);
  248. module.results.add(searchHTML);
  249. },
  250. remote: function(searchTerm) {
  251. var
  252. xhr = ($module.data('xhr') !== undefined)
  253. ? $module.data('xhr')
  254. : false,
  255. apiSettings = {
  256. stateContext : $module,
  257. url : source,
  258. urlData: { query: searchTerm },
  259. success : function(response) {
  260. searchHTML = module.results.generate(response);
  261. module.search.cache.write(searchTerm, searchHTML);
  262. module.results.add(searchHTML);
  263. },
  264. failure : module.error
  265. },
  266. searchHTML
  267. ;
  268. // api attaches xhr event to context, use this to prevent overlapping queries
  269. if( xhr && xhr.state() != 'resolved') {
  270. xhr.abort();
  271. }
  272. $.extend(true, apiSettings, settings.apiSettings);
  273. $.api(apiSettings);
  274. },
  275. cache: {
  276. read: function(name) {
  277. var
  278. cache = $module.data('cache')
  279. ;
  280. return (settings.cache && (typeof cache == 'object') && (cache[name] !== undefined) )
  281. ? cache[name]
  282. : false
  283. ;
  284. },
  285. write: function(name, value) {
  286. var
  287. cache = ($module.data('cache') !== undefined)
  288. ? $module.data('cache')
  289. : {}
  290. ;
  291. cache[name] = value;
  292. $module
  293. .data('cache', cache)
  294. ;
  295. }
  296. }
  297. },
  298. results: {
  299. generate: function(response) {
  300. module.debug('Generating html from response', response);
  301. var
  302. template = settings.templates[settings.type],
  303. html = ''
  304. ;
  305. if(($.isPlainObject(response.results) && !$.isEmptyObject(response.results)) || ($.isArray(response.results) && response.results.length > 0) ) {
  306. if(settings.maxResults > 0) {
  307. response.results = $.makeArray(response.results).slice(0, settings.maxResults);
  308. }
  309. if(response.results.length > 0) {
  310. if($.isFunction(template)) {
  311. html = template(response);
  312. }
  313. else {
  314. module.error(errors.noTemplate, false);
  315. }
  316. }
  317. }
  318. else {
  319. html = module.message(errors.noResults, 'empty');
  320. }
  321. $.proxy(settings.onSearchResults, $module)(response);
  322. return html;
  323. },
  324. add: function(html) {
  325. if(settings.onResultsAdd == 'default' || $.proxy(settings.onResultsAdd, $searchResults)(html) == 'default') {
  326. $searchResults
  327. .html(html)
  328. ;
  329. }
  330. module.results.show();
  331. },
  332. show: function() {
  333. if( ($searchResults.filter(':visible').size() === 0) && ($searchPrompt.filter(':focus').size() > 0) && $searchResults.html() !== '') {
  334. $searchResults
  335. .stop()
  336. .fadeIn(200)
  337. ;
  338. $.proxy(settings.onResultsOpen, $searchResults)();
  339. }
  340. },
  341. hide: function() {
  342. if($searchResults.filter(':visible').size() > 0) {
  343. $searchResults
  344. .stop()
  345. .fadeOut(200)
  346. ;
  347. $.proxy(settings.onResultsClose, $searchResults)();
  348. }
  349. },
  350. followLink: function() {
  351. },
  352. select: function(event) {
  353. module.debug('Search result selected');
  354. var
  355. $result = $(this),
  356. $title = $result.find('.title'),
  357. title = $title.html()
  358. ;
  359. if(settings.onSelect == 'default' || $.proxy(settings.onSelect, this)(event) == 'default') {
  360. var
  361. $link = $result.find('a[href]').eq(0),
  362. href = $link.attr('href'),
  363. target = $link.attr('target')
  364. ;
  365. try {
  366. module.results.hide();
  367. $searchPrompt
  368. .val(title)
  369. ;
  370. if(target == '_blank' || event.ctrlKey) {
  371. window.open(href);
  372. }
  373. else {
  374. window.location.href = (href);
  375. }
  376. }
  377. catch(error) {}
  378. }
  379. }
  380. },
  381. /* standard module */
  382. setting: function(name, value) {
  383. if(value === undefined) {
  384. return settings[name];
  385. }
  386. settings[name] = value;
  387. },
  388. debug: function() {
  389. var
  390. output = [],
  391. message = settings.moduleName + ': ' + arguments[0],
  392. variables = [].slice.call( arguments, 1 ),
  393. log = console.info || console.log || function(){}
  394. ;
  395. log = Function.prototype.bind.call(log, console);
  396. if(settings.debug) {
  397. output.push(message);
  398. log.apply(console, output.concat(variables) );
  399. }
  400. },
  401. // displays mesage visibly in search results
  402. message: function(text, type) {
  403. type = type || 'standard';
  404. module.results.add( settings.templates.message(text, type) );
  405. return settings.templates.message(text, type);
  406. },
  407. // update view with error message
  408. error: function(errorMessage, escalate) {
  409. // show user error message
  410. escalate = (escalate !== undefined)
  411. ? escalate
  412. : true
  413. ;
  414. console.warn(settings.moduleName + ': ' + errorMessage);
  415. if(escalate && errorMessage !== undefined) {
  416. module.message(errorMessage, 'error');
  417. }
  418. },
  419. invoke: function(query, context, passedArguments) {
  420. var
  421. maxDepth,
  422. found
  423. ;
  424. passedArguments = passedArguments || [].slice.call( arguments, 2 );
  425. if(typeof query == 'string' && instance !== undefined) {
  426. query = query.split('.');
  427. maxDepth = query.length - 1;
  428. $.each(query, function(depth, value) {
  429. if( $.isPlainObject( instance[value] ) && (depth != maxDepth) ) {
  430. instance = instance[value];
  431. return true;
  432. }
  433. else if( instance[value] !== undefined ) {
  434. found = instance[value];
  435. return true;
  436. }
  437. module.error(errors.method);
  438. return false;
  439. });
  440. }
  441. if ( $.isFunction( found ) ) {
  442. return found.apply(context, passedArguments);
  443. }
  444. // return retrieved variable or chain
  445. return found;
  446. }
  447. };
  448. // check for invoking internal method
  449. if(methodInvoked) {
  450. invokedResponse = module.invoke(query, element, passedArguments);
  451. }
  452. // otherwise initialize
  453. else {
  454. module.initialize();
  455. }
  456. })
  457. ;
  458. // chain or return queried method
  459. return (invokedResponse !== undefined)
  460. ? invokedResponse
  461. : this
  462. ;
  463. };
  464. $.fn.searchPrompt.settings = {
  465. moduleName : 'Search Module',
  466. debug : true,
  467. namespace : 'search',
  468. // onSelect default action is defined in module
  469. onSelect : 'default',
  470. onResultsAdd : 'default',
  471. onSearchQuery : function(){},
  472. onSearchResults : function(response){},
  473. onResultsOpen : function(){},
  474. onResultsClose : function(){},
  475. automatic : 'true',
  476. type : 'simple',
  477. minCharacters : 3,
  478. searchThrottle : 300,
  479. maxResults : 7,
  480. cache : true,
  481. searchFields : [
  482. 'title',
  483. 'description'
  484. ],
  485. // api config
  486. apiSettings: {
  487. },
  488. className: {
  489. active : 'active',
  490. down : 'down',
  491. focus : 'focus',
  492. empty : 'empty',
  493. loading : 'loading'
  494. },
  495. errors : {
  496. noResults : 'Your search returned no results',
  497. logging : 'Error in debug logging, exiting.',
  498. noTemplate : 'A valid template name was not specified.',
  499. serverError : 'There was an issue with querying the server.',
  500. method : 'The method you called is not defined.'
  501. },
  502. selector : {
  503. searchPrompt : '.prompt',
  504. searchButton : '.search.button',
  505. searchResults : '.results',
  506. category : '.category',
  507. result : '.result',
  508. emptyResult : '.results .message',
  509. resultPage : '.results .page'
  510. },
  511. templates: {
  512. message: function(message, type) {
  513. var
  514. html = ''
  515. ;
  516. if(message !== undefined && type !== undefined) {
  517. html += ''
  518. + '<div class="message ' + type +'">'
  519. ;
  520. // message type
  521. if(type == 'empty') {
  522. html += ''
  523. + '<div class="header">No Results</div class="header">'
  524. + '<div class="description">' + message + '</div class="description">'
  525. ;
  526. }
  527. else {
  528. html += ' <div class="description">' + message + '</div>';
  529. }
  530. html += '</div>';
  531. }
  532. return html;
  533. },
  534. categories: function(response) {
  535. var
  536. html = ''
  537. ;
  538. if(response.results !== undefined) {
  539. // each category
  540. $.each(response.results, function(index, category) {
  541. if(category.results !== undefined && category.results.length > 0) {
  542. html += ''
  543. + '<div class="category">'
  544. + '<div class="name">' + category.name + '</div>'
  545. ;
  546. // each item inside category
  547. $.each(category.results, function(index, result) {
  548. html += '<div class="result">';
  549. html += '<a href="' + result.url + '"></a>';
  550. if(result.image !== undefined) {
  551. html+= ''
  552. + '<div class="image">'
  553. + ' <img src="' + result.image + '">'
  554. + '</div>'
  555. ;
  556. }
  557. html += '<div class="info">';
  558. if(result.price !== undefined) {
  559. html+= '<div class="price">' + result.price + '</div>';
  560. }
  561. if(result.title !== undefined) {
  562. html+= '<div class="title">' + result.title + '</div>';
  563. }
  564. if(result.description !== undefined) {
  565. html+= '<div class="description">' + result.description + '</div>';
  566. }
  567. html += ''
  568. + '</div>'
  569. + '</div>'
  570. ;
  571. });
  572. html += ''
  573. + '</div>'
  574. ;
  575. }
  576. });
  577. if(response.resultPage) {
  578. html += ''
  579. + '<a href="' + response.resultPage.url + '" class="all">'
  580. + response.resultPage.text
  581. + '</a>';
  582. }
  583. return html;
  584. }
  585. return false;
  586. },
  587. simple: function(response) {
  588. var
  589. html = ''
  590. ;
  591. if(response.results !== undefined) {
  592. // each result
  593. $.each(response.results, function(index, result) {
  594. html += '<a class="result" href="' + result.url + '">';
  595. if(result.image !== undefined) {
  596. html+= ''
  597. + '<div class="image">'
  598. + ' <img src="' + result.image + '">'
  599. + '</div>'
  600. ;
  601. }
  602. html += '<div class="info">';
  603. if(result.price !== undefined) {
  604. html+= '<div class="price">' + result.price + '</div>';
  605. }
  606. if(result.title !== undefined) {
  607. html+= '<div class="title">' + result.title + '</div>';
  608. }
  609. if(result.description !== undefined) {
  610. html+= '<div class="description">' + result.description + '</div>';
  611. }
  612. html += ''
  613. + '</div>'
  614. + '</a>'
  615. ;
  616. });
  617. if(response.resultPage) {
  618. html += ''
  619. + '<a href="' + response.resultPage.url + '" class="all">'
  620. + response.resultPage.text
  621. + '</a>';
  622. }
  623. return html;
  624. }
  625. return false;
  626. }
  627. }
  628. };
  629. })( jQuery, window , document );