horsey.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. 'use strict';
  2. var sell = require('sell');
  3. var crossvent = require('crossvent');
  4. var bullseye = require('bullseye');
  5. var fuzzysearch = require('fuzzysearch');
  6. var KEY_BACKSPACE = 8;
  7. var KEY_ENTER = 13;
  8. var KEY_ESC = 27;
  9. var KEY_UP = 38;
  10. var KEY_DOWN = 40;
  11. var cache = [];
  12. var doc = document;
  13. var docElement = doc.documentElement;
  14. function find (el) {
  15. var entry;
  16. var i;
  17. for (i = 0; i < cache.length; i++) {
  18. entry = cache[i];
  19. if (entry.el === el) {
  20. return entry.api;
  21. }
  22. }
  23. return null;
  24. }
  25. function horsey (el, options) {
  26. var cached = find(el);
  27. if (cached) {
  28. return cached;
  29. }
  30. var o = options || {};
  31. var parent = o.appendTo || doc.body;
  32. var render = o.render || defaultRenderer;
  33. var getText = o.getText || defaultGetText;
  34. var getValue = o.getValue || defaultGetValue;
  35. var form = o.form;
  36. var limit = typeof o.limit === 'number' ? o.limit : Infinity;
  37. var suggestions = o.suggestions;
  38. var userFilter = o.filter || defaultFilter;
  39. var userSet = o.set || defaultSetter;
  40. var ul = tag('ul', 'sey-list');
  41. var selection = null;
  42. var oneload = once(loading);
  43. var eye;
  44. var deferredFiltering = defer(filtering);
  45. var attachment = el;
  46. var textInput;
  47. var anyInput;
  48. var ranchorleft;
  49. var ranchorright;
  50. if (o.autoHideOnBlur === void 0) { o.autoHideOnBlur = true; }
  51. if (o.autoHideOnClick === void 0) { o.autoHideOnClick = true; }
  52. if (o.autoShowOnUpDown === void 0) { o.autoShowOnUpDown = el.tagName === 'INPUT'; }
  53. if (o.anchor) {
  54. ranchorleft = new RegExp('^' + o.anchor);
  55. ranchorright = new RegExp(o.anchor + '$');
  56. }
  57. var api = {
  58. add: add,
  59. anchor: o.anchor,
  60. clear: clear,
  61. show: show,
  62. hide: hide,
  63. toggle: toggle,
  64. destroy: destroy,
  65. refreshPosition: refreshPosition,
  66. appendText: appendText,
  67. appendHTML: appendHTML,
  68. filterAnchoredText: filterAnchoredText,
  69. filterAnchoredHTML: filterAnchoredHTML,
  70. defaultAppendText: appendText,
  71. defaultFilter: defaultFilter,
  72. defaultGetText: defaultGetText,
  73. defaultGetValue: defaultGetValue,
  74. defaultRenderer: defaultRenderer,
  75. defaultSetter: defaultSetter,
  76. retarget: retarget,
  77. attachment: attachment,
  78. list: ul,
  79. suggestions: []
  80. };
  81. var entry = { el: el, api: api };
  82. retarget(el);
  83. cache.push(entry);
  84. parent.appendChild(ul);
  85. el.setAttribute('autocomplete', 'off');
  86. if (Array.isArray(suggestions)) {
  87. loaded(suggestions);
  88. }
  89. return api;
  90. function retarget (el) {
  91. inputEvents(true);
  92. attachment = api.attachment = el;
  93. textInput = attachment.tagName === 'INPUT' || attachment.tagName === 'TEXTAREA';
  94. anyInput = textInput || isEditable(attachment);
  95. inputEvents();
  96. }
  97. function refreshPosition () {
  98. if (eye) { eye.refresh(); }
  99. }
  100. function loading () {
  101. crossvent.remove(attachment, 'focus', oneload);
  102. suggestions(loaded);
  103. }
  104. function loaded (suggestions) {
  105. suggestions.forEach(add);
  106. api.suggestions = suggestions;
  107. }
  108. function clear () {
  109. while (ul.lastChild) {
  110. ul.removeChild(ul.lastChild);
  111. }
  112. }
  113. function add (suggestion) {
  114. var li = tag('li', 'sey-item');
  115. render(li, suggestion);
  116. crossvent.add(li, 'click', clickedSuggestion);
  117. crossvent.add(li, 'horsey-filter', filterItem);
  118. crossvent.add(li, 'horsey-hide', hideItem);
  119. ul.appendChild(li);
  120. api.suggestions.push(suggestion);
  121. return li;
  122. function clickedSuggestion () {
  123. var value = getValue(suggestion);
  124. set(value);
  125. hide();
  126. attachment.focus();
  127. crossvent.fabricate(attachment, 'horsey-selected', value);
  128. }
  129. function filterItem () {
  130. var value = textInput ? el.value : el.innerHTML;
  131. if (filter(value, suggestion)) {
  132. li.className = li.className.replace(/ sey-hide/g, '');
  133. } else {
  134. crossvent.fabricate(li, 'horsey-hide');
  135. }
  136. }
  137. function hideItem () {
  138. if (!hidden(li)) {
  139. li.className += ' sey-hide';
  140. if (selection === li) {
  141. unselect();
  142. }
  143. }
  144. }
  145. }
  146. function set (value) {
  147. if (o.anchor) {
  148. return (isText() ? api.appendText : api.appendHTML)(value);
  149. }
  150. userSet(value);
  151. }
  152. function filter (value, suggestion) {
  153. if (o.anchor) {
  154. var il = (isText() ? api.filterAnchoredText : api.filterAnchoredHTML)(value, suggestion);
  155. return il ? userFilter(il.input, il.suggestion) : false;
  156. }
  157. return userFilter(value, suggestion);
  158. }
  159. function isText () { return isInput(attachment); }
  160. function visible () { return ul.className.indexOf('sey-show') !== -1; }
  161. function hidden (li) { return li.className.indexOf('sey-hide') !== -1; }
  162. function show () {
  163. if (!visible()) {
  164. ul.className += ' sey-show';
  165. eye.refresh();
  166. crossvent.fabricate(attachment, 'horsey-show');
  167. }
  168. }
  169. function toggler (e) {
  170. var left = e.which === 1 && !e.metaKey && !e.ctrlKey;
  171. if (left === false) {
  172. return; // we only care about honest to god left-clicks
  173. }
  174. toggle();
  175. }
  176. function toggle () {
  177. if (!visible()) {
  178. show();
  179. } else {
  180. hide();
  181. }
  182. }
  183. function select (suggestion) {
  184. unselect();
  185. if (suggestion) {
  186. selection = suggestion;
  187. selection.className += ' sey-selected';
  188. }
  189. }
  190. function unselect () {
  191. if (selection) {
  192. selection.className = selection.className.replace(/ sey-selected/g, '');
  193. selection = null;
  194. }
  195. }
  196. function move (up, moves) {
  197. var total = ul.children.length;
  198. if (total < moves) {
  199. unselect();
  200. return;
  201. }
  202. if (total === 0) {
  203. return;
  204. }
  205. var first = up ? 'lastChild' : 'firstChild';
  206. var next = up ? 'previousSibling' : 'nextSibling';
  207. var suggestion = selection && selection[next] || ul[first];
  208. select(suggestion);
  209. if (hidden(suggestion)) {
  210. move(up, moves ? moves + 1 : 1);
  211. }
  212. }
  213. function hide () {
  214. eye.sleep();
  215. ul.className = ul.className.replace(/ sey-show/g, '');
  216. unselect();
  217. crossvent.fabricate(attachment, 'horsey-hide');
  218. }
  219. function keydown (e) {
  220. var shown = visible();
  221. var which = e.which || e.keyCode;
  222. if (which === KEY_DOWN) {
  223. if (anyInput && o.autoShowOnUpDown) {
  224. show();
  225. }
  226. if (shown) {
  227. move();
  228. stop(e);
  229. }
  230. } else if (which === KEY_UP) {
  231. if (anyInput && o.autoShowOnUpDown) {
  232. show();
  233. }
  234. if (shown) {
  235. move(true);
  236. stop(e);
  237. }
  238. } else if (which === KEY_BACKSPACE) {
  239. if (anyInput && o.autoShowOnUpDown) {
  240. show();
  241. }
  242. } else if (shown) {
  243. if (which === KEY_ENTER) {
  244. if (selection) {
  245. crossvent.fabricate(selection, 'click');
  246. } else {
  247. hide();
  248. }
  249. stop(e);
  250. } else if (which === KEY_ESC) {
  251. hide();
  252. stop(e);
  253. }
  254. }
  255. }
  256. function stop (e) {
  257. e.stopPropagation();
  258. e.preventDefault();
  259. }
  260. function filtering () {
  261. if (!visible()) {
  262. return;
  263. }
  264. crossvent.fabricate(attachment, 'horsey-filter');
  265. var li = ul.firstChild;
  266. var count = 0;
  267. while (li) {
  268. if (count >= limit) {
  269. crossvent.fabricate(li, 'horsey-hide');
  270. }
  271. if (count < limit) {
  272. crossvent.fabricate(li, 'horsey-filter');
  273. if (li.className.indexOf('sey-hide') === -1) {
  274. count++;
  275. }
  276. }
  277. li = li.nextSibling;
  278. }
  279. if (!selection) {
  280. move();
  281. }
  282. if (!selection) {
  283. hide();
  284. }
  285. }
  286. function deferredFilteringNoEnter (e) {
  287. var which = e.which || e.keyCode;
  288. if (which === KEY_ENTER) {
  289. return;
  290. }
  291. deferredFiltering();
  292. }
  293. function deferredShow (e) {
  294. var which = e.which || e.keyCode;
  295. if (which === KEY_ENTER) {
  296. return;
  297. }
  298. setTimeout(show, 0);
  299. }
  300. function horseyEventTarget (e) {
  301. var target = e.target;
  302. if (target === attachment) {
  303. return true;
  304. }
  305. while (target) {
  306. if (target === ul || target === attachment) {
  307. return true;
  308. }
  309. target = target.parentNode;
  310. }
  311. }
  312. function hideOnBlur (e) {
  313. if (horseyEventTarget(e)) {
  314. return;
  315. }
  316. hide();
  317. }
  318. function hideOnClick (e) {
  319. if (horseyEventTarget(e)) {
  320. return;
  321. }
  322. hide();
  323. }
  324. function inputEvents (remove) {
  325. var op = remove ? 'remove' : 'add';
  326. if (eye) {
  327. eye.destroy();
  328. eye = null;
  329. }
  330. if (!remove) {
  331. eye = bullseye(ul, attachment, { caret: anyInput && attachment.tagName !== 'INPUT' });
  332. if (!visible()) { eye.sleep(); }
  333. }
  334. if (typeof suggestions === 'function' && !oneload.used) {
  335. if (remove || (anyInput && doc.activeElement !== attachment)) {
  336. crossvent[op](attachment, 'focus', oneload);
  337. } else {
  338. oneload();
  339. }
  340. }
  341. if (anyInput) {
  342. crossvent[op](attachment, 'keypress', deferredShow);
  343. crossvent[op](attachment, 'keypress', deferredFiltering);
  344. crossvent[op](attachment, 'keydown', deferredFilteringNoEnter);
  345. crossvent[op](attachment, 'paste', deferredFiltering);
  346. crossvent[op](attachment, 'keydown', keydown);
  347. if (o.autoHideOnBlur) { crossvent[op](docElement, 'focus', hideOnBlur, true); }
  348. } else {
  349. crossvent[op](attachment, 'click', toggler);
  350. crossvent[op](docElement, 'keydown', keydown);
  351. }
  352. if (o.autoHideOnClick) { crossvent[op](doc, 'click', hideOnClick); }
  353. if (form) { crossvent[op](form, 'submit', hide); }
  354. }
  355. function destroy () {
  356. inputEvents(true);
  357. if (parent.contains(ul)) { parent.removeChild(ul); }
  358. cache.splice(cache.indexOf(entry), 1);
  359. }
  360. function defaultSetter (value) {
  361. if (textInput) {
  362. el.value = value;
  363. } else {
  364. el.innerHTML = value;
  365. }
  366. }
  367. function defaultRenderer (li, suggestion) {
  368. li.innerText = li.textContent = getText(suggestion);
  369. }
  370. function defaultFilter (q, suggestion) {
  371. var text = getText(suggestion) || '';
  372. var value = getValue(suggestion) || '';
  373. var needle = q.toLowerCase();
  374. return fuzzysearch(needle, text.toLowerCase()) || fuzzysearch(needle, value.toLowerCase());
  375. }
  376. function loopbackToAnchor (text, p) {
  377. var result = '';
  378. var anchored = false;
  379. var start = p.start;
  380. while (anchored === false && start >= 0) {
  381. result = text.substr(start - 1, p.start - start + 1);
  382. anchored = ranchorleft.test(result);
  383. start--;
  384. }
  385. return {
  386. text: anchored ? result : null,
  387. start: start
  388. };
  389. }
  390. function filterAnchoredText (q, suggestion) {
  391. var position = sell(el);
  392. var input = loopbackToAnchor(q, position).text;
  393. if (input) {
  394. return { input: input, suggestion: suggestion };
  395. }
  396. }
  397. function appendText (value) {
  398. var current = el.value;
  399. var position = sell(el);
  400. var input = loopbackToAnchor(current, position);
  401. var left = current.substr(0, input.start);
  402. var right = current.substr(input.start + input.text.length + (position.end - position.start));
  403. var before = left + value + ' ';
  404. el.value = before + right;
  405. sell(el, { start: before.length, end: before.length });
  406. }
  407. function filterAnchoredHTML () {
  408. throw new Error('Anchoring in editable elements is disabled by default.');
  409. }
  410. function appendHTML () {
  411. throw new Error('Anchoring in editable elements is disabled by default.');
  412. }
  413. }
  414. function isInput (el) { return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'; }
  415. function defaultGetValue (suggestion) {
  416. return defaultGet('value', suggestion);
  417. }
  418. function defaultGetText (suggestion) {
  419. return defaultGet('text', suggestion);
  420. }
  421. function defaultGet (type, value) {
  422. return value && value[type] !== void 0 ? value[type] : value;
  423. }
  424. function tag (type, className) {
  425. var el = doc.createElement(type);
  426. el.className = className;
  427. return el;
  428. }
  429. function once (fn) {
  430. var disposed;
  431. function disposable () {
  432. if (disposed) { return; }
  433. disposable.used = disposed = true;
  434. (fn || noop).apply(null, arguments);
  435. }
  436. return disposable;
  437. }
  438. function defer (fn) { return function () { setTimeout(fn, 0); }; }
  439. function noop () {}
  440. function isEditable (el) {
  441. var value = el.getAttribute('contentEditable');
  442. if (value === 'false') {
  443. return false;
  444. }
  445. if (value === 'true') {
  446. return true;
  447. }
  448. if (el.parentElement) {
  449. return isEditable(el.parentElement);
  450. }
  451. return false;
  452. }
  453. horsey.find = find;
  454. module.exports = horsey;