@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.
- package/autocomplete.css +25 -0
- package/autocomplete.d.ts +31 -17
- package/autocomplete.js +208 -47
- package/package.json +4 -2
package/autocomplete.css
ADDED
|
@@ -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
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
interface Item {
|
|
2
|
+
caption: string;
|
|
3
|
+
id: number;
|
|
4
4
|
}
|
|
5
5
|
export declare class AutoComplete {
|
|
6
6
|
idInput?: HTMLInputElement;
|
|
7
|
-
input:
|
|
7
|
+
input: HTMLInputElement;
|
|
8
|
+
lastKey: string;
|
|
8
9
|
suggestions: Suggestions;
|
|
9
10
|
constructor(input: HTMLInputElement);
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
29
|
+
list?: HTMLUListElement;
|
|
20
30
|
constructor(combo: AutoComplete);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
id: number;
|
|
24
|
-
};
|
|
31
|
+
createList(): HTMLUListElement;
|
|
32
|
+
first(): Item | null;
|
|
25
33
|
hide(): void;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 =
|
|
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
|
-
|
|
14
|
+
autoComplete() {
|
|
14
15
|
const input = this.input;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
31
|
+
autoEmptyClass() {
|
|
23
32
|
const input = this.input;
|
|
24
33
|
const classList = input.classList;
|
|
25
|
-
if (
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
72
|
+
initIdInput() {
|
|
66
73
|
const input = this.input;
|
|
67
|
-
|
|
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
|
-
|
|
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.
|
|
155
|
+
return this.keyDown(event);
|
|
76
156
|
case 'ArrowUp':
|
|
77
157
|
case 'Up':
|
|
78
|
-
return this.
|
|
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
|
-
|
|
82
|
-
if (
|
|
165
|
+
suggest(value) {
|
|
166
|
+
if (typeof value !== 'string') {
|
|
83
167
|
return;
|
|
84
168
|
}
|
|
85
|
-
this.
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
43
|
+
"version": "0.0.2"
|
|
42
44
|
}
|