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