@itrocks/autocomplete 0.1.6 → 0.1.9

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;
@@ -154,6 +197,8 @@ export class AutoComplete {
154
197
  if (DEBUG)
155
198
  console.log('input.value =');
156
199
  this.input.value = '';
200
+ this.lastStart = '';
201
+ this.suggestions.removeList();
157
202
  this.onInputValueChange();
158
203
  this.autoIdInputValue();
159
204
  }
@@ -174,7 +219,20 @@ export class AutoComplete {
174
219
  onBlur(_event) {
175
220
  if (DEBUG)
176
221
  console.log('onBlur()');
222
+ const suggestion = this.suggestions.selected();
177
223
  this.suggestions.removeList();
224
+ if (suggestion
225
+ && this.idInput?.value
226
+ && (this.input.value !== suggestion.caption)
227
+ && (toInsensitive(this.input.value) === toInsensitive(suggestion.caption))) {
228
+ this.input.value = suggestion.caption;
229
+ this.onInputValueChange();
230
+ }
231
+ else if (!this.options.allowNew && this.idInput && this.input.value && !this.idInput.value) {
232
+ this.input.value = '';
233
+ this.onInputValueChange();
234
+ this.autoIdInputValue();
235
+ }
178
236
  }
179
237
  onInput(event) {
180
238
  if (DEBUG)
@@ -183,7 +241,7 @@ export class AutoComplete {
183
241
  return;
184
242
  if (this.input.dataset.lastValue === this.input.value)
185
243
  return;
186
- this.fetch();
244
+ this.fetch(this.lastKey);
187
245
  }
188
246
  onInputValueChange() {
189
247
  if (DEBUG)
@@ -199,8 +257,10 @@ export class AutoComplete {
199
257
  }
200
258
  onKeyDown(event) {
201
259
  if (DEBUG)
202
- console.log('onKeyDown()', event.key);
260
+ console.log('########## onKeyDown()', event.key);
203
261
  this.lastKey = event.key;
262
+ if (this.consumeSelectedChar(event))
263
+ return;
204
264
  switch (event.key) {
205
265
  case 'ArrowDown':
206
266
  case 'Down':
@@ -218,14 +278,22 @@ export class AutoComplete {
218
278
  this.openSuggestions(event);
219
279
  }
220
280
  openSuggestions(event) {
281
+ if (DEBUG)
282
+ console.log('openSuggestions()');
221
283
  const suggestions = this.suggestions;
222
284
  if (!suggestions.length) {
285
+ if (DEBUG)
286
+ console.log('OS: no suggestions => fetch');
223
287
  this.fetch();
224
288
  }
225
289
  if (suggestions.isVisible()) {
290
+ if (DEBUG)
291
+ console.log('OS: isVisible is true => return');
226
292
  return false;
227
293
  }
228
294
  if ((suggestions.length > 1) || (!this.input.value.length && suggestions.length)) {
295
+ if (DEBUG)
296
+ console.log('OS: has items => show');
229
297
  event.preventDefault();
230
298
  suggestions.show();
231
299
  }
@@ -260,11 +328,19 @@ export class AutoComplete {
260
328
  this.onInputValueChange();
261
329
  this.autoIdInputValue();
262
330
  }
331
+ updateSuggestions(items, startsWith = '') {
332
+ startsWith = toInsensitive(startsWith);
333
+ const suggestions = startsWith.length
334
+ ? items.filter(item => (item.caption === '...') || toInsensitive(item.caption).startsWith(startsWith))
335
+ : items;
336
+ this.suggestions.update(suggestions);
337
+ }
263
338
  }
264
339
  class Suggestions {
265
340
  autoComplete;
266
341
  length = 0;
267
342
  list;
343
+ partial = false;
268
344
  pointerStart;
269
345
  constructor(autoComplete) {
270
346
  this.autoComplete = autoComplete;
@@ -331,6 +407,13 @@ class Suggestions {
331
407
  item.classList.add('selected');
332
408
  this.autoComplete.select();
333
409
  this.pointerStart = undefined;
410
+ const input = this.autoComplete.input;
411
+ if (DEBUG)
412
+ console.log('down: focus+setSelectionRange', input.value);
413
+ setTimeout(() => {
414
+ input.focus();
415
+ input.setSelectionRange(0, input.value.length);
416
+ });
334
417
  }
335
418
  onPointerCancel(_event) {
336
419
  this.pointerStart = undefined;
@@ -350,6 +433,13 @@ class Suggestions {
350
433
  this.pointerStart.item.classList.add('selected');
351
434
  this.autoComplete.select();
352
435
  this.pointerStart = undefined;
436
+ const input = this.autoComplete.input;
437
+ if (DEBUG)
438
+ console.log('up: focus+setSelectionRange', input.value);
439
+ setTimeout(() => {
440
+ input.focus();
441
+ input.setSelectionRange(0, input.value.length);
442
+ });
353
443
  }
354
444
  removeList() {
355
445
  if (DEBUG)
@@ -364,7 +454,7 @@ class Suggestions {
364
454
  item ??= this.list?.querySelector('li.selected') ?? null;
365
455
  if (DEBUG)
366
456
  console.log('selected()', item && { caption: item.innerText, id: +(item.dataset.id ?? 0) });
367
- return item && { caption: item.innerText, id: +(item.dataset.id ?? 0) };
457
+ return item && { caption: item.innerText === '...' ? '' : item.innerText, id: +(item.dataset.id ?? 0) };
368
458
  }
369
459
  selectFirst() {
370
460
  if (DEBUG)
@@ -396,6 +486,7 @@ class Suggestions {
396
486
  this.unselect(item);
397
487
  item = item[sibling];
398
488
  item.classList.add('selected');
489
+ item.scrollIntoView({ block: 'nearest' });
399
490
  }
400
491
  if (DEBUG)
401
492
  console.log(' ', item);
@@ -428,12 +519,13 @@ class Suggestions {
428
519
  }
429
520
  update(suggestions) {
430
521
  if (DEBUG)
431
- console.log('update()');
522
+ console.log('update()', suggestions);
432
523
  let hasSelected = false;
433
524
  const list = this.list ?? this.createList();
434
525
  const selected = list.querySelector('li.selected')?.innerText;
435
526
  list.innerHTML = '';
436
527
  this.length = suggestions.length;
528
+ this.partial = false;
437
529
  for (const suggestion of suggestions) {
438
530
  const item = document.createElement('li');
439
531
  item.dataset.id = '' + suggestion.id;
@@ -443,7 +535,14 @@ class Suggestions {
443
535
  item.classList.add('selected');
444
536
  }
445
537
  list.appendChild(item);
538
+ if (!suggestion.id && (suggestion.caption === '...')) {
539
+ this.partial = true;
540
+ }
446
541
  }
542
+ if (DEBUG)
543
+ console.log('- length =', this.length);
544
+ if (DEBUG)
545
+ console.log('- partial =', this.partial ? 'true' : 'false');
447
546
  if (!hasSelected) {
448
547
  list.firstElementChild?.classList.add('selected');
449
548
  }
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.9"
44
44
  }