@itrocks/autocomplete 0.1.6 → 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,6 +2,9 @@
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;
7
10
  user-select: none;
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,11 +37,13 @@ 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
43
  autoComplete: AutoComplete;
34
44
  length: number;
35
45
  list?: HTMLUListElement;
46
+ partial: boolean;
36
47
  pointerStart?: {
37
48
  id: number;
38
49
  item: HTMLLIElement;
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;
86
+ const input = this.input;
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 = '') {
70
102
  const input = this.input;
71
- const inputValue = ((input.selectionStart !== null) && (input.selectionEnd === input.value.length))
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 => {
88
- 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(() => {
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(() => {
101
129
  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;
@@ -175,6 +218,11 @@ export class AutoComplete {
175
218
  if (DEBUG)
176
219
  console.log('onBlur()');
177
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
+ }
178
226
  }
179
227
  onInput(event) {
180
228
  if (DEBUG)
@@ -183,7 +231,7 @@ export class AutoComplete {
183
231
  return;
184
232
  if (this.input.dataset.lastValue === this.input.value)
185
233
  return;
186
- this.fetch();
234
+ this.fetch(this.lastKey);
187
235
  }
188
236
  onInputValueChange() {
189
237
  if (DEBUG)
@@ -199,8 +247,10 @@ export class AutoComplete {
199
247
  }
200
248
  onKeyDown(event) {
201
249
  if (DEBUG)
202
- console.log('onKeyDown()', event.key);
250
+ console.log('########## onKeyDown()', event.key);
203
251
  this.lastKey = event.key;
252
+ if (this.consumeSelectedChar(event))
253
+ return;
204
254
  switch (event.key) {
205
255
  case 'ArrowDown':
206
256
  case 'Down':
@@ -218,14 +268,22 @@ export class AutoComplete {
218
268
  this.openSuggestions(event);
219
269
  }
220
270
  openSuggestions(event) {
271
+ if (DEBUG)
272
+ console.log('openSuggestions()');
221
273
  const suggestions = this.suggestions;
222
274
  if (!suggestions.length) {
275
+ if (DEBUG)
276
+ console.log('OS: no suggestions => fetch');
223
277
  this.fetch();
224
278
  }
225
279
  if (suggestions.isVisible()) {
280
+ if (DEBUG)
281
+ console.log('OS: isVisible is true => return');
226
282
  return false;
227
283
  }
228
284
  if ((suggestions.length > 1) || (!this.input.value.length && suggestions.length)) {
285
+ if (DEBUG)
286
+ console.log('OS: has items => show');
229
287
  event.preventDefault();
230
288
  suggestions.show();
231
289
  }
@@ -260,11 +318,19 @@ 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
330
  autoComplete;
266
331
  length = 0;
267
332
  list;
333
+ partial = false;
268
334
  pointerStart;
269
335
  constructor(autoComplete) {
270
336
  this.autoComplete = autoComplete;
@@ -331,6 +397,13 @@ class Suggestions {
331
397
  item.classList.add('selected');
332
398
  this.autoComplete.select();
333
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
+ });
334
407
  }
335
408
  onPointerCancel(_event) {
336
409
  this.pointerStart = undefined;
@@ -350,6 +423,13 @@ class Suggestions {
350
423
  this.pointerStart.item.classList.add('selected');
351
424
  this.autoComplete.select();
352
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
+ });
353
433
  }
354
434
  removeList() {
355
435
  if (DEBUG)
@@ -364,7 +444,7 @@ class Suggestions {
364
444
  item ??= this.list?.querySelector('li.selected') ?? null;
365
445
  if (DEBUG)
366
446
  console.log('selected()', item && { caption: item.innerText, id: +(item.dataset.id ?? 0) });
367
- return item && { caption: item.innerText, id: +(item.dataset.id ?? 0) };
447
+ return item && { caption: item.innerText === '...' ? '' : item.innerText, id: +(item.dataset.id ?? 0) };
368
448
  }
369
449
  selectFirst() {
370
450
  if (DEBUG)
@@ -396,6 +476,7 @@ class Suggestions {
396
476
  this.unselect(item);
397
477
  item = item[sibling];
398
478
  item.classList.add('selected');
479
+ item.scrollIntoView({ block: 'nearest' });
399
480
  }
400
481
  if (DEBUG)
401
482
  console.log(' ', item);
@@ -428,12 +509,13 @@ class Suggestions {
428
509
  }
429
510
  update(suggestions) {
430
511
  if (DEBUG)
431
- console.log('update()');
512
+ console.log('update()', suggestions);
432
513
  let hasSelected = false;
433
514
  const list = this.list ?? this.createList();
434
515
  const selected = list.querySelector('li.selected')?.innerText;
435
516
  list.innerHTML = '';
436
517
  this.length = suggestions.length;
518
+ this.partial = false;
437
519
  for (const suggestion of suggestions) {
438
520
  const item = document.createElement('li');
439
521
  item.dataset.id = '' + suggestion.id;
@@ -443,7 +525,14 @@ class Suggestions {
443
525
  item.classList.add('selected');
444
526
  }
445
527
  list.appendChild(item);
528
+ if (!suggestion.id && (suggestion.caption === '...')) {
529
+ this.partial = true;
530
+ }
446
531
  }
532
+ if (DEBUG)
533
+ console.log('- length =', this.length);
534
+ if (DEBUG)
535
+ console.log('- partial =', this.partial ? 'true' : 'false');
447
536
  if (!hasSelected) {
448
537
  list.firstElementChild?.classList.add('selected');
449
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.6"
43
+ "version": "0.1.7"
44
44
  }