@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,19 @@
1
+ /**
2
+ * @duskmoon-dev/el-autocomplete
3
+ *
4
+ * DuskMoon Autocomplete custom element
5
+ */
6
+ import { ElDmAutocomplete } from './el-dm-autocomplete.js';
7
+ export { ElDmAutocomplete };
8
+ export type { AutocompleteSize, AutocompleteOption } from './el-dm-autocomplete.js';
9
+ /**
10
+ * Register the el-dm-autocomplete custom element
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { register } from '@duskmoon-dev/el-autocomplete';
15
+ * register();
16
+ * ```
17
+ */
18
+ export declare function register(): void;
19
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAE3D,OAAO,EAAE,gBAAgB,EAAE,CAAC;AAC5B,YAAY,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAEpF;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,IAAI,IAAI,CAI/B"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=register.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"register.d.ts","sourceRoot":"","sources":["../../src/register.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@duskmoon-dev/el-autocomplete",
3
+ "version": "0.4.0",
4
+ "description": "DuskMoon Autocomplete custom element",
5
+ "type": "module",
6
+ "main": "./dist/cjs/index.js",
7
+ "module": "./dist/esm/index.js",
8
+ "types": "./dist/types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/esm/index.js",
12
+ "require": "./dist/cjs/index.js",
13
+ "types": "./dist/types/index.d.ts"
14
+ },
15
+ "./register": {
16
+ "import": "./dist/esm/register.js",
17
+ "require": "./dist/cjs/register.js",
18
+ "types": "./dist/types/register.d.ts"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src"
24
+ ],
25
+ "scripts": {
26
+ "build": "bun run build:esm && bun run build:cjs && bun run build:types",
27
+ "build:esm": "bun build ./src/index.ts ./src/register.ts --outdir ./dist/esm --format esm --target browser --external @duskmoon-dev/el-core --external @duskmoon-dev/core",
28
+ "build:cjs": "bun build ./src/index.ts ./src/register.ts --outdir ./dist/cjs --format cjs --target browser --external @duskmoon-dev/el-core --external @duskmoon-dev/core",
29
+ "build:types": "tsc --emitDeclarationOnly",
30
+ "clean": "rm -rf dist",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "dependencies": {
34
+ "@duskmoon-dev/core": "^1.1.1",
35
+ "@duskmoon-dev/el-core": "0.3.0"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.8.3"
39
+ },
40
+ "peerDependencies": {
41
+ "@duskmoon-dev/core": "^0.1.7"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/AkaraChen/duskmoon-elements.git",
49
+ "directory": "elements/autocomplete"
50
+ },
51
+ "keywords": [
52
+ "web-components",
53
+ "custom-elements",
54
+ "autocomplete",
55
+ "duskmoon"
56
+ ],
57
+ "author": "AkaraChen",
58
+ "license": "MIT"
59
+ }
@@ -0,0 +1,4 @@
1
+ declare module '@duskmoon-dev/core/components/autocomplete' {
2
+ export const css: string;
3
+ export const styles: CSSStyleSheet;
4
+ }
@@ -0,0 +1,536 @@
1
+ import { BaseElement, css as cssTag } from '@duskmoon-dev/el-core';
2
+ import { css } from '@duskmoon-dev/core/components/autocomplete';
3
+
4
+ // Strip @layer components wrapper for Shadow DOM
5
+ const strippedCss = css
6
+ .replace(/@layer\s+components\s*\{/, '')
7
+ .replace(/\}[\s]*$/, '');
8
+
9
+ const styles = cssTag`
10
+ ${strippedCss}
11
+
12
+ :host {
13
+ display: block;
14
+ }
15
+
16
+ :host([hidden]) {
17
+ display: none;
18
+ }
19
+
20
+ .autocomplete {
21
+ width: 100%;
22
+ }
23
+ `;
24
+
25
+ export type AutocompleteSize = 'sm' | 'md' | 'lg';
26
+
27
+ export interface AutocompleteOption {
28
+ value: string;
29
+ label: string;
30
+ description?: string;
31
+ disabled?: boolean;
32
+ group?: string;
33
+ }
34
+
35
+ export class ElDmAutocomplete extends BaseElement {
36
+ static properties = {
37
+ value: { type: String, reflect: true, default: '' },
38
+ options: { type: String, reflect: true, default: '[]' },
39
+ multiple: { type: Boolean, reflect: true, default: false },
40
+ disabled: { type: Boolean, reflect: true, default: false },
41
+ clearable: { type: Boolean, reflect: true, default: false },
42
+ placeholder: { type: String, reflect: true, default: '' },
43
+ size: { type: String, reflect: true, default: 'md' },
44
+ loading: { type: Boolean, reflect: true, default: false },
45
+ noResultsText: { type: String, reflect: true, default: 'No results found' },
46
+ };
47
+
48
+ value!: string;
49
+ options!: string;
50
+ multiple!: boolean;
51
+ disabled!: boolean;
52
+ clearable!: boolean;
53
+ placeholder!: string;
54
+ size!: AutocompleteSize;
55
+ loading!: boolean;
56
+ noResultsText!: string;
57
+
58
+ private _isOpen = false;
59
+ private _searchValue = '';
60
+ private _highlightedIndex = -1;
61
+ private _selectedValues: string[] = [];
62
+
63
+ constructor() {
64
+ super();
65
+ this.attachStyles(styles);
66
+ }
67
+
68
+ connectedCallback() {
69
+ super.connectedCallback();
70
+ this._parseValue();
71
+ document.addEventListener('click', this._handleOutsideClick);
72
+ }
73
+
74
+ disconnectedCallback() {
75
+ super.disconnectedCallback();
76
+ document.removeEventListener('click', this._handleOutsideClick);
77
+ }
78
+
79
+ private _handleOutsideClick = (e: MouseEvent) => {
80
+ if (!this.contains(e.target as Node)) {
81
+ this._close();
82
+ }
83
+ };
84
+
85
+ private _parseValue() {
86
+ if (this.multiple && this.value) {
87
+ try {
88
+ this._selectedValues = JSON.parse(this.value);
89
+ } catch {
90
+ this._selectedValues = this.value ? [this.value] : [];
91
+ }
92
+ } else {
93
+ this._selectedValues = this.value ? [this.value] : [];
94
+ }
95
+ }
96
+
97
+ private _getOptions(): AutocompleteOption[] {
98
+ try {
99
+ return JSON.parse(this.options);
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ private _getFilteredOptions(): AutocompleteOption[] {
106
+ const allOptions = this._getOptions();
107
+ if (!this._searchValue) return allOptions;
108
+
109
+ const search = this._searchValue.toLowerCase();
110
+ return allOptions.filter(
111
+ (opt) =>
112
+ opt.label.toLowerCase().includes(search) ||
113
+ opt.value.toLowerCase().includes(search) ||
114
+ opt.description?.toLowerCase().includes(search)
115
+ );
116
+ }
117
+
118
+ private _open() {
119
+ if (this.disabled) return;
120
+ this._isOpen = true;
121
+ this._highlightedIndex = -1;
122
+ this.update();
123
+ }
124
+
125
+ private _close() {
126
+ this._isOpen = false;
127
+ this._searchValue = '';
128
+ this._highlightedIndex = -1;
129
+ this.update();
130
+ }
131
+
132
+ private _toggle() {
133
+ if (this._isOpen) {
134
+ this._close();
135
+ } else {
136
+ this._open();
137
+ }
138
+ }
139
+
140
+ private _handleInputChange(e: Event) {
141
+ const input = e.target as HTMLInputElement;
142
+ this._searchValue = input.value;
143
+ this._highlightedIndex = -1;
144
+
145
+ if (!this._isOpen) {
146
+ this._open();
147
+ } else {
148
+ this.update();
149
+ }
150
+
151
+ this.emit('input', { searchValue: this._searchValue });
152
+ }
153
+
154
+ private _handleKeyDown(e: KeyboardEvent) {
155
+ const filteredOptions = this._getFilteredOptions().filter(
156
+ (opt) => !opt.disabled
157
+ );
158
+
159
+ switch (e.key) {
160
+ case 'ArrowDown':
161
+ e.preventDefault();
162
+ if (!this._isOpen) {
163
+ this._open();
164
+ } else {
165
+ this._highlightedIndex = Math.min(
166
+ this._highlightedIndex + 1,
167
+ filteredOptions.length - 1
168
+ );
169
+ this.update();
170
+ }
171
+ break;
172
+
173
+ case 'ArrowUp':
174
+ e.preventDefault();
175
+ if (this._isOpen) {
176
+ this._highlightedIndex = Math.max(this._highlightedIndex - 1, 0);
177
+ this.update();
178
+ }
179
+ break;
180
+
181
+ case 'Enter':
182
+ e.preventDefault();
183
+ if (
184
+ this._isOpen &&
185
+ this._highlightedIndex >= 0 &&
186
+ this._highlightedIndex < filteredOptions.length
187
+ ) {
188
+ this._selectOption(filteredOptions[this._highlightedIndex]);
189
+ } else if (!this._isOpen) {
190
+ this._open();
191
+ }
192
+ break;
193
+
194
+ case 'Escape':
195
+ e.preventDefault();
196
+ this._close();
197
+ break;
198
+
199
+ case 'Backspace':
200
+ if (
201
+ this.multiple &&
202
+ !this._searchValue &&
203
+ this._selectedValues.length > 0
204
+ ) {
205
+ const lastValue = this._selectedValues[this._selectedValues.length - 1];
206
+ this._removeValue(lastValue);
207
+ }
208
+ break;
209
+ }
210
+ }
211
+
212
+ private _selectOption(option: AutocompleteOption) {
213
+ if (option.disabled) return;
214
+
215
+ if (this.multiple) {
216
+ const index = this._selectedValues.indexOf(option.value);
217
+ if (index === -1) {
218
+ this._selectedValues = [...this._selectedValues, option.value];
219
+ } else {
220
+ this._selectedValues = this._selectedValues.filter(
221
+ (v) => v !== option.value
222
+ );
223
+ }
224
+ this.value = JSON.stringify(this._selectedValues);
225
+ this._searchValue = '';
226
+ } else {
227
+ this._selectedValues = [option.value];
228
+ this.value = option.value;
229
+ this._close();
230
+ }
231
+
232
+ this.emit('change', {
233
+ value: this.value,
234
+ selectedValues: this._selectedValues,
235
+ option,
236
+ });
237
+
238
+ this.update();
239
+ }
240
+
241
+ private _removeValue(val: string) {
242
+ this._selectedValues = this._selectedValues.filter((v) => v !== val);
243
+ this.value = this.multiple
244
+ ? JSON.stringify(this._selectedValues)
245
+ : this._selectedValues[0] || '';
246
+
247
+ this.emit('change', {
248
+ value: this.value,
249
+ selectedValues: this._selectedValues,
250
+ });
251
+
252
+ this.update();
253
+ }
254
+
255
+ private _clear() {
256
+ this._selectedValues = [];
257
+ this._searchValue = '';
258
+ this.value = this.multiple ? '[]' : '';
259
+
260
+ this.emit('clear', { value: this.value });
261
+ this.emit('change', {
262
+ value: this.value,
263
+ selectedValues: this._selectedValues,
264
+ });
265
+
266
+ this.update();
267
+ }
268
+
269
+ private _getDisplayValue(): string {
270
+ if (this._selectedValues.length === 0) return '';
271
+
272
+ const allOptions = this._getOptions();
273
+ if (this.multiple) {
274
+ return '';
275
+ }
276
+
277
+ const selectedOption = allOptions.find(
278
+ (opt) => opt.value === this._selectedValues[0]
279
+ );
280
+ return selectedOption?.label || this._selectedValues[0];
281
+ }
282
+
283
+ private _highlightMatch(text: string): string {
284
+ if (!this._searchValue) return text;
285
+
286
+ const search = this._searchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
287
+ const regex = new RegExp(`(${search})`, 'gi');
288
+ return text.replace(
289
+ regex,
290
+ '<span class="autocomplete-highlight">$1</span>'
291
+ );
292
+ }
293
+
294
+ private _renderTags(): string {
295
+ const allOptions = this._getOptions();
296
+ return this._selectedValues
297
+ .map((val) => {
298
+ const option = allOptions.find((opt) => opt.value === val);
299
+ const label = option?.label || val;
300
+ return `
301
+ <span class="autocomplete-tag">
302
+ <span>${label}</span>
303
+ <button
304
+ type="button"
305
+ class="autocomplete-tag-remove"
306
+ data-value="${val}"
307
+ ${this.disabled ? 'disabled' : ''}
308
+ >&times;</button>
309
+ </span>
310
+ `;
311
+ })
312
+ .join('');
313
+ }
314
+
315
+ private _renderOptions(): string {
316
+ const filteredOptions = this._getFilteredOptions();
317
+
318
+ if (this.loading) {
319
+ return `
320
+ <div class="autocomplete-loading">
321
+ <span>Loading...</span>
322
+ </div>
323
+ `;
324
+ }
325
+
326
+ if (filteredOptions.length === 0) {
327
+ return `
328
+ <div class="autocomplete-no-results">${this.noResultsText}</div>
329
+ `;
330
+ }
331
+
332
+ // Group options if they have groups
333
+ const groups = new Map<string, AutocompleteOption[]>();
334
+ const ungrouped: AutocompleteOption[] = [];
335
+
336
+ for (const opt of filteredOptions) {
337
+ if (opt.group) {
338
+ const group = groups.get(opt.group) || [];
339
+ group.push(opt);
340
+ groups.set(opt.group, group);
341
+ } else {
342
+ ungrouped.push(opt);
343
+ }
344
+ }
345
+
346
+ let html = '';
347
+ let globalIndex = 0;
348
+
349
+ // Render grouped options
350
+ for (const [groupName, options] of groups) {
351
+ html += `<div class="autocomplete-group-header">${groupName}</div>`;
352
+ for (const opt of options) {
353
+ html += this._renderOption(opt, globalIndex++);
354
+ }
355
+ }
356
+
357
+ // Render ungrouped options
358
+ for (const opt of ungrouped) {
359
+ html += this._renderOption(opt, globalIndex++);
360
+ }
361
+
362
+ return html;
363
+ }
364
+
365
+ private _renderOption(opt: AutocompleteOption, index: number): string {
366
+ const isSelected = this._selectedValues.includes(opt.value);
367
+ const isHighlighted = index === this._highlightedIndex;
368
+
369
+ const classes = [
370
+ 'autocomplete-option',
371
+ isSelected ? 'selected' : '',
372
+ isHighlighted ? 'highlighted' : '',
373
+ opt.disabled ? 'disabled' : '',
374
+ ]
375
+ .filter(Boolean)
376
+ .join(' ');
377
+
378
+ return `
379
+ <div
380
+ class="${classes}"
381
+ data-value="${opt.value}"
382
+ data-index="${index}"
383
+ role="option"
384
+ aria-selected="${isSelected}"
385
+ ${opt.disabled ? 'aria-disabled="true"' : ''}
386
+ >
387
+ ${
388
+ this.multiple
389
+ ? `
390
+ <span class="autocomplete-option-icon">
391
+ ${isSelected ? '✓' : ''}
392
+ </span>
393
+ `
394
+ : ''
395
+ }
396
+ <div class="autocomplete-option-content">
397
+ <div class="autocomplete-option-label">${this._highlightMatch(opt.label)}</div>
398
+ ${opt.description ? `<div class="autocomplete-option-description">${this._highlightMatch(opt.description)}</div>` : ''}
399
+ </div>
400
+ </div>
401
+ `;
402
+ }
403
+
404
+ render() {
405
+ const sizeClass =
406
+ this.size !== 'md' ? `autocomplete-${this.size}` : '';
407
+ const openClass = this._isOpen ? 'autocomplete-open' : '';
408
+ const clearableClass = this.clearable ? 'autocomplete-clearable' : '';
409
+ const showClear =
410
+ this.clearable && this._selectedValues.length > 0 && !this.disabled;
411
+
412
+ if (this.multiple) {
413
+ return `
414
+ <div class="autocomplete ${sizeClass} ${openClass} ${clearableClass}">
415
+ <div class="autocomplete-tags">
416
+ ${this._renderTags()}
417
+ <input
418
+ type="text"
419
+ class="autocomplete-tags-input"
420
+ placeholder="${this._selectedValues.length === 0 ? this.placeholder : ''}"
421
+ value="${this._searchValue}"
422
+ ${this.disabled ? 'disabled' : ''}
423
+ role="combobox"
424
+ aria-expanded="${this._isOpen}"
425
+ aria-haspopup="listbox"
426
+ autocomplete="off"
427
+ />
428
+ </div>
429
+ ${
430
+ showClear
431
+ ? `
432
+ <button type="button" class="autocomplete-clear" aria-label="Clear selection">&times;</button>
433
+ `
434
+ : ''
435
+ }
436
+ <div class="autocomplete-dropdown" role="listbox">
437
+ ${this._renderOptions()}
438
+ </div>
439
+ </div>
440
+ `;
441
+ }
442
+
443
+ return `
444
+ <div class="autocomplete ${sizeClass} ${openClass} ${clearableClass}">
445
+ <input
446
+ type="text"
447
+ class="autocomplete-input"
448
+ placeholder="${this.placeholder}"
449
+ value="${this._isOpen ? this._searchValue : this._getDisplayValue()}"
450
+ ${this.disabled ? 'disabled' : ''}
451
+ role="combobox"
452
+ aria-expanded="${this._isOpen}"
453
+ aria-haspopup="listbox"
454
+ autocomplete="off"
455
+ />
456
+ ${
457
+ showClear
458
+ ? `
459
+ <button type="button" class="autocomplete-clear" aria-label="Clear selection">&times;</button>
460
+ `
461
+ : ''
462
+ }
463
+ <div class="autocomplete-dropdown" role="listbox">
464
+ ${this._renderOptions()}
465
+ </div>
466
+ </div>
467
+ `;
468
+ }
469
+
470
+ update() {
471
+ super.update();
472
+ this._attachEventListeners();
473
+ }
474
+
475
+ private _attachEventListeners() {
476
+ const input = this.shadowRoot?.querySelector(
477
+ '.autocomplete-input, .autocomplete-tags-input'
478
+ ) as HTMLInputElement;
479
+
480
+ if (input) {
481
+ input.removeEventListener('input', this._handleInputChange.bind(this));
482
+ input.removeEventListener('keydown', this._handleKeyDown.bind(this));
483
+ input.removeEventListener('focus', this._open.bind(this));
484
+
485
+ input.addEventListener('input', this._handleInputChange.bind(this));
486
+ input.addEventListener('keydown', this._handleKeyDown.bind(this));
487
+ input.addEventListener('focus', this._open.bind(this));
488
+ }
489
+
490
+ const clearBtn = this.shadowRoot?.querySelector('.autocomplete-clear');
491
+ if (clearBtn) {
492
+ clearBtn.removeEventListener('click', this._clear.bind(this));
493
+ clearBtn.addEventListener('click', this._clear.bind(this));
494
+ }
495
+
496
+ // Option click handlers
497
+ const options = this.shadowRoot?.querySelectorAll('.autocomplete-option');
498
+ options?.forEach((opt) => {
499
+ opt.removeEventListener('click', this._handleOptionClick);
500
+ opt.addEventListener('click', this._handleOptionClick);
501
+ });
502
+
503
+ // Tag remove handlers
504
+ const tagRemoves = this.shadowRoot?.querySelectorAll(
505
+ '.autocomplete-tag-remove'
506
+ );
507
+ tagRemoves?.forEach((btn) => {
508
+ btn.removeEventListener('click', this._handleTagRemove);
509
+ btn.addEventListener('click', this._handleTagRemove);
510
+ });
511
+ }
512
+
513
+ private _handleOptionClick = (e: Event) => {
514
+ e.stopPropagation();
515
+ const target = e.currentTarget as HTMLElement;
516
+ const value = target.dataset.value;
517
+
518
+ if (value) {
519
+ const allOptions = this._getOptions();
520
+ const option = allOptions.find((opt) => opt.value === value);
521
+ if (option) {
522
+ this._selectOption(option);
523
+ }
524
+ }
525
+ };
526
+
527
+ private _handleTagRemove = (e: Event) => {
528
+ e.stopPropagation();
529
+ const target = e.currentTarget as HTMLElement;
530
+ const value = target.dataset.value;
531
+
532
+ if (value) {
533
+ this._removeValue(value);
534
+ }
535
+ };
536
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @duskmoon-dev/el-autocomplete
3
+ *
4
+ * DuskMoon Autocomplete custom element
5
+ */
6
+
7
+ import { ElDmAutocomplete } from './el-dm-autocomplete.js';
8
+
9
+ export { ElDmAutocomplete };
10
+ export type { AutocompleteSize, AutocompleteOption } from './el-dm-autocomplete.js';
11
+
12
+ /**
13
+ * Register the el-dm-autocomplete custom element
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { register } from '@duskmoon-dev/el-autocomplete';
18
+ * register();
19
+ * ```
20
+ */
21
+ export function register(): void {
22
+ if (!customElements.get('el-dm-autocomplete')) {
23
+ customElements.define('el-dm-autocomplete', ElDmAutocomplete);
24
+ }
25
+ }
@@ -0,0 +1,2 @@
1
+ import { register } from './index.js';
2
+ register();