@itrocks/autocomplete 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/autocomplete.css CHANGED
@@ -2,8 +2,12 @@
2
2
  background: white;
3
3
  border: 1px solid #ddd;
4
4
  margin-top: -1px;
5
+ max-height: 24em;
6
+ overflow-y: auto;
7
+ overflow-x: hidden;
5
8
  padding: 0;
6
9
  position: absolute;
10
+ user-select: none;
7
11
  z-index: 1;
8
12
  }
9
13
  .autocomplete + .suggestions:empty {
package/autocomplete.d.ts CHANGED
@@ -1,3 +1,7 @@
1
+ export interface AutoCompleteOptions {
2
+ allowNew: boolean;
3
+ fetchAttribute: string;
4
+ }
1
5
  interface Item {
2
6
  caption: string;
3
7
  id: number;
@@ -6,13 +10,18 @@ export declare class AutoComplete {
6
10
  fetching?: string;
7
11
  idInput?: HTMLInputElement;
8
12
  input: HTMLInputElement;
13
+ lastItems: Item[];
9
14
  lastKey: string;
15
+ lastStart: string;
16
+ options: AutoCompleteOptions;
10
17
  suggestions: Suggestions;
11
- constructor(input: HTMLInputElement);
18
+ constructor(input: HTMLInputElement, options?: Partial<AutoCompleteOptions>);
12
19
  autoComplete(): void;
13
20
  autoEmptyClass(): void;
14
21
  autoIdInputValue(): void;
15
- fetch(): void;
22
+ consumeSelectedChar(event: KeyboardEvent): boolean;
23
+ fetch(lastKey?: string): void;
24
+ fetchDone(items: Item[] | undefined, startsWith: string, lastKey: string): void;
16
25
  initIdInput(): HTMLInputElement | undefined;
17
26
  initInput(input: HTMLInputElement): HTMLInputElement;
18
27
  initParent(): void;
@@ -28,19 +37,30 @@ export declare class AutoComplete {
28
37
  openSuggestions(event: Event): boolean;
29
38
  select(): void;
30
39
  suggest(value?: string): void;
40
+ updateSuggestions(items: Item[], startsWith?: string): void;
31
41
  }
32
42
  declare class Suggestions {
33
- combo: AutoComplete;
43
+ autoComplete: AutoComplete;
34
44
  length: number;
35
45
  list?: HTMLUListElement;
36
- constructor(combo: AutoComplete);
46
+ partial: boolean;
47
+ pointerStart?: {
48
+ id: number;
49
+ item: HTMLLIElement;
50
+ x: number;
51
+ y: number;
52
+ };
53
+ constructor(autoComplete: AutoComplete);
37
54
  createList(): HTMLUListElement;
38
55
  first(): Item | null;
39
56
  hide(): void;
40
57
  isFirstSelected(): boolean | null | undefined;
41
58
  isLastSelected(): boolean | null | undefined;
42
59
  isVisible(): boolean | undefined;
43
- onPointerDown(event: MouseEvent): void;
60
+ onPointerDown(event: PointerEvent): void;
61
+ onPointerCancel(_event: PointerEvent): void;
62
+ onPointerMove(event: PointerEvent): void;
63
+ onPointerUp(event: PointerEvent): void;
44
64
  removeList(): void;
45
65
  selected(item?: HTMLLIElement | null): Item | null;
46
66
  selectFirst(): void;
package/autocomplete.js CHANGED
@@ -1,15 +1,23 @@
1
1
  const DEBUG = false;
2
+ const defaultOptions = {
3
+ allowNew: false,
4
+ fetchAttribute: 'data-fetch'
5
+ };
2
6
  export class AutoComplete {
3
7
  fetching;
4
8
  idInput;
5
9
  input;
10
+ lastItems = [];
6
11
  lastKey = '';
12
+ lastStart = '';
13
+ options;
7
14
  suggestions;
8
- constructor(input) {
15
+ constructor(input, options = {}) {
9
16
  if (DEBUG)
10
- console.log('new AutoComplete()', input);
17
+ console.log('new AutoComplete()', input, options);
11
18
  this.input = this.initInput(input);
12
19
  this.idInput = this.initIdInput();
20
+ this.options = Object.assign(defaultOptions, options);
13
21
  this.suggestions = new Suggestions(this);
14
22
  this.initParent();
15
23
  input.addEventListener('blur', event => this.onBlur(event));
@@ -47,6 +55,8 @@ export class AutoComplete {
47
55
  classList.add('empty');
48
56
  return;
49
57
  }
58
+ if (DEBUG)
59
+ console.log(' - empty');
50
60
  classList.remove('empty');
51
61
  if (!classList.length) {
52
62
  input.removeAttribute('class');
@@ -61,47 +71,80 @@ export class AutoComplete {
61
71
  const input = this.input;
62
72
  const suggestion = this.suggestions.selected();
63
73
  idInput.value = (suggestion && (toInsensitive(input.value) === toInsensitive(suggestion.caption)))
64
- ? '' + suggestion.id
74
+ ? (suggestion.id ? ('' + suggestion.id) : '')
65
75
  : '';
66
76
  if (DEBUG)
67
77
  console.log(' idInput =', idInput.value);
68
78
  }
69
- fetch() {
79
+ consumeSelectedChar(event) {
80
+ if (event.key.length !== 1)
81
+ return false;
82
+ if (event.ctrlKey || event.altKey || event.metaKey)
83
+ return false;
84
+ if (!this.lastItems[this.lastItems.length - 1]?.id)
85
+ return false;
70
86
  const input = this.input;
71
- const inputValue = ((input.selectionStart !== null) && (input.selectionEnd === input.value.length))
87
+ const start = input.selectionStart;
88
+ const end = input.selectionEnd;
89
+ if ((start === null) || (end === null) || (start === end))
90
+ return false;
91
+ const firstSelectedChar = input.value.slice(start, start + 1);
92
+ if (toInsensitive(event.key) !== toInsensitive(firstSelectedChar))
93
+ return false;
94
+ if (DEBUG)
95
+ console.log('consumeSelectedChar(' + event.key + ')');
96
+ event.preventDefault();
97
+ input.setSelectionRange(start + 1, input.value.length);
98
+ this.fetch(event.key);
99
+ return true;
100
+ }
101
+ fetch(lastKey = '') {
102
+ const input = this.input;
103
+ const startsWith = ((input.selectionStart !== null) && (input.selectionEnd === input.value.length))
72
104
  ? input.value.slice(0, input.selectionStart)
73
105
  : input.value;
106
+ if (DEBUG)
107
+ console.log('fetch() with lastStart =', this.lastStart);
108
+ if (this.lastStart.length && toInsensitive(startsWith).startsWith(this.lastStart) && !this.suggestions.partial) {
109
+ this.fetchDone(undefined, startsWith, lastKey);
110
+ return;
111
+ }
112
+ this.lastStart = toInsensitive(startsWith);
74
113
  if (this.fetching) {
75
- if (inputValue !== this.fetching) {
76
- setTimeout(() => this.fetch(), 50);
114
+ if (startsWith !== this.fetching) {
115
+ setTimeout(() => this.fetch(lastKey), 50);
77
116
  }
78
117
  return;
79
118
  }
80
- this.fetching = inputValue;
81
- const dataFetch = input.dataset.fetch ?? input.closest('[data-fetch]')?.dataset.fetch;
82
- const lastKey = this.lastKey;
119
+ this.fetching = startsWith;
120
+ const fetchAttribute = this.options.fetchAttribute;
121
+ const dataFetch = input.closest('[' + fetchAttribute + ']')?.getAttribute(fetchAttribute);
83
122
  const requestInit = { headers: { Accept: 'application/json' } };
84
- const summaryRoute = dataFetch + (inputValue ? ('?startsWith=' + inputValue) : '');
123
+ const url = dataFetch + '?limit&startsWith=' + startsWith;
85
124
  if (DEBUG)
86
- console.log('fetch()', 'startsWith=' + inputValue);
87
- fetch(summaryRoute, requestInit).then(response => response.text()).then(json => {
125
+ console.log('fetch()', 'limit&startsWith=' + startsWith);
126
+ fetch(url, requestInit).then(response => response.text())
127
+ .then(json => this.fetchDone(JSON.parse(json).map(([id, caption]) => ({ caption, id: +id })), startsWith, lastKey))
128
+ .catch(() => {
88
129
  this.fetching = undefined;
89
- const summary = JSON.parse(json).map(([id, caption]) => ({ caption, id: +id }));
90
- const startsWith = toInsensitive(inputValue);
91
- const suggestions = startsWith.length
92
- ? summary.filter(item => toInsensitive(item.caption).startsWith(startsWith))
93
- : summary;
94
- this.suggestions.update(suggestions);
95
- if (!['Backspace', 'Delete'].includes(lastKey)) {
96
- this.autoComplete();
97
- }
98
- this.onInputValueChange();
99
- this.autoIdInputValue();
100
- }).catch(() => {
101
- this.fetching = undefined;
102
- setTimeout(() => this.fetch(), 100);
130
+ setTimeout(() => this.fetch(lastKey), 100);
103
131
  });
104
132
  }
133
+ fetchDone(items, startsWith, lastKey) {
134
+ if (items) {
135
+ this.lastItems = items;
136
+ }
137
+ else {
138
+ items = this.lastItems;
139
+ }
140
+ this.fetching = undefined;
141
+ this.updateSuggestions(items, startsWith);
142
+ if (!['Backspace', 'Delete'].includes(lastKey)) {
143
+ this.autoComplete();
144
+ }
145
+ this.onInputValueChange();
146
+ this.autoIdInputValue();
147
+ }
105
148
  initIdInput() {
106
149
  const input = this.input;
107
150
  const next = input.nextElementSibling;
@@ -137,6 +180,7 @@ export class AutoComplete {
137
180
  return;
138
181
  event.preventDefault();
139
182
  this.select();
183
+ suggestions.hide();
140
184
  }
141
185
  keyEscape(event) {
142
186
  if (DEBUG)
@@ -174,6 +218,11 @@ export class AutoComplete {
174
218
  if (DEBUG)
175
219
  console.log('onBlur()');
176
220
  this.suggestions.removeList();
221
+ if (!this.options.allowNew && this.idInput && this.input.value && !this.idInput.value) {
222
+ this.input.value = '';
223
+ this.onInputValueChange();
224
+ this.autoIdInputValue();
225
+ }
177
226
  }
178
227
  onInput(event) {
179
228
  if (DEBUG)
@@ -182,7 +231,7 @@ export class AutoComplete {
182
231
  return;
183
232
  if (this.input.dataset.lastValue === this.input.value)
184
233
  return;
185
- this.fetch();
234
+ this.fetch(this.lastKey);
186
235
  }
187
236
  onInputValueChange() {
188
237
  if (DEBUG)
@@ -198,8 +247,10 @@ export class AutoComplete {
198
247
  }
199
248
  onKeyDown(event) {
200
249
  if (DEBUG)
201
- console.log('onKeyDown()', event.key);
250
+ console.log('########## onKeyDown()', event.key);
202
251
  this.lastKey = event.key;
252
+ if (this.consumeSelectedChar(event))
253
+ return;
203
254
  switch (event.key) {
204
255
  case 'ArrowDown':
205
256
  case 'Down':
@@ -217,14 +268,22 @@ export class AutoComplete {
217
268
  this.openSuggestions(event);
218
269
  }
219
270
  openSuggestions(event) {
271
+ if (DEBUG)
272
+ console.log('openSuggestions()');
220
273
  const suggestions = this.suggestions;
221
274
  if (!suggestions.length) {
275
+ if (DEBUG)
276
+ console.log('OS: no suggestions => fetch');
222
277
  this.fetch();
223
278
  }
224
279
  if (suggestions.isVisible()) {
280
+ if (DEBUG)
281
+ console.log('OS: isVisible is true => return');
225
282
  return false;
226
283
  }
227
284
  if ((suggestions.length > 1) || (!this.input.value.length && suggestions.length)) {
285
+ if (DEBUG)
286
+ console.log('OS: has items => show');
228
287
  event.preventDefault();
229
288
  suggestions.show();
230
289
  }
@@ -242,7 +301,6 @@ export class AutoComplete {
242
301
  this.input.value = suggestion.caption;
243
302
  this.onInputValueChange();
244
303
  this.autoIdInputValue();
245
- suggestions.hide();
246
304
  }
247
305
  suggest(value) {
248
306
  if (DEBUG)
@@ -260,21 +318,33 @@ export class AutoComplete {
260
318
  this.onInputValueChange();
261
319
  this.autoIdInputValue();
262
320
  }
321
+ updateSuggestions(items, startsWith = '') {
322
+ startsWith = toInsensitive(startsWith);
323
+ const suggestions = startsWith.length
324
+ ? items.filter(item => (item.caption === '...') || toInsensitive(item.caption).startsWith(startsWith))
325
+ : items;
326
+ this.suggestions.update(suggestions);
327
+ }
263
328
  }
264
329
  class Suggestions {
265
- combo;
330
+ autoComplete;
266
331
  length = 0;
267
332
  list;
268
- constructor(combo) {
269
- this.combo = combo;
333
+ partial = false;
334
+ pointerStart;
335
+ constructor(autoComplete) {
336
+ this.autoComplete = autoComplete;
270
337
  }
271
338
  createList() {
272
339
  if (DEBUG)
273
340
  console.log('createList()');
274
341
  const list = this.list = document.createElement('ul');
275
342
  list.classList.add('suggestions');
276
- this.combo.input.insertAdjacentElement('afterend', list);
343
+ this.autoComplete.input.insertAdjacentElement('afterend', list);
277
344
  list.addEventListener('pointerdown', event => this.onPointerDown(event));
345
+ list.addEventListener('pointercancel', event => this.onPointerCancel(event));
346
+ list.addEventListener('pointermove', event => this.onPointerMove(event));
347
+ list.addEventListener('pointerup', event => this.onPointerUp(event));
278
348
  return list;
279
349
  }
280
350
  first() {
@@ -307,7 +377,7 @@ class Suggestions {
307
377
  onPointerDown(event) {
308
378
  if (DEBUG)
309
379
  console.log('onPointerDown()', event.button);
310
- if (event.button !== 0)
380
+ if ((event.pointerType === 'mouse') && (event.button !== 0))
311
381
  return;
312
382
  if (!(event.target instanceof Element))
313
383
  return;
@@ -319,10 +389,47 @@ class Suggestions {
319
389
  return;
320
390
  if (DEBUG)
321
391
  console.log(' select', item);
392
+ if (event.pointerType !== 'mouse') {
393
+ this.pointerStart = { id: event.pointerId, item, x: event.clientX, y: event.clientY };
394
+ return;
395
+ }
322
396
  this.unselect();
323
397
  item.classList.add('selected');
324
- this.combo.select();
325
- event.preventDefault();
398
+ this.autoComplete.select();
399
+ this.pointerStart = undefined;
400
+ const input = this.autoComplete.input;
401
+ if (DEBUG)
402
+ console.log('down: focus+setSelectionRange', input.value);
403
+ setTimeout(() => {
404
+ input.focus();
405
+ input.setSelectionRange(0, input.value.length);
406
+ });
407
+ }
408
+ onPointerCancel(_event) {
409
+ this.pointerStart = undefined;
410
+ }
411
+ onPointerMove(event) {
412
+ if (!this.pointerStart || (event.pointerId !== this.pointerStart.id))
413
+ return;
414
+ const distance = Math.abs(event.clientX - this.pointerStart.x) + Math.abs(event.clientY - this.pointerStart.y);
415
+ if (distance < 8)
416
+ return;
417
+ this.pointerStart = undefined;
418
+ }
419
+ onPointerUp(event) {
420
+ if (!this.pointerStart || (event.pointerId !== this.pointerStart.id))
421
+ return;
422
+ this.unselect();
423
+ this.pointerStart.item.classList.add('selected');
424
+ this.autoComplete.select();
425
+ this.pointerStart = undefined;
426
+ const input = this.autoComplete.input;
427
+ if (DEBUG)
428
+ console.log('up: focus+setSelectionRange', input.value);
429
+ setTimeout(() => {
430
+ input.focus();
431
+ input.setSelectionRange(0, input.value.length);
432
+ });
326
433
  }
327
434
  removeList() {
328
435
  if (DEBUG)
@@ -337,7 +444,7 @@ class Suggestions {
337
444
  item ??= this.list?.querySelector('li.selected') ?? null;
338
445
  if (DEBUG)
339
446
  console.log('selected()', item && { caption: item.innerText, id: +(item.dataset.id ?? 0) });
340
- return item && { caption: item.innerText, id: +(item.dataset.id ?? 0) };
447
+ return item && { caption: item.innerText === '...' ? '' : item.innerText, id: +(item.dataset.id ?? 0) };
341
448
  }
342
449
  selectFirst() {
343
450
  if (DEBUG)
@@ -369,6 +476,7 @@ class Suggestions {
369
476
  this.unselect(item);
370
477
  item = item[sibling];
371
478
  item.classList.add('selected');
479
+ item.scrollIntoView({ block: 'nearest' });
372
480
  }
373
481
  if (DEBUG)
374
482
  console.log(' ', item);
@@ -401,12 +509,13 @@ class Suggestions {
401
509
  }
402
510
  update(suggestions) {
403
511
  if (DEBUG)
404
- console.log('update()');
512
+ console.log('update()', suggestions);
405
513
  let hasSelected = false;
406
514
  const list = this.list ?? this.createList();
407
515
  const selected = list.querySelector('li.selected')?.innerText;
408
516
  list.innerHTML = '';
409
517
  this.length = suggestions.length;
518
+ this.partial = false;
410
519
  for (const suggestion of suggestions) {
411
520
  const item = document.createElement('li');
412
521
  item.dataset.id = '' + suggestion.id;
@@ -416,7 +525,14 @@ class Suggestions {
416
525
  item.classList.add('selected');
417
526
  }
418
527
  list.appendChild(item);
528
+ if (!suggestion.id && (suggestion.caption === '...')) {
529
+ this.partial = true;
530
+ }
419
531
  }
532
+ if (DEBUG)
533
+ console.log('- length =', this.length);
534
+ if (DEBUG)
535
+ console.log('- partial =', this.partial ? 'true' : 'false');
420
536
  if (!hasSelected) {
421
537
  list.firstElementChild?.classList.add('selected');
422
538
  }
package/package.json CHANGED
@@ -40,5 +40,5 @@
40
40
  },
41
41
  "type": "module",
42
42
  "types": "./autocomplete.d.ts",
43
- "version": "0.1.5"
43
+ "version": "0.1.7"
44
44
  }