@itrocks/autocomplete 0.0.1 → 0.0.2

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,25 @@
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 > * {
13
+ cursor: pointer;
14
+ list-style: none;
15
+ padding: 0.5em 1em;
16
+ }
17
+ .combobox .suggestions > *:hover {
18
+ background: #efe;
19
+ }
20
+ .combobox .suggestions > *.selected {
21
+ background: #edf;
22
+ }
23
+ .combobox .suggestions > *.selected:hover {
24
+ background: #dce;
25
+ }
package/autocomplete.d.ts CHANGED
@@ -1,32 +1,46 @@
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
+ list?: HTMLUListElement;
20
30
  constructor(combo: AutoComplete);
21
- first(): {
22
- caption: string;
23
- id: number;
24
- };
31
+ createList(): HTMLUListElement;
32
+ first(): Item | null;
25
33
  hide(): void;
26
- show(): void;
27
- update(suggestions: {
28
- caption: string;
29
- id: number;
30
- }[]): void;
34
+ isFirstSelected(): boolean | null | undefined;
35
+ isLastSelected(): boolean | null | undefined;
36
+ isVisible(): boolean | undefined;
37
+ removeList(): void;
38
+ selected(item?: HTMLLIElement | null): Item | null;
39
+ selectFirst(): void;
40
+ selectNext(): Item | null;
41
+ selectPrevious(): Item | null;
42
+ selectSibling(sibling: 'nextElementSibling' | 'previousElementSibling'): HTMLLIElement | null;
43
+ show(): HTMLUListElement;
44
+ update(suggestions: Item[]): void;
31
45
  }
32
46
  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,43 +62,117 @@ 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.isLastSelected()) {
87
+ return;
88
+ }
89
+ event.preventDefault();
90
+ if (!suggestions.isVisible()) {
91
+ suggestions.show();
68
92
  return;
69
- input.value = input.selectedValue;
93
+ }
94
+ this.suggest(suggestions.selectNext()?.caption);
95
+ }
96
+ keyEnter(event) {
97
+ const suggestions = this.suggestions;
98
+ if (!suggestions.isVisible()) {
99
+ return;
100
+ }
101
+ event.preventDefault();
102
+ const suggestion = suggestions.selected();
103
+ if (!suggestion) {
104
+ return;
105
+ }
106
+ this.input.value = suggestion.caption;
107
+ this.onInputValueChange();
108
+ this.autoIdInputValue();
109
+ suggestions.hide();
110
+ }
111
+ keyEscape(event) {
112
+ const suggestions = this.suggestions;
113
+ if ((this.input.value === '') && !suggestions.isVisible()) {
114
+ return;
115
+ }
116
+ event.preventDefault();
117
+ if (suggestions.isVisible()) {
118
+ suggestions.hide();
119
+ return;
120
+ }
121
+ this.input.value = '';
122
+ this.onInputValueChange();
123
+ this.autoIdInputValue();
124
+ }
125
+ keyUp(event) {
126
+ const suggestions = this.suggestions;
127
+ if (!suggestions.isVisible()) {
128
+ return;
129
+ }
130
+ event.preventDefault();
131
+ if (suggestions.isFirstSelected()) {
132
+ suggestions.hide();
133
+ return;
134
+ }
135
+ this.suggest(suggestions.selectPrevious()?.caption);
136
+ }
137
+ onBlur(_event) {
138
+ setTimeout(() => this.suggestions.removeList());
139
+ }
140
+ onInput(_event) {
141
+ if (this.input.dataset.lastValue === this.input.value) {
142
+ return;
143
+ }
144
+ this.fetch();
145
+ }
146
+ onInputValueChange() {
147
+ this.input.dataset.lastValue = this.input.value;
148
+ this.autoEmptyClass();
70
149
  }
71
150
  onKeyDown(event) {
151
+ this.lastKey = event.key;
72
152
  switch (event.key) {
73
153
  case 'ArrowDown':
74
154
  case 'Down':
75
- return this.suggestions.show();
155
+ return this.keyDown(event);
76
156
  case 'ArrowUp':
77
157
  case 'Up':
78
- return this.suggestions.hide();
158
+ return this.keyUp(event);
159
+ case 'Escape':
160
+ return this.keyEscape(event);
161
+ case 'Enter':
162
+ return this.keyEnter(event);
79
163
  }
80
164
  }
