@itrocks/autocomplete 0.0.1 → 0.0.3

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.
@@ -0,0 +1,29 @@
1
+ .combobox input[data-type=object] {
2
+ display: block;
3
+ }
4
+ .combobox .suggestions {
5
+ background: white;
6
+ box-sizing: border-box;
7
+ border: 1px solid #ddd;
8
+ margin-top: -1px;
9
+ padding: 0;
10
+ position: absolute;
11
+ }
12
+ .combobox .suggestions:empty {
13
+ height: 2em;
14
+ width: 50px;
15
+ }
16
+ .combobox .suggestions > * {
17
+ cursor: pointer;
18
+ list-style: none;
19
+ padding: 0.5em 1em;
20
+ }
21
+ .combobox .suggestions > *:hover {
22
+ background: #efe;
23
+ }
24
+ .combobox .suggestions > *.selected {
25
+ background: #edf;
26
+ }
27
+ .combobox .suggestions > *.selected:hover {
28
+ background: #dce;
29
+ }
package/autocomplete.d.ts CHANGED
@@ -1,32 +1,47 @@
1
- interface HTMLAutoCompleteInputElement extends HTMLInputElement {
2
- lastValue: string;
3
- selectedValue?: string;
1
+ interface Item {
2
+ caption: string;
3
+ id: number;
4
4
  }
5
5
  export declare class AutoComplete {
6
6
  idInput?: HTMLInputElement;
7
- input: HTMLAutoCompleteInputElement;
7
+ input: HTMLInputElement;
8
+ lastKey: string;
8
9
  suggestions: Suggestions;
9
10
  constructor(input: HTMLInputElement);
10
- autoIdInput(): void;
11
- emptyClass(): void;
11
+ autoComplete(): void;
12
+ autoEmptyClass(): void;
13
+ autoIdInputValue(): void;
12
14
  fetch(): void;
15
+ initIdInput(): HTMLInputElement | undefined;
16
+ initInput(input: HTMLInputElement): HTMLInputElement;
17
+ keyDown(event: KeyboardEvent): void;
18
+ keyEnter(event: KeyboardEvent): void;
19
+ keyEscape(event: KeyboardEvent): void;
20
+ keyUp(event: KeyboardEvent): void;
13
21
  onBlur(_event: FocusEvent): void;
14
- onKeyDown(event: KeyboardEvent): void;
15
22
  onInput(_event: Event): void;
23
+ onInputValueChange(): void;
24
+ onKeyDown(event: KeyboardEvent): void;
25
+ suggest(value?: string): void;
16
26
  }
17
27
  declare class Suggestions {
18
28
  combo: AutoComplete;
19
- list: HTMLUListElement;
29
+ length: number;
30
+ list?: HTMLUListElement;
20
31
  constructor(combo: AutoComplete);
21
- first(): {
22
- caption: string;
23
- id: number;
24
- };
32
+ createList(): HTMLUListElement;
33
+ first(): Item | null;
25
34
  hide(): void;
26
- show(): void;
27
- update(suggestions: {
28
- caption: string;
29
- id: number;
30
- }[]): void;
35
+ isFirstSelected(): boolean | null | undefined;
36
+ isLastSelected(): boolean | null | undefined;
37
+ isVisible(): boolean | undefined;
38
+ removeList(): void;
39
+ selected(item?: HTMLLIElement | null): Item | null;
40
+ selectFirst(): void;
41
+ selectNext(): Item | null;
42
+ selectPrevious(): Item | null;
43
+ selectSibling(sibling: 'nextElementSibling' | 'previousElementSibling'): HTMLLIElement | null;
44
+ show(): HTMLUListElement;
45
+ update(suggestions: Item[]): void;
31
46
  }
32
47
  export {};
package/autocomplete.js CHANGED
@@ -1,43 +1,59 @@
1
1
  export class AutoComplete {
2
2
  idInput;
3
3
  input;
4
+ lastKey = '';
4
5
  suggestions;
5
6
  constructor(input) {
6
- this.input = Object.assign(input, { lastValue: input.value });
7
+ this.input = this.initInput(input);
8
+ this.idInput = this.initIdInput();
7
9
  this.suggestions = new Suggestions(this);
8
- this.autoIdInput();
9
10
  input.addEventListener('blur', event => this.onBlur(event));
10
11
  input.addEventListener('keydown', event => this.onKeyDown(event));
11
12
  input.addEventListener('input', event => this.onInput(event));
12
13
  }
13
- autoIdInput() {
14
+ autoComplete() {
14
15
  const input = this.input;
15
- const next = input.nextElementSibling;
16
- const prev = input.previousElementSibling;
17
- this.idInput
18
- = ((next instanceof HTMLInputElement) && (next.type === 'hidden')) ? next
19
- : ((prev instanceof HTMLInputElement) && (prev.type === 'hidden')) ? prev
20
- : undefined;
16
+ if (input.selectionStart !== input.value.length) {
17
+ return;
18
+ }
19
+ const suggestion = this.suggestions.selected();
20
+ if (!suggestion) {
21
+ return;
22
+ }
23
+ const caption = suggestion.caption;
24
+ const position = input.value.length;
25
+ if (position >= caption.length) {
26
+ return;
27
+ }
28
+ input.setRangeText(caption.slice(position));
29
+ input.setSelectionRange(position, input.value.length);
21
30
  }
22
- emptyClass() {
31
+ autoEmptyClass() {
23
32
  const input = this.input;
24
33
  const classList = input.classList;
25
- if (!input.value.length) {
34
+ if (input.value === '') {
26
35
  classList.add('empty');
27
- delete input.selectedValue;
28
- if (this.idInput)
29
- this.idInput.value = '';
30
36
  return;
31
37
  }
32
- if (classList.contains('empty')) {
33
- (classList.length > 1)
34
- ? classList.remove('empty')
35
- : input.removeAttribute('class');
38
+ classList.remove('empty');
39
+ if (!classList.length) {
40
+ input.removeAttribute('class');
36
41
  }
37
42
  }
43
+ autoIdInputValue() {
44
+ const idInput = this.idInput;
45
+ if (!idInput)
46
+ return;
47
+ const input = this.input;
48
+ const suggestion = this.suggestions.selected();
49
+ idInput.value = (input.value === suggestion?.caption)
50
+ ? '' + suggestion.id
51
+ : '';
52
+ }
38
53
  fetch() {
39
54
  const input = this.input;
40
55
  const dataFetch = input.dataset.fetch ?? input.closest('[data-fetch]')?.dataset.fetch;
56
+ const lastKey = this.lastKey;
41
57
  const requestInit = { headers: { Accept: 'application/json' } };
42
58
  const summaryRoute = dataFetch + '?startsWith=' + input.value;
43
59
  fetch(summaryRoute, requestInit).then(response => response.text()).then(json => {
@@ -46,74 +62,229 @@ export class AutoComplete {
46
62
  const suggestions = summary.map(([id, caption]) => ({ caption, id: +id }))
47
63
  .filter(item => item.caption.toLowerCase().startsWith(startsWith));
48
64
  this.suggestions.update(suggestions);
49
- const suggestion = this.suggestions.first();
50
- if (!suggestion) {
51
- delete input.selectedValue;
52
- if (this.idInput)
53
- this.idInput.value = '';
54
- return;
65
+ if (!['Backspace', 'Delete'].includes(lastKey)) {
66
+ this.autoComplete();
55
67
  }
56
- const caption = suggestion.caption;
57
- input.selectedValue = caption;
58
- if (this.idInput)
59
- this.idInput.value = '' + suggestion.id;
60
- const position = input.value.length;
61
- input.setRangeText(caption.slice(position));
62
- input.setSelectionRange(position, input.value.length);
68
+ this.onInputValueChange();
69
+ this.autoIdInputValue();
63
70
  });
64
71
  }
65
- onBlur(_event) {
72
+ initIdInput() {
66
73
  const input = this.input;
67
- if (!input.selectedValue)
74
+ const next = input.nextElementSibling;
75
+ const prev = input.previousElementSibling;
76
+ return ((next instanceof HTMLInputElement) && (next.type === 'hidden')) ? next
77
+ : ((prev instanceof HTMLInputElement) && (prev.type === 'hidden')) ? prev
78
+ : undefined;
79
+ }
80
+ initInput(input) {
81
+ input.dataset.lastValue = input.value;
82
+ return input;
83
+ }
84
+ keyDown(event) {
85
+ const suggestions = this.suggestions;
86
+ if (!suggestions.isVisible()) {
87
+ if (suggestions.length > 1) {
88
+ event.preventDefault();
89
+ suggestions.show();
90
+ }
91
+ return;
92
+ }
93
+ event.preventDefault();
94
+ if (suggestions.isLastSelected()) {
68
95
  return;
69
- input.value = input.selectedValue;
96
+ }
97
+ this.suggest(suggestions.selectNext()?.caption);
98
+ }
99
+ keyEnter(event) {
100
+ const suggestions = this.suggestions;
101
+ if (!suggestions.isVisible()) {
102
+ return;
103
+ }
104
+ event.preventDefault();
105
+ const suggestion = suggestions.selected();
106
+ if (!suggestion) {
107
+ return;
108
+ }
109
+ this.input.value = suggestion.caption;
110
+ this.onInputValueChange();
111
+ this.autoIdInputValue();
112
+ suggestions.hide();
113
+ }
114
+ keyEscape(event) {
115
+ const suggestions = this.suggestions;
116
+ if ((this.input.value === '') && !suggestions.isVisible()) {
117
+ return;
118
+ }
119
+ event.preventDefault();
120
+ if (suggestions.isVisible()) {
121
+ suggestions.hide();
122
+ return;
123
+ }
124
+ this.input.value = '';
125
+ this.onInputValueChange();
126
+ this.autoIdInputValue();
127
+ }
128
+ keyUp(event) {
129
+ const suggestions = this.suggestions;
130
+ if (!suggestions.isVisible()) {
131
+ return;
132
+ }
133
+ event.preventDefault();
134
+ if (suggestions.isFirstSelected()) {
135
+ suggestions.hide();
136
+ return;
137
+ }
138
+ this.suggest(suggestions.selectPrevious()?.caption);
139
+ }
140
+ onBlur(_event) {
141
+ setTimeout(() => this.suggestions.removeList());
142
+ }
143
+ onInput(_event) {
144
+ if (this.input.dataset.lastValue === this.input.value) {
145
+ return;
146
+ }
147
+ this.fetch();
148
+ }
149
+ onInputValueChange() {
150
+ this.input.dataset.lastValue = this.input.value;
151
+ this.autoEmptyClass();
70
152
  }
71
153
  onKeyDown(event) {
154
+ this.lastKey = event.key;
72
155
  switch (event.key) {
73
156
  case 'ArrowDown':
74
157
  case 'Down':
75
- return this.suggestions.show();
158
+ return this.keyDown(event);
76
159
  case 'ArrowUp':
77
160
  case 'Up':
78
- return this.suggestions.hide();
161
+ return this.keyUp(event);
162
+ case 'Escape':
163
+ return this.keyEscape(event);
164
+ case 'Enter':
165
+ return this.keyEnter(event);
79
166
  }
80
167
  }
81
- onInput(_event) {
82
- if (this.input.lastValue === this.input.value) {
168
+ suggest(value) {
169
+ if (typeof value !== 'string') {
83
170
  return;
84
171
  }
85
- this.fetch();
172
+ const input = this.input;
173
+ const position = input.selectionStart;
174
+ input.value = value;
175
+ input.setSelectionRange(position, input.value.length);
176
+ this.autoComplete();
177
+ this.onInputValueChange();
178
+ this.autoIdInputValue();
86
179
  }
87
180
  }
88
181
  class Suggestions {
89
182
  combo;
183
+ length = 0;
90
184
  list;
91
185
  constructor(combo) {
92
186
  this.combo = combo;
187
+ }
188
+ createList() {
93
189
  const list = this.list = document.createElement('ul');
94
190
  list.classList.add('suggestions');
95
- list.style.display = 'none';
96
- combo.input.insertAdjacentElement('afterend', list);
191
+ let input = this.combo.input;
192
+ const idInput = input.nextElementSibling;
193
+ if ((idInput instanceof HTMLInputElement) && (idInput.type === 'hidden')) {
194
+ input = idInput;
195
+ }
196
+ input.insertAdjacentElement('afterend', list);
197
+ return list;
97
198
  }
98
199
  first() {
99
- const item = this.list.firstElementChild;
100
- return { caption: item.innerText, id: +(item.dataset.id ?? 0) };
200
+ const item = this.list?.firstElementChild ?? null;
201
+ return item && { caption: item.innerText, id: +(item.dataset.id ?? 0) };
101
202
  }
102
203
  hide() {
103
- this.list.style.display = 'none';
204
+ const list = this.list;
205
+ if (!list)
206
+ return;
207
+ list.style.display = 'none';
208
+ }
209
+ isFirstSelected() {
210
+ return this.list
211
+ && this.list.firstElementChild
212
+ && (this.list.firstElementChild === this.list.querySelector('li.selected'));
213
+ }
214
+ isLastSelected() {
215
+ return this.list
216
+ && this.list.lastElementChild
217
+ && (this.list.lastElementChild === this.list.querySelector('li.selected'));
218
+ }
219
+ isVisible() {
220
+ return this.list && (this.list.style.display !== 'none');
221
+ }
222
+ removeList() {
223
+ this.list?.remove();
224
+ this.list = undefined;
225
+ }
226
+ selected(item = null) {
227
+ item ??= this.list?.querySelector('li.selected') ?? null;
228
+ return item && { caption: item.innerText, id: +(item.dataset.id ?? 0) };
229
+ }
230
+ selectFirst() {
231
+ const list = this.list;
232
+ if (!list)
233
+ return;
234
+ list.querySelector('li.selected')?.classList.remove('selected');
235
+ list.firstElementChild?.classList.add('selected');
236
+ }
237
+ selectNext() {
238
+ return this.selected(this.selectSibling('nextElementSibling'));
239
+ }
240
+ selectPrevious() {
241
+ return this.selected(this.selectSibling('previousElementSibling'));
242
+ }
243
+ selectSibling(sibling) {
244
+ const list = this.list;
245
+ if (!list)
246
+ return null;
247
+ let item = list.querySelector('li.selected');
248
+ if (item && item[sibling]) {
249
+ item.classList.remove('selected');
250
+ item = item[sibling];
251
+ item.classList.add('selected');
252
+ }
253
+ return item;
104
254
  }
105
255
  show() {
106
- this.list.style.removeProperty('display');
256
+ if (this.list) {
257
+ this.list.style.removeProperty('display');
258
+ return this.list;
259
+ }
260
+ return this.createList();
107
261
  }
108
262
  update(suggestions) {
109
- const list = this.list;
263
+ let hasSelected = false;
264
+ const list = this.list ?? this.createList();
265
+ const selected = list.querySelector('li.selected')?.innerText;
110
266
  list.innerHTML = '';
267
+ this.length = suggestions.length;
111
268
  for (const suggestion of suggestions) {
112
269
  const item = document.createElement('li');
113
270
  item.dataset.id = '' + suggestion.id;
114
271
  item.innerText = suggestion.caption;
272
+ if (suggestion.caption === selected) {
273
+ hasSelected = true;
274
+ item.classList.add('selected');
275
+ }
115
276
  list.appendChild(item);
116
277
  }
117
- list.firstElementChild?.classList.add('selected');
278
+ if (!hasSelected) {
279
+ list.firstElementChild?.classList.add('selected');
280
+ }
281
+ if (this.length > 1) {
282
+ if (!this.isVisible())
283
+ this.show();
284
+ }
285
+ else {
286
+ if (this.isVisible())
287
+ this.hide();
288
+ }
118
289
  }
119
290
  }
package/package.json CHANGED
@@ -6,11 +6,13 @@
6
6
  "description": "Editable combobox component featuring smart autocomplete from a list of id-caption pairs for it.rocks",
7
7
  "devDependencies": {
8
8
  "@types/node": "^22.9",
9
+ "sass": "^1.83",
9
10
  "typescript": "5.6"
10
11
  },
11
12
  "files": [
12
13
  "LICENSE",
13
14
  "README.md",
15
+ "*.css",
14
16
  "*.d.ts",
15
17
  "*.js"
16
18
  ],
@@ -34,9 +36,9 @@
34
36
  "url": "git+https://github.com/itrocks-ts/autocomplete.git"
35
37
  },
36
38
  "scripts": {
37
- "build": "tsc"
39
+ "build": "sass --no-source-map .:. && tsc"
38
40
  },
39
41
  "type": "module",
40
42
  "types": "./autocomplete.d.ts",
41
- "version": "0.0.1"
43
+ "version": "0.0.3"
42
44
  }