@duskmoon-dev/el-autocomplete 0.4.0

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,420 @@
1
+ // src/el-dm-autocomplete.ts
2
+ import { BaseElement, css as cssTag } from "@duskmoon-dev/el-core";
3
+ import { css } from "@duskmoon-dev/core/components/autocomplete";
4
+ var strippedCss = css.replace(/@layer\s+components\s*\{/, "").replace(/\}[\s]*$/, "");
5
+ var styles = cssTag`
6
+ ${strippedCss}
7
+
8
+ :host {
9
+ display: block;
10
+ }
11
+
12
+ :host([hidden]) {
13
+ display: none;
14
+ }
15
+
16
+ .autocomplete {
17
+ width: 100%;
18
+ }
19
+ `;
20
+
21
+ class ElDmAutocomplete extends BaseElement {
22
+ static properties = {
23
+ value: { type: String, reflect: true, default: "" },
24
+ options: { type: String, reflect: true, default: "[]" },
25
+ multiple: { type: Boolean, reflect: true, default: false },
26
+ disabled: { type: Boolean, reflect: true, default: false },
27
+ clearable: { type: Boolean, reflect: true, default: false },
28
+ placeholder: { type: String, reflect: true, default: "" },
29
+ size: { type: String, reflect: true, default: "md" },
30
+ loading: { type: Boolean, reflect: true, default: false },
31
+ noResultsText: { type: String, reflect: true, default: "No results found" }
32
+ };
33
+ value;
34
+ options;
35
+ multiple;
36
+ disabled;
37
+ clearable;
38
+ placeholder;
39
+ size;
40
+ loading;
41
+ noResultsText;
42
+ _isOpen = false;
43
+ _searchValue = "";
44
+ _highlightedIndex = -1;
45
+ _selectedValues = [];
46
+ constructor() {
47
+ super();
48
+ this.attachStyles(styles);
49
+ }
50
+ connectedCallback() {
51
+ super.connectedCallback();
52
+ this._parseValue();
53
+ document.addEventListener("click", this._handleOutsideClick);
54
+ }
55
+ disconnectedCallback() {
56
+ super.disconnectedCallback();
57
+ document.removeEventListener("click", this._handleOutsideClick);
58
+ }
59
+ _handleOutsideClick = (e) => {
60
+ if (!this.contains(e.target)) {
61
+ this._close();
62
+ }
63
+ };
64
+ _parseValue() {
65
+ if (this.multiple && this.value) {
66
+ try {
67
+ this._selectedValues = JSON.parse(this.value);
68
+ } catch {
69
+ this._selectedValues = this.value ? [this.value] : [];
70
+ }
71
+ } else {
72
+ this._selectedValues = this.value ? [this.value] : [];
73
+ }
74
+ }
75
+ _getOptions() {
76
+ try {
77
+ return JSON.parse(this.options);
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+ _getFilteredOptions() {
83
+ const allOptions = this._getOptions();
84
+ if (!this._searchValue)
85
+ return allOptions;
86
+ const search = this._searchValue.toLowerCase();
87
+ return allOptions.filter((opt) => opt.label.toLowerCase().includes(search) || opt.value.toLowerCase().includes(search) || opt.description?.toLowerCase().includes(search));
88
+ }
89
+ _open() {
90
+ if (this.disabled)
91
+ return;
92
+ this._isOpen = true;
93
+ this._highlightedIndex = -1;
94
+ this.update();
95
+ }
96
+ _close() {
97
+ this._isOpen = false;
98
+ this._searchValue = "";
99
+ this._highlightedIndex = -1;
100
+ this.update();
101
+ }
102
+ _toggle() {
103
+ if (this._isOpen) {
104
+ this._close();
105
+ } else {
106
+ this._open();
107
+ }
108
+ }
109
+ _handleInputChange(e) {
110
+ const input = e.target;
111
+ this._searchValue = input.value;
112
+ this._highlightedIndex = -1;
113
+ if (!this._isOpen) {
114
+ this._open();
115
+ } else {
116
+ this.update();
117
+ }
118
+ this.emit("input", { searchValue: this._searchValue });
119
+ }
120
+ _handleKeyDown(e) {
121
+ const filteredOptions = this._getFilteredOptions().filter((opt) => !opt.disabled);
122
+ switch (e.key) {
123
+ case "ArrowDown":
124
+ e.preventDefault();
125
+ if (!this._isOpen) {
126
+ this._open();
127
+ } else {
128
+ this._highlightedIndex = Math.min(this._highlightedIndex + 1, filteredOptions.length - 1);
129
+ this.update();
130
+ }
131
+ break;
132
+ case "ArrowUp":
133
+ e.preventDefault();
134
+ if (this._isOpen) {
135
+ this._highlightedIndex = Math.max(this._highlightedIndex - 1, 0);
136
+ this.update();
137
+ }
138
+ break;
139
+ case "Enter":
140
+ e.preventDefault();
141
+ if (this._isOpen && this._highlightedIndex >= 0 && this._highlightedIndex < filteredOptions.length) {
142
+ this._selectOption(filteredOptions[this._highlightedIndex]);
143
+ } else if (!this._isOpen) {
144
+ this._open();
145
+ }
146
+ break;
147
+ case "Escape":
148
+ e.preventDefault();
149
+ this._close();
150
+ break;
151
+ case "Backspace":
152
+ if (this.multiple && !this._searchValue && this._selectedValues.length > 0) {
153
+ const lastValue = this._selectedValues[this._selectedValues.length - 1];
154
+ this._removeValue(lastValue);
155
+ }
156
+ break;
157
+ }
158
+ }
159
+ _selectOption(option) {
160
+ if (option.disabled)
161
+ return;
162
+ if (this.multiple) {
163
+ const index = this._selectedValues.indexOf(option.value);
164
+ if (index === -1) {
165
+ this._selectedValues = [...this._selectedValues, option.value];
166
+ } else {
167
+ this._selectedValues = this._selectedValues.filter((v) => v !== option.value);
168
+ }
169
+ this.value = JSON.stringify(this._selectedValues);
170
+ this._searchValue = "";
171
+ } else {
172
+ this._selectedValues = [option.value];
173
+ this.value = option.value;
174
+ this._close();
175
+ }
176
+ this.emit("change", {
177
+ value: this.value,
178
+ selectedValues: this._selectedValues,
179
+ option
180
+ });
181
+ this.update();
182
+ }
183
+ _removeValue(val) {
184
+ this._selectedValues = this._selectedValues.filter((v) => v !== val);
185
+ this.value = this.multiple ? JSON.stringify(this._selectedValues) : this._selectedValues[0] || "";
186
+ this.emit("change", {
187
+ value: this.value,
188
+ selectedValues: this._selectedValues
189
+ });
190
+ this.update();
191
+ }
192
+ _clear() {
193
+ this._selectedValues = [];
194
+ this._searchValue = "";
195
+ this.value = this.multiple ? "[]" : "";
196
+ this.emit("clear", { value: this.value });
197
+ this.emit("change", {
198
+ value: this.value,
199
+ selectedValues: this._selectedValues
200
+ });
201
+ this.update();
202
+ }
203
+ _getDisplayValue() {
204
+ if (this._selectedValues.length === 0)
205
+ return "";
206
+ const allOptions = this._getOptions();
207
+ if (this.multiple) {
208
+ return "";
209
+ }
210
+ const selectedOption = allOptions.find((opt) => opt.value === this._selectedValues[0]);
211
+ return selectedOption?.label || this._selectedValues[0];
212
+ }
213
+ _highlightMatch(text) {
214
+ if (!this._searchValue)
215
+ return text;
216
+ const search = this._searchValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
217
+ const regex = new RegExp(`(${search})`, "gi");
218
+ return text.replace(regex, '<span class="autocomplete-highlight">$1</span>');
219
+ }
220
+ _renderTags() {
221
+ const allOptions = this._getOptions();
222
+ return this._selectedValues.map((val) => {
223
+ const option = allOptions.find((opt) => opt.value === val);
224
+ const label = option?.label || val;
225
+ return `
226
+ <span class="autocomplete-tag">
227
+ <span>${label}</span>
228
+ <button
229
+ type="button"
230
+ class="autocomplete-tag-remove"
231
+ data-value="${val}"
232
+ ${this.disabled ? "disabled" : ""}
233
+ >&times;</button>
234
+ </span>
235
+ `;
236
+ }).join("");
237
+ }
238
+ _renderOptions() {
239
+ const filteredOptions = this._getFilteredOptions();
240
+ if (this.loading) {
241
+ return `
242
+ <div class="autocomplete-loading">
243
+ <span>Loading...</span>
244
+ </div>
245
+ `;
246
+ }
247
+ if (filteredOptions.length === 0) {
248
+ return `
249
+ <div class="autocomplete-no-results">${this.noResultsText}</div>
250
+ `;
251
+ }
252
+ const groups = new Map;
253
+ const ungrouped = [];
254
+ for (const opt of filteredOptions) {
255
+ if (opt.group) {
256
+ const group = groups.get(opt.group) || [];
257
+ group.push(opt);
258
+ groups.set(opt.group, group);
259
+ } else {
260
+ ungrouped.push(opt);
261
+ }
262
+ }
263
+ let html = "";
264
+ let globalIndex = 0;
265
+ for (const [groupName, options] of groups) {
266
+ html += `<div class="autocomplete-group-header">${groupName}</div>`;
267
+ for (const opt of options) {
268
+ html += this._renderOption(opt, globalIndex++);
269
+ }
270
+ }
271
+ for (const opt of ungrouped) {
272
+ html += this._renderOption(opt, globalIndex++);
273
+ }
274
+ return html;
275
+ }
276
+ _renderOption(opt, index) {
277
+ const isSelected = this._selectedValues.includes(opt.value);
278
+ const isHighlighted = index === this._highlightedIndex;
279
+ const classes = [
280
+ "autocomplete-option",
281
+ isSelected ? "selected" : "",
282
+ isHighlighted ? "highlighted" : "",
283
+ opt.disabled ? "disabled" : ""
284
+ ].filter(Boolean).join(" ");
285
+ return `
286
+ <div
287
+ class="${classes}"
288
+ data-value="${opt.value}"
289
+ data-index="${index}"
290
+ role="option"
291
+ aria-selected="${isSelected}"
292
+ ${opt.disabled ? 'aria-disabled="true"' : ""}
293
+ >
294
+ ${this.multiple ? `
295
+ <span class="autocomplete-option-icon">
296
+ ${isSelected ? "✓" : ""}
297
+ </span>
298
+ ` : ""}
299
+ <div class="autocomplete-option-content">
300
+ <div class="autocomplete-option-label">${this._highlightMatch(opt.label)}</div>
301
+ ${opt.description ? `<div class="autocomplete-option-description">${this._highlightMatch(opt.description)}</div>` : ""}
302
+ </div>
303
+ </div>
304
+ `;
305
+ }
306
+ render() {
307
+ const sizeClass = this.size !== "md" ? `autocomplete-${this.size}` : "";
308
+ const openClass = this._isOpen ? "autocomplete-open" : "";
309
+ const clearableClass = this.clearable ? "autocomplete-clearable" : "";
310
+ const showClear = this.clearable && this._selectedValues.length > 0 && !this.disabled;
311
+ if (this.multiple) {
312
+ return `
313
+ <div class="autocomplete ${sizeClass} ${openClass} ${clearableClass}">
314
+ <div class="autocomplete-tags">
315
+ ${this._renderTags()}
316
+ <input
317
+ type="text"
318
+ class="autocomplete-tags-input"
319
+ placeholder="${this._selectedValues.length === 0 ? this.placeholder : ""}"
320
+ value="${this._searchValue}"
321
+ ${this.disabled ? "disabled" : ""}
322
+ role="combobox"
323
+ aria-expanded="${this._isOpen}"
324
+ aria-haspopup="listbox"
325
+ autocomplete="off"
326
+ />
327
+ </div>
328
+ ${showClear ? `
329
+ <button type="button" class="autocomplete-clear" aria-label="Clear selection">&times;</button>
330
+ ` : ""}
331
+ <div class="autocomplete-dropdown" role="listbox">
332
+ ${this._renderOptions()}
333
+ </div>
334
+ </div>
335
+ `;
336
+ }
337
+ return `
338
+ <div class="autocomplete ${sizeClass} ${openClass} ${clearableClass}">
339
+ <input
340
+ type="text"
341
+ class="autocomplete-input"
342
+ placeholder="${this.placeholder}"
343
+ value="${this._isOpen ? this._searchValue : this._getDisplayValue()}"
344
+ ${this.disabled ? "disabled" : ""}
345
+ role="combobox"
346
+ aria-expanded="${this._isOpen}"
347
+ aria-haspopup="listbox"
348
+ autocomplete="off"
349
+ />
350
+ ${showClear ? `
351
+ <button type="button" class="autocomplete-clear" aria-label="Clear selection">&times;</button>
352
+ ` : ""}
353
+ <div class="autocomplete-dropdown" role="listbox">
354
+ ${this._renderOptions()}
355
+ </div>
356
+ </div>
357
+ `;
358
+ }
359
+ update() {
360
+ super.update();
361
+ this._attachEventListeners();
362
+ }
363
+ _attachEventListeners() {
364
+ const input = this.shadowRoot?.querySelector(".autocomplete-input, .autocomplete-tags-input");
365
+ if (input) {
366
+ input.removeEventListener("input", this._handleInputChange.bind(this));
367
+ input.removeEventListener("keydown", this._handleKeyDown.bind(this));
368
+ input.removeEventListener("focus", this._open.bind(this));
369
+ input.addEventListener("input", this._handleInputChange.bind(this));
370
+ input.addEventListener("keydown", this._handleKeyDown.bind(this));
371
+ input.addEventListener("focus", this._open.bind(this));
372
+ }
373
+ const clearBtn = this.shadowRoot?.querySelector(".autocomplete-clear");
374
+ if (clearBtn) {
375
+ clearBtn.removeEventListener("click", this._clear.bind(this));
376
+ clearBtn.addEventListener("click", this._clear.bind(this));
377
+ }
378
+ const options = this.shadowRoot?.querySelectorAll(".autocomplete-option");
379
+ options?.forEach((opt) => {
380
+ opt.removeEventListener("click", this._handleOptionClick);
381
+ opt.addEventListener("click", this._handleOptionClick);
382
+ });
383
+ const tagRemoves = this.shadowRoot?.querySelectorAll(".autocomplete-tag-remove");
384
+ tagRemoves?.forEach((btn) => {
385
+ btn.removeEventListener("click", this._handleTagRemove);
386
+ btn.addEventListener("click", this._handleTagRemove);
387
+ });
388
+ }
389
+ _handleOptionClick = (e) => {
390
+ e.stopPropagation();
391
+ const target = e.currentTarget;
392
+ const value = target.dataset.value;
393
+ if (value) {
394
+ const allOptions = this._getOptions();
395
+ const option = allOptions.find((opt) => opt.value === value);
396
+ if (option) {
397
+ this._selectOption(option);
398
+ }
399
+ }
400
+ };
401
+ _handleTagRemove = (e) => {
402
+ e.stopPropagation();
403
+ const target = e.currentTarget;
404
+ const value = target.dataset.value;
405
+ if (value) {
406
+ this._removeValue(value);
407
+ }
408
+ };
409
+ }
410
+
411
+ // src/index.ts
412
+ function register() {
413
+ if (!customElements.get("el-dm-autocomplete")) {
414
+ customElements.define("el-dm-autocomplete", ElDmAutocomplete);
415
+ }
416
+ }
417
+ export {
418
+ register,
419
+ ElDmAutocomplete
420
+ };