awesomplete.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. /**
  2. * Simple, lightweight, usable local autocomplete library for modern browsers
  3. * Because there weren’t enough autocomplete scripts in the world? Because I’m completely insane and have NIH syndrome? Probably both. :P
  4. * @author Lea Verou http://leaverou.github.io/awesomplete
  5. * MIT license
  6. */
  7. (function () {
  8. var _ = function (input, o) {
  9. var me = this;
  10. // Setup
  11. this.isOpened = false;
  12. this.input = $(input);
  13. this.input.setAttribute("autocomplete", "off");
  14. this.input.setAttribute("aria-autocomplete", "list");
  15. o = o || {};
  16. configure(this, {
  17. minChars: 2,
  18. maxItems: 10,
  19. autoFirst: false,
  20. data: _.DATA,
  21. filter: _.FILTER_CONTAINS,
  22. sort: _.SORT_BYLENGTH,
  23. item: _.ITEM,
  24. replace: _.REPLACE
  25. }, o);
  26. this.index = -1;
  27. // Create necessary elements
  28. this.container = $.create("div", {
  29. className: "awesomplete",
  30. around: input
  31. });
  32. this.ul = $.create("ul", {
  33. hidden: "hidden",
  34. inside: this.container
  35. });
  36. this.status = $.create("span", {
  37. className: "visually-hidden",
  38. role: "status",
  39. "aria-live": "assertive",
  40. "aria-relevant": "additions",
  41. inside: this.container
  42. });
  43. // Bind events
  44. $.bind(this.input, {
  45. "input": this.evaluate.bind(this),
  46. "blur": this.close.bind(this, { reason: "blur" }),
  47. "keydown": function(evt) {
  48. var c = evt.keyCode;
  49. // If the dropdown `ul` is in view, then act on keydown for the following keys:
  50. // Enter / Esc / Up / Down
  51. if(me.opened) {
  52. if (c === 13 && me.selected) { // Enter
  53. evt.preventDefault();
  54. me.select();
  55. }
  56. else if (c === 27) { // Esc
  57. me.close({ reason: "esc" });
  58. }
  59. else if (c === 38 || c === 40) { // Down/Up arrow
  60. evt.preventDefault();
  61. me[c === 38? "previous" : "next"]();
  62. }
  63. }
  64. }
  65. });
  66. $.bind(this.input.form, {"submit": this.close.bind(this, { reason: "submit" })});
  67. $.bind(this.ul, {"mousedown": function(evt) {
  68. var li = evt.target;
  69. if (li !== this) {
  70. while (li && !/li/i.test(li.nodeName)) {
  71. li = li.parentNode;
  72. }
  73. if (li && evt.button === 0) { // Only select on left click
  74. evt.preventDefault();
  75. me.select(li, evt.target);
  76. }
  77. }
  78. }});
  79. if (this.input.hasAttribute("list")) {
  80. this.list = "#" + this.input.getAttribute("list");
  81. this.input.removeAttribute("list");
  82. }
  83. else {
  84. this.list = this.input.getAttribute("data-list") || o.list || [];
  85. }
  86. _.all.push(this);
  87. };
  88. _.prototype = {
  89. set list(list) {
  90. if (Array.isArray(list)) {
  91. this._list = list;
  92. }
  93. else if (typeof list === "string" && list.indexOf(",") > -1) {
  94. this._list = list.split(/\s*,\s*/);
  95. }
  96. else { // Element or CSS selector
  97. list = $(list);
  98. if (list && list.children) {
  99. var items = [];
  100. slice.apply(list.children).forEach(function (el) {
  101. if (!el.disabled) {
  102. var text = el.textContent.trim();
  103. var value = el.value || text;
  104. var label = el.label || text;
  105. if (value !== "") {
  106. items.push({ label: label, value: value });
  107. }
  108. }
  109. });
  110. this._list = items;
  111. }
  112. }
  113. if (document.activeElement === this.input) {
  114. this.evaluate();
  115. }
  116. },
  117. get selected() {
  118. return this.index > -1;
  119. },
  120. get opened() {
  121. return this.isOpened;
  122. },
  123. close: function (o) {
  124. if (!this.opened) {
  125. return;
  126. }
  127. this.ul.setAttribute("hidden", "");
  128. this.isOpened = false;
  129. this.index = -1;
  130. $.fire(this.input, "awesomplete-close", o || {});
  131. },
  132. open: function () {
  133. this.ul.removeAttribute("hidden");
  134. this.isOpened = true;
  135. if (this.autoFirst && this.index === -1) {
  136. this.goto(0);
  137. }
  138. $.fire(this.input, "awesomplete-open");
  139. },
  140. next: function () {
  141. var count = this.ul.children.length;
  142. this.goto(this.index < count - 1 ? this.index + 1 : (count ? 0 : -1) );
  143. },
  144. previous: function () {
  145. var count = this.ul.children.length;
  146. var pos = this.index - 1;
  147. this.goto(this.selected && pos !== -1 ? pos : count - 1);
  148. },
  149. // Should not be used, highlights specific item without any checks!
  150. goto: function (i) {
  151. var lis = this.ul.children;
  152. if (this.selected) {
  153. lis[this.index].setAttribute("aria-selected", "false");
  154. }
  155. this.index = i;
  156. if (i > -1 && lis.length > 0) {
  157. lis[i].setAttribute("aria-selected", "true");
  158. this.status.textContent = lis[i].textContent;
  159. // scroll to highlighted element in case parent's height is fixed
  160. this.ul.scrollTop = lis[i].offsetTop - this.ul.clientHeight + lis[i].clientHeight;
  161. $.fire(this.input, "awesomplete-highlight", {
  162. text: this.suggestions[this.index]
  163. });
  164. }
  165. },
  166. select: function (selected, origin) {
  167. if (selected) {
  168. this.index = $.siblingIndex(selected);
  169. } else {
  170. selected = this.ul.children[this.index];
  171. }
  172. if (selected) {
  173. var suggestion = this.suggestions[this.index];
  174. var allowed = $.fire(this.input, "awesomplete-select", {
  175. text: suggestion,
  176. origin: origin || selected
  177. });
  178. if (allowed) {
  179. this.replace(suggestion);
  180. this.close({ reason: "select" });
  181. $.fire(this.input, "awesomplete-selectcomplete", {
  182. text: suggestion
  183. });
  184. }
  185. }
  186. },
  187. evaluate: function() {
  188. var me = this;
  189. var value = this.input.value;
  190. if (value.length >= this.minChars && this._list.length > 0) {
  191. this.index = -1;
  192. // Populate list with options that match
  193. this.ul.innerHTML = "";
  194. this.suggestions = this._list
  195. .map(function(item) {
  196. return new Suggestion(me.data(item, value));
  197. })
  198. .filter(function(item) {
  199. return me.filter(item, value);
  200. })
  201. .sort(this.sort)
  202. .slice(0, this.maxItems);
  203. this.suggestions.forEach(function(text) {
  204. me.ul.appendChild(me.item(text, value));
  205. });
  206. if (this.ul.children.length === 0) {
  207. this.close({ reason: "nomatches" });
  208. } else {
  209. this.open();
  210. }
  211. }
  212. else {
  213. this.close({ reason: "nomatches" });
  214. }
  215. }
  216. };
  217. // Static methods/properties
  218. _.all = [];
  219. _.FILTER_CONTAINS = function (text, input) {
  220. return RegExp($.regExpEscape(input.trim()), "i").test(text);
  221. };
  222. _.FILTER_STARTSWITH = function (text, input) {
  223. return RegExp("^" + $.regExpEscape(input.trim()), "i").test(text);
  224. };
  225. _.SORT_BYLENGTH = function (a, b) {
  226. if (a.length !== b.length) {
  227. return a.length - b.length;
  228. }
  229. return a < b? -1 : 1;
  230. };
  231. _.ITEM = function (text, input) {
  232. var html = input.trim() === '' ? text : text.replace(RegExp($.regExpEscape(input.trim()), "gi"), "<mark>$&</mark>");
  233. return $.create("li", {
  234. innerHTML: html,
  235. "aria-selected": "false"
  236. });
  237. };
  238. _.REPLACE = function (text) {
  239. this.input.value = text.value;
  240. };
  241. _.DATA = function (item/*, input*/) { return item; };
  242. // Private functions
  243. function Suggestion(data) {
  244. var o = Array.isArray(data)
  245. ? { label: data[0], value: data[1] }
  246. : typeof data === "object" && "label" in data && "value" in data ? data : { label: data, value: data };
  247. this.label = o.label || o.value;
  248. this.value = o.value;
  249. }
  250. Object.defineProperty(Suggestion.prototype = Object.create(String.prototype), "length", {
  251. get: function() { return this.label.length; }
  252. });
  253. Suggestion.prototype.toString = Suggestion.prototype.valueOf = function () {
  254. return "" + this.label;
  255. };
  256. function configure(instance, properties, o) {
  257. for (var i in properties) {
  258. var initial = properties[i],
  259. attrValue = instance.input.getAttribute("data-" + i.toLowerCase());
  260. if (typeof initial === "number") {
  261. instance[i] = parseInt(attrValue);
  262. }
  263. else if (initial === false) { // Boolean options must be false by default anyway
  264. instance[i] = attrValue !== null;
  265. }
  266. else if (initial instanceof Function) {
  267. instance[i] = null;
  268. }
  269. else {
  270. instance[i] = attrValue;
  271. }
  272. if (!instance[i] && instance[i] !== 0) {
  273. instance[i] = (i in o)? o[i] : initial;
  274. }
  275. }
  276. }
  277. // Helpers
  278. var slice = Array.prototype.slice;
  279. function $(expr, con) {
  280. return typeof expr === "string"? (con || document).querySelector(expr) : expr || null;
  281. }
  282. function $$(expr, con) {
  283. return slice.call((con || document).querySelectorAll(expr));
  284. }
  285. $.create = function(tag, o) {
  286. var element = document.createElement(tag);
  287. for (var i in o) {
  288. var val = o[i];
  289. if (i === "inside") {
  290. $(val).appendChild(element);
  291. }
  292. else if (i === "around") {
  293. var ref = $(val);
  294. ref.parentNode.insertBefore(element, ref);
  295. element.appendChild(ref);
  296. }
  297. else if (i in element) {
  298. element[i] = val;
  299. }
  300. else {
  301. element.setAttribute(i, val);
  302. }
  303. }
  304. return element;
  305. };
  306. $.bind = function(element, o) {
  307. if (element) {
  308. for (var event in o) {
  309. var callback = o[event];
  310. event.split(/\s+/).forEach(function (event) {
  311. element.addEventListener(event, callback);
  312. });
  313. }
  314. }
  315. };
  316. $.fire = function(target, type, properties) {
  317. var evt = document.createEvent("HTMLEvents");
  318. evt.initEvent(type, true, true );
  319. for (var j in properties) {
  320. evt[j] = properties[j];
  321. }
  322. return target.dispatchEvent(evt);
  323. };
  324. $.regExpEscape = function (s) {
  325. return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
  326. };
  327. $.siblingIndex = function (el) {
  328. /* eslint-disable no-cond-assign */
  329. for (var i = 0; el = el.previousElementSibling; i++);
  330. return i;
  331. };
  332. // Initialization
  333. function init() {
  334. $$("input.awesomplete").forEach(function (input) {
  335. new _(input);
  336. });
  337. }
  338. // Are we in a browser? Check for Document constructor
  339. if (typeof Document !== "undefined") {
  340. // DOM already loaded?
  341. if (document.readyState !== "loading") {
  342. init();
  343. }
  344. else {
  345. // Wait for it
  346. document.addEventListener("DOMContentLoaded", init);
  347. }
  348. }
  349. _.$ = $;
  350. _.$$ = $$;
  351. // Make sure to export Awesomplete on self when in a browser
  352. if (typeof self !== "undefined") {
  353. self.Awesomplete = _;
  354. }
  355. // Expose Awesomplete as a CJS module
  356. if (typeof module === "object" && module.exports) {
  357. module.exports = _;
  358. }
  359. return _;
  360. }());