@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.
- package/autocomplete.css +29 -0
- package/autocomplete.d.ts +32 -17
- package/autocomplete.js +218 -47
- package/package.json +4 -2
package/autocomplete.css
ADDED
|
@@ -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
|
|
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
|
-
|
|
29
|
+
length: number;
|
|
30
|
+
list?: HTMLUListElement;
|
|
20
31
|
constructor(combo: AutoComplete);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
id: number;
|
|
24
|
-
};
|
|
32
|
+
createList(): HTMLUListElement;
|
|
33
|
+
first(): Item | null;
|
|
25
34
|
hide(): void;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 =
|
|
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,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
|
-
|
|
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.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
|
-
|
|
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.
|
|
158
|
+
return this.keyDown(event);
|
|
76
159
|
case 'ArrowUp':
|
|
77
160
|
case 'Up':
|
|
78
|
-
return this.
|
|
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
|
-
|
|
82
|
-
if (
|
|
168
|
+
suggest(value) {
|
|
169
|
+
if (typeof value !== 'string') {
|
|
83
170
|
return;
|
|
84
171
|
}
|
|
85
|
-
this.
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
43
|
+
"version": "0.0.3"
|
|
42
44
|
}
|