converse-autocomplete.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. // Converse.js
  2. // http://conversejs.org
  3. //
  4. // Copyright (c) 2013-2018, the Converse.js developers
  5. // Licensed under the Mozilla Public License (MPLv2)
  6. // This plugin started as a fork of Lea Verou's Awesomplete
  7. // https://leaverou.github.io/awesomplete/
  8. import converse from "@converse/headless/converse-core";
  9. const { _, Backbone } = converse.env,
  10. u = converse.env.utils;
  11. converse.plugins.add("converse-autocomplete", {
  12. initialize () {
  13. const { _converse } = this;
  14. _converse.FILTER_CONTAINS = function (text, input) {
  15. return RegExp(helpers.regExpEscape(input.trim()), "i").test(text);
  16. };
  17. _converse.FILTER_STARTSWITH = function (text, input) {
  18. return RegExp("^" + helpers.regExpEscape(input.trim()), "i").test(text);
  19. };
  20. const SORT_BYLENGTH = function (a, b) {
  21. if (a.length !== b.length) {
  22. return a.length - b.length;
  23. }
  24. return a < b? -1 : 1;
  25. };
  26. const ITEM = (text, input) => {
  27. input = input.trim();
  28. const element = document.createElement("li");
  29. element.setAttribute("aria-selected", "false");
  30. const regex = new RegExp("("+input+")", "ig");
  31. const parts = input ? text.split(regex) : [text];
  32. parts.forEach((txt) => {
  33. if (input && txt.match(regex)) {
  34. const match = document.createElement("mark");
  35. match.textContent = txt;
  36. element.appendChild(match);
  37. } else {
  38. element.appendChild(document.createTextNode(txt));
  39. }
  40. });
  41. return element;
  42. };
  43. class AutoComplete {
  44. constructor (el, config={}) {
  45. this.is_opened = false;
  46. if (u.hasClass('.suggestion-box', el)) {
  47. this.container = el;
  48. } else {
  49. this.container = el.querySelector('.suggestion-box');
  50. }
  51. this.input = this.container.querySelector('.suggestion-box__input');
  52. this.input.setAttribute("autocomplete", "off");
  53. this.input.setAttribute("aria-autocomplete", "list");
  54. this.ul = this.container.querySelector('.suggestion-box__results');
  55. this.status = this.container.querySelector('.suggestion-box__additions');
  56. _.assignIn(this, {
  57. 'match_current_word': false, // Match only the current word, otherwise all input is matched
  58. 'match_on_tab': false, // Whether matching should only start when tab's pressed
  59. 'trigger_on_at': false, // Whether @ should trigger autocomplete
  60. 'min_chars': 2,
  61. 'max_items': 10,
  62. 'auto_evaluate': true,
  63. 'auto_first': false,
  64. 'data': _.identity,
  65. 'filter': _converse.FILTER_CONTAINS,
  66. 'sort': config.sort === false ? false : SORT_BYLENGTH,
  67. 'item': ITEM
  68. }, config);
  69. this.index = -1;
  70. this.bindEvents()
  71. if (this.input.hasAttribute("list")) {
  72. this.list = "#" + this.input.getAttribute("list");
  73. this.input.removeAttribute("list");
  74. } else {
  75. this.list = this.input.getAttribute("data-list") || config.list || [];
  76. }
  77. }
  78. bindEvents () {
  79. // Bind events
  80. const input = {
  81. "blur": () => this.close({'reason': 'blur'})
  82. }
  83. if (this.auto_evaluate) {
  84. input["input"] = () => this.evaluate();
  85. }
  86. this._events = {
  87. 'input': input,
  88. 'form': {
  89. "submit": () => this.close({'reason': 'submit'})
  90. },
  91. 'ul': {
  92. "mousedown": (ev) => this.onMouseDown(ev),
  93. "mouseover": (ev) => this.onMouseOver(ev)
  94. }
  95. };
  96. helpers.bind(this.input, this._events.input);
  97. helpers.bind(this.input.form, this._events.form);
  98. helpers.bind(this.ul, this._events.ul);
  99. }
  100. set list (list) {
  101. if (Array.isArray(list) || typeof list === "function") {
  102. this._list = list;
  103. } else if (typeof list === "string" && _.includes(list, ",")) {
  104. this._list = list.split(/\s*,\s*/);
  105. } else { // Element or CSS selector
  106. list = helpers.getElement(list);
  107. if (list && list.children) {
  108. const items = [];
  109. slice.apply(list.children).forEach(function (el) {
  110. if (!el.disabled) {
  111. const text = el.textContent.trim(),
  112. value = el.value || text,
  113. label = el.label || text;
  114. if (value !== "") {
  115. items.push({ label: label, value: value });
  116. }
  117. }
  118. });
  119. this._list = items;
  120. }
  121. }
  122. if (document.activeElement === this.input) {
  123. this.evaluate();
  124. }
  125. }
  126. get selected () {
  127. return this.index > -1;
  128. }
  129. get opened () {
  130. return this.is_opened;
  131. }
  132. close (o) {
  133. if (!this.opened) {
  134. return;
  135. }
  136. this.ul.setAttribute("hidden", "");
  137. this.is_opened = false;
  138. this.index = -1;
  139. this.trigger("suggestion-box-close", o || {});
  140. }
  141. insertValue (suggestion) {
  142. let value;
  143. if (this.match_current_word) {
  144. u.replaceCurrentWord(this.input, suggestion.value);
  145. } else {
  146. this.input.value = suggestion.value;
  147. }
  148. }
  149. open () {
  150. this.ul.removeAttribute("hidden");
  151. this.is_opened = true;
  152. if (this.auto_first && this.index === -1) {
  153. this.goto(0);
  154. }
  155. this.trigger("suggestion-box-open");
  156. }
  157. destroy () {
  158. //remove events from the input and its form
  159. helpers.unbind(this.input, this._events.input);
  160. helpers.unbind(this.input.form, this._events.form);
  161. //move the input out of the suggestion-box container and remove the container and its children
  162. const parentNode = this.container.parentNode;
  163. parentNode.insertBefore(this.input, this.container);
  164. parentNode.removeChild(this.container);
  165. //remove autocomplete and aria-autocomplete attributes
  166. this.input.removeAttribute("autocomplete");
  167. this.input.removeAttribute("aria-autocomplete");
  168. }
  169. next () {
  170. const count = this.ul.children.length;
  171. this.goto(this.index < count - 1 ? this.index + 1 : (count ? 0 : -1) );
  172. }
  173. previous () {
  174. const count = this.ul.children.length,
  175. pos = this.index - 1;
  176. this.goto(this.selected && pos !== -1 ? pos : count - 1);
  177. }
  178. goto (i) {
  179. // Should not be used directly, highlights specific item without any checks!
  180. const list = this.ul.children;
  181. if (this.selected) {
  182. list[this.index].setAttribute("aria-selected", "false");
  183. }
  184. this.index = i;
  185. if (i > -1 && list.length > 0) {
  186. list[i].setAttribute("aria-selected", "true");
  187. list[i].focus();
  188. this.status.textContent = list[i].textContent;
  189. // scroll to highlighted element in case parent's height is fixed
  190. this.ul.scrollTop = list[i].offsetTop - this.ul.clientHeight + list[i].clientHeight;
  191. this.trigger("suggestion-box-highlight", {'text': this.suggestions[this.index]});
  192. }
  193. }
  194. select (selected, origin) {
  195. if (selected) {
  196. this.index = u.siblingIndex(selected);
  197. } else {
  198. selected = this.ul.children[this.index];
  199. }
  200. if (selected) {
  201. const suggestion = this.suggestions[this.index];
  202. this.insertValue(suggestion);
  203. this.close({'reason': 'select'});
  204. this.auto_completing = false;
  205. this.trigger("suggestion-box-selectcomplete", {'text': suggestion});
  206. }
  207. }
  208. onMouseOver (ev) {
  209. const li = u.ancestor(ev.target, 'li');
  210. if (li) {
  211. this.goto(Array.prototype.slice.call(this.ul.children).indexOf(li))
  212. }
  213. }
  214. onMouseDown (ev) {
  215. if (ev.button !== 0) {
  216. return; // Only select on left click
  217. }
  218. const li = u.ancestor(ev.target, 'li');
  219. if (li) {
  220. ev.preventDefault();
  221. this.select(li, ev.target);
  222. }
  223. }
  224. keyPressed (ev) {
  225. if (this.opened) {
  226. if (_.includes([_converse.keycodes.ENTER, _converse.keycodes.TAB], ev.keyCode) && this.selected) {
  227. ev.preventDefault();
  228. ev.stopPropagation();
  229. this.select();
  230. return true;
  231. } else if (ev.keyCode === _converse.keycodes.ESCAPE) {
  232. this.close({'reason': 'esc'});
  233. return true;
  234. } else if (_.includes([_converse.keycodes.UP_ARROW, _converse.keycodes.DOWN_ARROW], ev.keyCode)) {
  235. ev.preventDefault();
  236. ev.stopPropagation();
  237. this[ev.keyCode === _converse.keycodes.UP_ARROW ? "previous" : "next"]();
  238. return true;
  239. }
  240. }
  241. if (_.includes([
  242. _converse.keycodes.SHIFT,
  243. _converse.keycodes.META,
  244. _converse.keycodes.META_RIGHT,
  245. _converse.keycodes.ESCAPE,
  246. _converse.keycodes.ALT]
  247. , ev.keyCode)) {
  248. return;
  249. }
  250. if (this.match_on_tab && ev.keyCode === _converse.keycodes.TAB) {
  251. ev.preventDefault();
  252. this.auto_completing = true;
  253. } else if (this.trigger_on_at && ev.keyCode === _converse.keycodes.AT) {
  254. this.auto_completing = true;
  255. }
  256. }
  257. evaluate (ev) {
  258. const arrow_pressed = (
  259. ev.keyCode === _converse.keycodes.UP_ARROW ||
  260. ev.keyCode === _converse.keycodes.DOWN_ARROW
  261. );
  262. if (!this.auto_completing || (this.selected && arrow_pressed)) {
  263. return;
  264. }
  265. const list = typeof this._list === "function" ? this._list() : this._list;
  266. if (list.length === 0) {
  267. return;
  268. }
  269. let value = this.match_current_word ? u.getCurrentWord(this.input) : this.input.value;
  270. let ignore_min_chars = false;
  271. if (this.trigger_on_at && value.startsWith('@')) {
  272. ignore_min_chars = true;
  273. value = value.slice('1');
  274. }
  275. if ((value.length >= this.min_chars) || ignore_min_chars) {
  276. this.index = -1;
  277. // Populate list with options that match
  278. this.ul.innerHTML = "";
  279. this.suggestions = list
  280. .map(item => new Suggestion(this.data(item, value)))
  281. .filter(item => this.filter(item, value));
  282. if (this.sort !== false) {
  283. this.suggestions = this.suggestions.sort(this.sort);
  284. }
  285. this.suggestions = this.suggestions.slice(0, this.max_items);
  286. this.suggestions.forEach((text) => this.ul.appendChild(this.item(text, value)));
  287. if (this.ul.children.length === 0) {
  288. this.close({'reason': 'nomatches'});
  289. } else {
  290. this.open();
  291. }
  292. } else {
  293. this.close({'reason': 'nomatches'});
  294. this.auto_completing = false;
  295. }
  296. }
  297. }
  298. // Make it an event emitter
  299. _.extend(AutoComplete.prototype, Backbone.Events);
  300. // Private functions
  301. function Suggestion(data) {
  302. const o = Array.isArray(data)
  303. ? { label: data[0], value: data[1] }
  304. : typeof data === "object" && "label" in data && "value" in data ? data : { label: data, value: data };
  305. this.label = o.label || o.value;
  306. this.value = o.value;
  307. }
  308. Object.defineProperty(Suggestion.prototype = Object.create(String.prototype), "length", {
  309. get: function() { return this.label.length; }
  310. });
  311. Suggestion.prototype.toString = Suggestion.prototype.valueOf = function () {
  312. return "" + this.label;
  313. };
  314. // Helpers
  315. var slice = Array.prototype.slice;
  316. const helpers = {
  317. getElement (expr, el) {
  318. return typeof expr === "string"? (el || document).querySelector(expr) : expr || null;
  319. },
  320. bind (element, o) {
  321. if (element) {
  322. for (var event in o) {
  323. if (!Object.prototype.hasOwnProperty.call(o, event)) {
  324. continue;
  325. }
  326. const callback = o[event];
  327. event.split(/\s+/).forEach(event => element.addEventListener(event, callback));
  328. }
  329. }
  330. },
  331. unbind (element, o) {
  332. if (element) {
  333. for (var event in o) {
  334. if (!Object.prototype.hasOwnProperty.call(o, event)) {
  335. continue;
  336. }
  337. const callback = o[event];
  338. event.split(/\s+/).forEach(event => element.removeEventListener(event, callback));
  339. }
  340. }
  341. },
  342. regExpEscape (s) {
  343. return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
  344. }
  345. }
  346. _converse.AutoComplete = AutoComplete;
  347. }
  348. });