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.

669 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 : ['title', 'description'],
  482. // api config
  483. apiSettings: {
  484. },
  485. className: {
  486. active : 'active',
  487. down : 'down',
  488. focus : 'focus',
  489. empty : 'empty',
  490. loading : 'loading'
  491. },
  492. errors : {
  493. noResults : 'Your search returned no results',
  494. logging : 'Error in debug logging, exiting.',
  495. noTemplate : 'A valid template name was not specified.',
  496. serverError : 'There was an issue with querying the server.',
  497. method : 'The method you called is not defined.'
  498. },
  499. selector : {
  500. searchPrompt : '.prompt',
  501. searchButton : '.search.button',
  502. searchResults : '.results',
  503. category : '.category',
  504. result : '.result',
  505. emptyResult : '.results .message',
  506. resultPage : '.results .page'
  507. },
  508. templates: {
  509. message: function(message, type) {
  510. var
  511. html = ''
  512. ;
  513. if(message !== undefined && type !== undefined) {
  514. html += ''
  515. + '<div class="message ' + type +'">'
  516. + '<div class="text">'
  517. ;
  518. // message type
  519. if(type == 'empty') {
  520. html += ''
  521. + '<h2>No Results</h2>'
  522. + '<p>' + message + '</p>'
  523. ;
  524. }
  525. else {
  526. html += ' <div class="text">' + message + '</div>';
  527. }
  528. html += '</div>';
  529. }
  530. return html;
  531. },
  532. categories: function(response) {
  533. var
  534. html = ''
  535. ;
  536. if(response.results !== undefined) {
  537. // each category
  538. $.each(response.results, function(index, category) {
  539. if(category.results !== undefined && category.results.length > 0) {
  540. html += ''
  541. + '<div class="category">'
  542. + '<div class="name">' + category.name + '</div>'
  543. + '<ul>'
  544. ;
  545. // each item inside category
  546. $.each(category.results, function(index, result) {
  547. html += '<li class="result">';
  548. html += '<a href="' + result.url + '"></a>';
  549. if(result.image !== undefined) {
  550. html+= ''
  551. + '<div class="image">'
  552. + ' <img src="' + result.image + '">'
  553. + '</div>'
  554. ;
  555. }
  556. html += (result.image !== undefined)
  557. ? '<div class="indented info">'
  558. : '<div class="info">'
  559. ;
  560. if(result.price !== undefined) {
  561. html+= '<div class="price">' + result.price + '</div>';
  562. }
  563. if(result.title !== undefined) {
  564. html+= '<div class="title">' + result.title + '</div>';
  565. }
  566. if(result.description !== undefined) {
  567. html+= '<div class="description">' + result.description + '</div>';
  568. }
  569. html += ''
  570. + '</div>'
  571. + '</li>'
  572. ;
  573. });
  574. html += ''
  575. + '</ul>'
  576. + '</div>'
  577. ;
  578. }
  579. });
  580. if(response.resultPage) {
  581. html += ''
  582. + '<a href="' + response.resultPage.url + '" class="result-page">'
  583. + response.resultPage.text
  584. + '</a>';
  585. }
  586. return html;
  587. }
  588. return false;
  589. },
  590. simple: function(response) {
  591. var
  592. html = ''
  593. ;
  594. if(response.results !== undefined) {
  595. html += '<ul>';
  596. // each result
  597. $.each(response.results, function(index, result) {
  598. html += '<li class="result">';
  599. if(result.url !== undefined) {
  600. html += '<a href="' + result.url + '"></a>';
  601. }
  602. if(result.image !== undefined) {
  603. html+= ''
  604. + '<div class="image">'
  605. + ' <img src="' + result.image + '">'
  606. + '</div>'
  607. ;
  608. }
  609. html += (result.image !== undefined)
  610. ? '<div class="indented info">'
  611. : '<div class="info">'
  612. ;
  613. if(result.price !== undefined) {
  614. html+= '<div class="price">' + result.price + '</div>';
  615. }
  616. if(result.title !== undefined) {
  617. html+= '<div class="title">' + result.title + '</div>';
  618. }
  619. if(result.description !== undefined) {
  620. html+= '<div class="description">' + result.description + '</div>';
  621. }
  622. html += ''
  623. + '</div>'
  624. + '</li>'
  625. ;
  626. });
  627. html += '</ul>';
  628. if(response.resultPage) {
  629. html += ''
  630. + '<a href="' + response.resultPage.url + '" class="result-page">'
  631. + response.resultPage.text
  632. + '</a>';
  633. }
  634. return html;
  635. }
  636. return false;
  637. }
  638. }
  639. };
  640. })( jQuery, window , document );