81
- onInput(_event) {
82
- if (this.input.lastValue === this.input.value) {
165
+ suggest(value) {
166
+ if (typeof value !== 'string') {
83
167
  return;
84
168
  }
85
- this.fetch();
169
+ const input = this.input;
170
+ const position = input.selectionStart;
171
+ input.value = value;
172
+ input.setSelectionRange(position, input.value.length);
173
+ this.autoComplete();
174
+ this.onInputValueChange();
175
+ this.autoIdInputValue();
86
176
  }
87
177
  }
88
178
  class Suggestions {
@@ -90,30 +180,101 @@ class Suggestions {
90
180
  list;
91
181
  constructor(combo) {
92
182
  this.combo = combo;
183
+ }
184
+ createList() {
93
185
  const list = this.list = document.createElement('ul');
94
186
  list.classList.add('suggestions');
95
- list.style.display = 'none';
96
- combo.input.insertAdjacentElement('afterend', list);
187
+ let input = this.combo.input;
188
+ const idInput = input.nextElementSibling;
189
+ if ((idInput instanceof HTMLInputElement) && (idInput.type === 'hidden')) {
190
+ input = idInput;
191
+ }
192
+ input.insertAdjacentElement('afterend', list);
193
+ return list;
97
194
  }
98
195
  first() {
99
- const item = this.list.firstElementChild;
100
- return { caption: item.innerText, id: +(item.dataset.id ?? 0) };
196
+ const item = this.list?.firstElementChild ?? null;
197
+ return item && { caption: item.innerText, id: +(item.dataset.id ?? 0) };
101
198
  }
102
199
  hide() {
103
- this.list.style.display = 'none';
200
+ const list = this.list;
201
+ if (!list)
202
+ return;
203
+ list.style.display = 'none';
204
+ }
205
+ isFirstSelected() {
206
+ return this.list
207
+ && this.list.firstElementChild
208
+ && (this.list.firstElementChild === this.list.querySelector('li.selected'));
209
+ }
210
+ isLastSelected() {
211
+ return this.list
212
+ && this.list.lastElementChild
213
+ && (this.list.lastElementChild === this.list.querySelector('li.selected'));
214
+ }
215
+ isVisible() {
216
+ return this.list && (this.list.style.display !== 'none');
217
+ }
218
+ removeList() {
219
+ this.list?.remove();
220
+ this.list = undefined;
221
+ }
222
+ selected(item = null) {
223
+ item ??= this.list?.querySelector('li.selected') ?? null;
224
+ return item && { caption: item.innerText, id: +(item.dataset.id ?? 0) };
225
+ }
226
+ selectFirst() {
227
+ const list = this.list;
228
+ if (!list)
229
+ return;
230
+ list.querySelector('li.selected')?.classList.remove('selected');
231
+ list.firstElementChild?.classList.add('selected');
232
+ }
233
+ selectNext() {
234
+ return this.selected(this.selectSibling('nextElementSibling'));
235
+ }
236
+ selectPrevious() {
237
+ return this.selected(this.selectSibling('previousElementSibling'));
238
+ }
239
+ selectSibling(sibling) {
240
+ const list = this.list;
241
+ if (!list)
242
+ return null;
243
+ let item = list.querySelector('li.selected');
244
+ if (item && item[sibling]) {
245
+ item.classList.remove('selected');
246
+ item = item[sibling];
247
+ item.classList.add('selected');
248
+ }
249
+ return item;
104
250
  }
105
251
  show() {
106
- this.list.style.removeProperty('display');
252
+ if (this.list) {
253
+ this.list.style.removeProperty('display');
254
+ return this.list;
255
+ }
256
+ return this.createList();
107
257
  }
108
258
  update(suggestions) {
109
- const list = this.list;
259
+ if (!suggestions.length) {
260
+ return this.hide();
261
+ }
262
+ let hasSelected = false;
263
+ const list = this.show();
264
+ const selected = list.querySelector('li.selected')?.innerText;
110
265
  list.innerHTML = '';
111
266
  for (const suggestion of suggestions) {
112
267
  const item = document.createElement('li');
113
268
  item.dataset.id = '' + suggestion.id;
114
269
  item.innerText = suggestion.caption;
270
+ if (suggestion.caption === selected) {
271
+ hasSelected = true;
272
+ item.classList.add('selected');
273
+ }
115
274
  list.appendChild(item);
116
275
  }
117
- list.firstElementChild?.classList.add('selected');
276
+ if (!hasSelected) {
277
+ list.firstElementChild?.classList.add('selected');
278
+ }
118
279
  }
119
280
  }
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.2"
42
44
  }