@melodicdev/components 1.1.0 → 1.2.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.
- package/assets/melodic-components.js +3 -0
- package/assets/melodic-components.js.map +1 -1
- package/assets/melodic-components.min.js +5 -3
- package/lib/components/forms/autocomplete/autocomplete.component.d.ts +127 -0
- package/lib/components/forms/autocomplete/autocomplete.component.d.ts.map +1 -0
- package/lib/components/forms/autocomplete/autocomplete.component.js +491 -0
- package/lib/components/forms/autocomplete/autocomplete.styles.d.ts +2 -0
- package/lib/components/forms/autocomplete/autocomplete.styles.d.ts.map +1 -0
- package/lib/components/forms/autocomplete/autocomplete.styles.js +354 -0
- package/lib/components/forms/autocomplete/autocomplete.template.d.ts +3 -0
- package/lib/components/forms/autocomplete/autocomplete.template.d.ts.map +1 -0
- package/lib/components/forms/autocomplete/autocomplete.template.js +128 -0
- package/lib/components/forms/autocomplete/autocomplete.types.d.ts +18 -0
- package/lib/components/forms/autocomplete/autocomplete.types.d.ts.map +1 -0
- package/lib/components/forms/autocomplete/autocomplete.types.js +1 -0
- package/lib/components/forms/autocomplete/index.d.ts +5 -0
- package/lib/components/forms/autocomplete/index.d.ts.map +1 -0
- package/lib/components/forms/autocomplete/index.js +3 -0
- package/lib/components/forms/date-time-picker/date-time-picker.template.js +2 -2
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/package.json +5 -1
|
@@ -977,7 +977,7 @@ var V = class {
|
|
|
977
977
|
};
|
|
978
978
|
this._resolversExecutedForPath = m;
|
|
979
979
|
}
|
|
980
|
-
if (a ? history.replaceState(l, "", m) : history.pushState(l, "", m), this._currentPath = m, c) {
|
|
980
|
+
if (this.setCurrentMatches(u), a ? history.replaceState(l, "", m) : history.pushState(l, "", m), this._currentPath = m, c) {
|
|
981
981
|
const h = m.includes("#") ? m.split("#")[1] : null;
|
|
982
982
|
if (h) {
|
|
983
983
|
const p = document.getElementById(h);
|
|
@@ -1083,8 +1083,10 @@ var V = class {
|
|
|
1083
1083
|
return;
|
|
1084
1084
|
}
|
|
1085
1085
|
this._currentPath = r;
|
|
1086
|
-
const a =
|
|
1087
|
-
|
|
1086
|
+
const a = this.matchPath(window.location.pathname);
|
|
1087
|
+
this.setCurrentMatches(a);
|
|
1088
|
+
const o = new CustomEvent("NavigationEvent", { detail: De("push", t.state, "", window.location.pathname) });
|
|
1089
|
+
window.dispatchEvent(o);
|
|
1088
1090
|
}
|
|
1089
1091
|
async executeResolver(t, r) {
|
|
1090
1092
|
const l = t.resolve(r);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { IElementRef, OnCreate, OnDestroy } from '@melodicdev/core';
|
|
2
|
+
import type { Size } from '../../../types/index.js';
|
|
3
|
+
import type { AutocompleteOption, AutocompleteSearchFn } from './autocomplete.types.js';
|
|
4
|
+
/**
|
|
5
|
+
* ml-autocomplete - Typeahead/autocomplete form component
|
|
6
|
+
*
|
|
7
|
+
* Provides a text input with dropdown suggestions. Supports static option lists
|
|
8
|
+
* or async search via a promise-returning function.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```html
|
|
12
|
+
* <ml-autocomplete
|
|
13
|
+
* label="Search users"
|
|
14
|
+
* placeholder="Type to search..."
|
|
15
|
+
* .options=${userOptions}
|
|
16
|
+
* ></ml-autocomplete>
|
|
17
|
+
*
|
|
18
|
+
* <ml-autocomplete
|
|
19
|
+
* label="Search"
|
|
20
|
+
* .searchFn=${async (query) => fetch(`/api/search?q=${query}`).then(r => r.json())}
|
|
21
|
+
* .debounce=${300}
|
|
22
|
+
* ></ml-autocomplete>
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @fires ml:change - Emitted when selection changes
|
|
26
|
+
* @fires ml:search - Emitted when search query changes
|
|
27
|
+
* @fires ml:open - Emitted when dropdown opens
|
|
28
|
+
* @fires ml:close - Emitted when dropdown closes
|
|
29
|
+
*/
|
|
30
|
+
export declare class AutocompleteComponent implements IElementRef, OnCreate, OnDestroy {
|
|
31
|
+
elementRef: HTMLElement;
|
|
32
|
+
/** Label text */
|
|
33
|
+
label: string;
|
|
34
|
+
/** Placeholder text */
|
|
35
|
+
placeholder: string;
|
|
36
|
+
/** Hint text shown below input */
|
|
37
|
+
hint: string;
|
|
38
|
+
/** Error message (shows error state when set) */
|
|
39
|
+
error: string;
|
|
40
|
+
/** Component size */
|
|
41
|
+
size: Size;
|
|
42
|
+
/** Disable the component */
|
|
43
|
+
disabled: boolean;
|
|
44
|
+
/** Mark as required */
|
|
45
|
+
required: boolean;
|
|
46
|
+
/** Enable multi-select mode */
|
|
47
|
+
multiple: boolean;
|
|
48
|
+
/** Currently selected value (single mode) */
|
|
49
|
+
value: string;
|
|
50
|
+
/** Currently selected values (multi mode) */
|
|
51
|
+
values: string[];
|
|
52
|
+
/** Static options list */
|
|
53
|
+
options: AutocompleteOption[];
|
|
54
|
+
/** Async search function */
|
|
55
|
+
searchFn: AutocompleteSearchFn | null;
|
|
56
|
+
/** Debounce ms for async search */
|
|
57
|
+
debounce: number;
|
|
58
|
+
/** Min chars before searching (0 = show on focus) */
|
|
59
|
+
minChars: number;
|
|
60
|
+
/** Show search icon */
|
|
61
|
+
showIcon: boolean;
|
|
62
|
+
/** Current search query */
|
|
63
|
+
search: string;
|
|
64
|
+
/** Whether dropdown is open */
|
|
65
|
+
isOpen: boolean;
|
|
66
|
+
/** Currently focused option index */
|
|
67
|
+
focusedIndex: number;
|
|
68
|
+
/** Loading state for async search */
|
|
69
|
+
_loading: boolean;
|
|
70
|
+
/** Resolved async results */
|
|
71
|
+
_asyncOptions: AutocompleteOption[];
|
|
72
|
+
private readonly _handleKeyDown;
|
|
73
|
+
private readonly _handleDocumentClick;
|
|
74
|
+
private _handleScroll;
|
|
75
|
+
private _debounceTimer;
|
|
76
|
+
private _syncingValues;
|
|
77
|
+
onCreate(): void;
|
|
78
|
+
onDestroy(): void;
|
|
79
|
+
onPropertyChange(name: string): void;
|
|
80
|
+
/** Get the selected option (single mode) */
|
|
81
|
+
get selectedOption(): AutocompleteOption | undefined;
|
|
82
|
+
/** Get selected options (multi mode) */
|
|
83
|
+
get selectedOptions(): AutocompleteOption[];
|
|
84
|
+
/** All available options (static or async) */
|
|
85
|
+
get allOptions(): AutocompleteOption[];
|
|
86
|
+
/** Filtered options for display */
|
|
87
|
+
get filteredOptions(): AutocompleteOption[];
|
|
88
|
+
get hasValue(): boolean;
|
|
89
|
+
/** Display text for the input in single mode */
|
|
90
|
+
get displayText(): string;
|
|
91
|
+
/** Open the dropdown */
|
|
92
|
+
open: () => void;
|
|
93
|
+
/** Close the dropdown */
|
|
94
|
+
close: () => void;
|
|
95
|
+
/** Select an option */
|
|
96
|
+
selectOption: (option: AutocompleteOption) => void;
|
|
97
|
+
/** Handle click on option */
|
|
98
|
+
handleOptionClick: (event: Event, option: AutocompleteOption) => void;
|
|
99
|
+
/** Handle input changes */
|
|
100
|
+
handleInput: (event: Event) => void;
|
|
101
|
+
/** Handle input focus */
|
|
102
|
+
handleFocus: () => void;
|
|
103
|
+
/** Handle input click */
|
|
104
|
+
handleInputClick: (event: Event) => void;
|
|
105
|
+
/** Handle tag remove in multi mode */
|
|
106
|
+
handleTagRemove: (event: Event, value: string) => void;
|
|
107
|
+
/** Clear the current value (single mode) */
|
|
108
|
+
handleClear: (event: Event) => void;
|
|
109
|
+
private toggleOption;
|
|
110
|
+
private debouncedSearch;
|
|
111
|
+
private executeSearch;
|
|
112
|
+
/** Close dropdown on clicks outside the component */
|
|
113
|
+
private onDocumentClick;
|
|
114
|
+
private addDocumentClickListener;
|
|
115
|
+
private removeDocumentClickListener;
|
|
116
|
+
private positionDropdown;
|
|
117
|
+
private addScrollListener;
|
|
118
|
+
private removeScrollListener;
|
|
119
|
+
private getDropdownEl;
|
|
120
|
+
private onKeyDown;
|
|
121
|
+
private focusNextOption;
|
|
122
|
+
private focusPreviousOption;
|
|
123
|
+
private findFirstEnabledIndex;
|
|
124
|
+
private updateValues;
|
|
125
|
+
private areValuesEqual;
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=autocomplete.component.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"autocomplete.component.d.ts","sourceRoot":"","sources":["../../../../src/components/forms/autocomplete/autocomplete.component.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { MelodicComponent } from '@melodicdev/core';
|
|
8
|
+
import { computePosition, offset, flip, shift } from '../../../utils/positioning/index.js';
|
|
9
|
+
import { autocompleteTemplate } from './autocomplete.template.js';
|
|
10
|
+
import { autocompleteStyles } from './autocomplete.styles.js';
|
|
11
|
+
/**
|
|
12
|
+
* ml-autocomplete - Typeahead/autocomplete form component
|
|
13
|
+
*
|
|
14
|
+
* Provides a text input with dropdown suggestions. Supports static option lists
|
|
15
|
+
* or async search via a promise-returning function.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```html
|
|
19
|
+
* <ml-autocomplete
|
|
20
|
+
* label="Search users"
|
|
21
|
+
* placeholder="Type to search..."
|
|
22
|
+
* .options=${userOptions}
|
|
23
|
+
* ></ml-autocomplete>
|
|
24
|
+
*
|
|
25
|
+
* <ml-autocomplete
|
|
26
|
+
* label="Search"
|
|
27
|
+
* .searchFn=${async (query) => fetch(`/api/search?q=${query}`).then(r => r.json())}
|
|
28
|
+
* .debounce=${300}
|
|
29
|
+
* ></ml-autocomplete>
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @fires ml:change - Emitted when selection changes
|
|
33
|
+
* @fires ml:search - Emitted when search query changes
|
|
34
|
+
* @fires ml:open - Emitted when dropdown opens
|
|
35
|
+
* @fires ml:close - Emitted when dropdown closes
|
|
36
|
+
*/
|
|
37
|
+
let AutocompleteComponent = class AutocompleteComponent {
|
|
38
|
+
constructor() {
|
|
39
|
+
/** Label text */
|
|
40
|
+
this.label = '';
|
|
41
|
+
/** Placeholder text */
|
|
42
|
+
this.placeholder = 'Search';
|
|
43
|
+
/** Hint text shown below input */
|
|
44
|
+
this.hint = '';
|
|
45
|
+
/** Error message (shows error state when set) */
|
|
46
|
+
this.error = '';
|
|
47
|
+
/** Component size */
|
|
48
|
+
this.size = 'md';
|
|
49
|
+
/** Disable the component */
|
|
50
|
+
this.disabled = false;
|
|
51
|
+
/** Mark as required */
|
|
52
|
+
this.required = false;
|
|
53
|
+
/** Enable multi-select mode */
|
|
54
|
+
this.multiple = false;
|
|
55
|
+
/** Currently selected value (single mode) */
|
|
56
|
+
this.value = '';
|
|
57
|
+
/** Currently selected values (multi mode) */
|
|
58
|
+
this.values = [];
|
|
59
|
+
/** Static options list */
|
|
60
|
+
this.options = [];
|
|
61
|
+
/** Async search function */
|
|
62
|
+
this.searchFn = null;
|
|
63
|
+
/** Debounce ms for async search */
|
|
64
|
+
this.debounce = 300;
|
|
65
|
+
/** Min chars before searching (0 = show on focus) */
|
|
66
|
+
this.minChars = 0;
|
|
67
|
+
/** Show search icon */
|
|
68
|
+
this.showIcon = true;
|
|
69
|
+
/** Current search query */
|
|
70
|
+
this.search = '';
|
|
71
|
+
/** Whether dropdown is open */
|
|
72
|
+
this.isOpen = false;
|
|
73
|
+
/** Currently focused option index */
|
|
74
|
+
this.focusedIndex = -1;
|
|
75
|
+
/** Loading state for async search */
|
|
76
|
+
this._loading = false;
|
|
77
|
+
/** Resolved async results */
|
|
78
|
+
this._asyncOptions = [];
|
|
79
|
+
this._handleKeyDown = this.onKeyDown.bind(this);
|
|
80
|
+
this._handleDocumentClick = this.onDocumentClick.bind(this);
|
|
81
|
+
this._handleScroll = null;
|
|
82
|
+
this._debounceTimer = null;
|
|
83
|
+
this._syncingValues = false;
|
|
84
|
+
/** Open the dropdown */
|
|
85
|
+
this.open = () => {
|
|
86
|
+
if (this.disabled || this.isOpen)
|
|
87
|
+
return;
|
|
88
|
+
if (this.minChars > 0 && this.search.length < this.minChars)
|
|
89
|
+
return;
|
|
90
|
+
const dropdownEl = this.getDropdownEl();
|
|
91
|
+
if (!dropdownEl)
|
|
92
|
+
return;
|
|
93
|
+
dropdownEl.showPopover();
|
|
94
|
+
this.isOpen = true;
|
|
95
|
+
this.focusedIndex = this.findFirstEnabledIndex();
|
|
96
|
+
this.positionDropdown();
|
|
97
|
+
this.addScrollListener();
|
|
98
|
+
this.addDocumentClickListener();
|
|
99
|
+
this.elementRef.dispatchEvent(new CustomEvent('ml:open', { bubbles: true, composed: true }));
|
|
100
|
+
};
|
|
101
|
+
/** Close the dropdown */
|
|
102
|
+
this.close = () => {
|
|
103
|
+
if (!this.isOpen)
|
|
104
|
+
return;
|
|
105
|
+
this.getDropdownEl()?.hidePopover();
|
|
106
|
+
this.isOpen = false;
|
|
107
|
+
this.focusedIndex = -1;
|
|
108
|
+
this.removeScrollListener();
|
|
109
|
+
this.removeDocumentClickListener();
|
|
110
|
+
this.elementRef.dispatchEvent(new CustomEvent('ml:close', { bubbles: true, composed: true }));
|
|
111
|
+
};
|
|
112
|
+
/** Select an option */
|
|
113
|
+
this.selectOption = (option) => {
|
|
114
|
+
if (option.disabled)
|
|
115
|
+
return;
|
|
116
|
+
if (this.multiple) {
|
|
117
|
+
this.toggleOption(option);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.value = option.value;
|
|
121
|
+
this.search = '';
|
|
122
|
+
this.close();
|
|
123
|
+
this.elementRef.dispatchEvent(new CustomEvent('ml:change', {
|
|
124
|
+
bubbles: true,
|
|
125
|
+
composed: true,
|
|
126
|
+
detail: { value: this.value, option }
|
|
127
|
+
}));
|
|
128
|
+
};
|
|
129
|
+
/** Handle click on option */
|
|
130
|
+
this.handleOptionClick = (event, option) => {
|
|
131
|
+
event.stopPropagation();
|
|
132
|
+
this.selectOption(option);
|
|
133
|
+
};
|
|
134
|
+
/** Handle input changes */
|
|
135
|
+
this.handleInput = (event) => {
|
|
136
|
+
if (this.disabled)
|
|
137
|
+
return;
|
|
138
|
+
const target = event.target;
|
|
139
|
+
this.search = target.value;
|
|
140
|
+
// In single mode, clear the current value when the user types
|
|
141
|
+
if (!this.multiple && this.value) {
|
|
142
|
+
this.value = '';
|
|
143
|
+
this.elementRef.dispatchEvent(new CustomEvent('ml:change', {
|
|
144
|
+
bubbles: true,
|
|
145
|
+
composed: true,
|
|
146
|
+
detail: { value: '', option: null }
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
this.elementRef.dispatchEvent(new CustomEvent('ml:search', {
|
|
150
|
+
bubbles: true,
|
|
151
|
+
composed: true,
|
|
152
|
+
detail: { query: this.search }
|
|
153
|
+
}));
|
|
154
|
+
if (this.minChars > 0 && this.search.length < this.minChars) {
|
|
155
|
+
this.close();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.focusedIndex = this.findFirstEnabledIndex();
|
|
159
|
+
if (!this.isOpen) {
|
|
160
|
+
this.open();
|
|
161
|
+
}
|
|
162
|
+
if (this.searchFn) {
|
|
163
|
+
this.debouncedSearch(this.search);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
/** Handle input focus */
|
|
167
|
+
this.handleFocus = () => {
|
|
168
|
+
if (this.disabled)
|
|
169
|
+
return;
|
|
170
|
+
if (this.minChars === 0) {
|
|
171
|
+
if (this.searchFn && this._asyncOptions.length === 0) {
|
|
172
|
+
this.debouncedSearch('');
|
|
173
|
+
}
|
|
174
|
+
if (!this.isOpen) {
|
|
175
|
+
this.open();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
/** Handle input click */
|
|
180
|
+
this.handleInputClick = (event) => {
|
|
181
|
+
event.stopPropagation();
|
|
182
|
+
if (!this.isOpen && this.minChars === 0) {
|
|
183
|
+
if (this.searchFn && this._asyncOptions.length === 0) {
|
|
184
|
+
this.debouncedSearch('');
|
|
185
|
+
}
|
|
186
|
+
this.open();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
/** Handle tag remove in multi mode */
|
|
190
|
+
this.handleTagRemove = (event, value) => {
|
|
191
|
+
event.stopPropagation();
|
|
192
|
+
if (this.disabled)
|
|
193
|
+
return;
|
|
194
|
+
this.values = this.values.filter((item) => item !== value);
|
|
195
|
+
this.elementRef.dispatchEvent(new CustomEvent('ml:change', {
|
|
196
|
+
bubbles: true,
|
|
197
|
+
composed: true,
|
|
198
|
+
detail: { values: [...this.values], options: this.selectedOptions }
|
|
199
|
+
}));
|
|
200
|
+
};
|
|
201
|
+
/** Clear the current value (single mode) */
|
|
202
|
+
this.handleClear = (event) => {
|
|
203
|
+
event.stopPropagation();
|
|
204
|
+
if (this.disabled)
|
|
205
|
+
return;
|
|
206
|
+
this.value = '';
|
|
207
|
+
this.search = '';
|
|
208
|
+
this.elementRef.dispatchEvent(new CustomEvent('ml:change', {
|
|
209
|
+
bubbles: true,
|
|
210
|
+
composed: true,
|
|
211
|
+
detail: { value: '', option: null }
|
|
212
|
+
}));
|
|
213
|
+
// Focus the input after clearing
|
|
214
|
+
const input = this.elementRef.shadowRoot?.querySelector('.ml-autocomplete__input');
|
|
215
|
+
input?.focus();
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
onCreate() {
|
|
219
|
+
this.elementRef.addEventListener('keydown', this._handleKeyDown);
|
|
220
|
+
}
|
|
221
|
+
onDestroy() {
|
|
222
|
+
this.elementRef.removeEventListener('keydown', this._handleKeyDown);
|
|
223
|
+
this.removeScrollListener();
|
|
224
|
+
this.removeDocumentClickListener();
|
|
225
|
+
if (this._debounceTimer) {
|
|
226
|
+
clearTimeout(this._debounceTimer);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
onPropertyChange(name) {
|
|
230
|
+
if (this._syncingValues)
|
|
231
|
+
return;
|
|
232
|
+
if (name === 'multiple') {
|
|
233
|
+
if (this.multiple) {
|
|
234
|
+
if (!this.values.length && this.value) {
|
|
235
|
+
this.updateValues([this.value]);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (this.values.length) {
|
|
240
|
+
this.value = this.values[0] ?? '';
|
|
241
|
+
}
|
|
242
|
+
this.updateValues([]);
|
|
243
|
+
this.search = '';
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (name === 'values') {
|
|
247
|
+
const rawValues = this.values;
|
|
248
|
+
let normalized = [];
|
|
249
|
+
if (typeof rawValues === 'string') {
|
|
250
|
+
normalized = rawValues
|
|
251
|
+
.split(',')
|
|
252
|
+
.map((v) => v.trim())
|
|
253
|
+
.filter((v) => v.length > 0);
|
|
254
|
+
}
|
|
255
|
+
else if (Array.isArray(rawValues)) {
|
|
256
|
+
normalized = rawValues.filter((v) => typeof v === 'string');
|
|
257
|
+
}
|
|
258
|
+
normalized = Array.from(new Set(normalized));
|
|
259
|
+
if (!this.areValuesEqual(this.values, normalized)) {
|
|
260
|
+
this.updateValues(normalized);
|
|
261
|
+
}
|
|
262
|
+
if (!this.multiple) {
|
|
263
|
+
this.value = normalized[0] ?? '';
|
|
264
|
+
this.updateValues([]);
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (name === 'value' && this.multiple) {
|
|
269
|
+
if (this.value) {
|
|
270
|
+
const nextValues = Array.from(new Set([...this.values, this.value]));
|
|
271
|
+
if (!this.areValuesEqual(this.values, nextValues)) {
|
|
272
|
+
this.updateValues(nextValues);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Get the selected option (single mode) */
|
|
278
|
+
get selectedOption() {
|
|
279
|
+
return this.allOptions.find((opt) => opt.value === this.value);
|
|
280
|
+
}
|
|
281
|
+
/** Get selected options (multi mode) */
|
|
282
|
+
get selectedOptions() {
|
|
283
|
+
if (!this.multiple) {
|
|
284
|
+
return this.selectedOption ? [this.selectedOption] : [];
|
|
285
|
+
}
|
|
286
|
+
return this.allOptions.filter((opt) => this.values.includes(opt.value));
|
|
287
|
+
}
|
|
288
|
+
/** All available options (static or async) */
|
|
289
|
+
get allOptions() {
|
|
290
|
+
return this.searchFn ? this._asyncOptions : this.options;
|
|
291
|
+
}
|
|
292
|
+
/** Filtered options for display */
|
|
293
|
+
get filteredOptions() {
|
|
294
|
+
if (this.searchFn) {
|
|
295
|
+
return this._asyncOptions;
|
|
296
|
+
}
|
|
297
|
+
const query = this.search.trim().toLowerCase();
|
|
298
|
+
if (!query)
|
|
299
|
+
return this.options;
|
|
300
|
+
return this.options.filter((option) => {
|
|
301
|
+
const labelMatch = option.label.toLowerCase().includes(query);
|
|
302
|
+
const valueMatch = option.value.toLowerCase().includes(query);
|
|
303
|
+
const subtitleMatch = option.subtitle?.toLowerCase().includes(query) ?? false;
|
|
304
|
+
return labelMatch || valueMatch || subtitleMatch;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
get hasValue() {
|
|
308
|
+
return this.multiple ? this.values.length > 0 : !!this.value;
|
|
309
|
+
}
|
|
310
|
+
/** Display text for the input in single mode */
|
|
311
|
+
get displayText() {
|
|
312
|
+
if (this.multiple)
|
|
313
|
+
return '';
|
|
314
|
+
return this.selectedOption?.label || '';
|
|
315
|
+
}
|
|
316
|
+
toggleOption(option) {
|
|
317
|
+
const exists = this.values.includes(option.value);
|
|
318
|
+
this.values = exists ? this.values.filter((v) => v !== option.value) : [...this.values, option.value];
|
|
319
|
+
this.elementRef.dispatchEvent(new CustomEvent('ml:change', {
|
|
320
|
+
bubbles: true,
|
|
321
|
+
composed: true,
|
|
322
|
+
detail: { values: [...this.values], options: this.selectedOptions, option }
|
|
323
|
+
}));
|
|
324
|
+
// Clear search and refocus input
|
|
325
|
+
this.search = '';
|
|
326
|
+
const input = this.elementRef.shadowRoot?.querySelector('.ml-autocomplete__input');
|
|
327
|
+
input?.focus();
|
|
328
|
+
}
|
|
329
|
+
debouncedSearch(query) {
|
|
330
|
+
if (this._debounceTimer) {
|
|
331
|
+
clearTimeout(this._debounceTimer);
|
|
332
|
+
}
|
|
333
|
+
this._debounceTimer = setTimeout(() => {
|
|
334
|
+
this.executeSearch(query);
|
|
335
|
+
}, this.debounce);
|
|
336
|
+
}
|
|
337
|
+
async executeSearch(query) {
|
|
338
|
+
if (!this.searchFn)
|
|
339
|
+
return;
|
|
340
|
+
this._loading = true;
|
|
341
|
+
try {
|
|
342
|
+
this._asyncOptions = await this.searchFn(query);
|
|
343
|
+
this.focusedIndex = this.findFirstEnabledIndex();
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
this._loading = false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** Close dropdown on clicks outside the component */
|
|
350
|
+
onDocumentClick(event) {
|
|
351
|
+
const path = event.composedPath();
|
|
352
|
+
if (!path.includes(this.elementRef)) {
|
|
353
|
+
this.close();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
addDocumentClickListener() {
|
|
357
|
+
document.addEventListener('click', this._handleDocumentClick, true);
|
|
358
|
+
}
|
|
359
|
+
removeDocumentClickListener() {
|
|
360
|
+
document.removeEventListener('click', this._handleDocumentClick, true);
|
|
361
|
+
}
|
|
362
|
+
positionDropdown() {
|
|
363
|
+
const triggerEl = this.elementRef.shadowRoot?.querySelector('.ml-autocomplete__trigger');
|
|
364
|
+
const dropdownEl = this.getDropdownEl();
|
|
365
|
+
if (!triggerEl || !dropdownEl)
|
|
366
|
+
return;
|
|
367
|
+
dropdownEl.style.width = `${triggerEl.offsetWidth}px`;
|
|
368
|
+
const { x, y } = computePosition(triggerEl, dropdownEl, {
|
|
369
|
+
placement: 'bottom-start',
|
|
370
|
+
middleware: [offset(4), flip(), shift({ padding: 8 })]
|
|
371
|
+
});
|
|
372
|
+
dropdownEl.style.left = `${x}px`;
|
|
373
|
+
dropdownEl.style.top = `${y}px`;
|
|
374
|
+
}
|
|
375
|
+
addScrollListener() {
|
|
376
|
+
this._handleScroll = (event) => {
|
|
377
|
+
const dropdownEl = this.getDropdownEl();
|
|
378
|
+
if (dropdownEl?.contains(event.target))
|
|
379
|
+
return;
|
|
380
|
+
this.close();
|
|
381
|
+
};
|
|
382
|
+
window.addEventListener('scroll', this._handleScroll, true);
|
|
383
|
+
}
|
|
384
|
+
removeScrollListener() {
|
|
385
|
+
if (this._handleScroll) {
|
|
386
|
+
window.removeEventListener('scroll', this._handleScroll, true);
|
|
387
|
+
this._handleScroll = null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
getDropdownEl() {
|
|
391
|
+
return this.elementRef.shadowRoot?.querySelector('.ml-autocomplete__dropdown');
|
|
392
|
+
}
|
|
393
|
+
onKeyDown(event) {
|
|
394
|
+
if (this.disabled)
|
|
395
|
+
return;
|
|
396
|
+
switch (event.key) {
|
|
397
|
+
case 'Enter':
|
|
398
|
+
if (this.isOpen && this.focusedIndex >= 0) {
|
|
399
|
+
event.preventDefault();
|
|
400
|
+
const option = this.filteredOptions[this.focusedIndex];
|
|
401
|
+
if (option && !option.disabled) {
|
|
402
|
+
this.selectOption(option);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
break;
|
|
406
|
+
case 'Escape':
|
|
407
|
+
event.preventDefault();
|
|
408
|
+
this.close();
|
|
409
|
+
break;
|
|
410
|
+
case 'ArrowDown':
|
|
411
|
+
event.preventDefault();
|
|
412
|
+
if (!this.isOpen) {
|
|
413
|
+
this.open();
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
this.focusNextOption();
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
case 'ArrowUp':
|
|
420
|
+
event.preventDefault();
|
|
421
|
+
if (this.isOpen) {
|
|
422
|
+
this.focusPreviousOption();
|
|
423
|
+
}
|
|
424
|
+
break;
|
|
425
|
+
case 'Backspace':
|
|
426
|
+
if (this.multiple && this.search === '' && this.values.length > 0) {
|
|
427
|
+
const lastValue = this.values[this.values.length - 1];
|
|
428
|
+
this.values = this.values.slice(0, -1);
|
|
429
|
+
this.elementRef.dispatchEvent(new CustomEvent('ml:change', {
|
|
430
|
+
bubbles: true,
|
|
431
|
+
composed: true,
|
|
432
|
+
detail: { values: [...this.values], options: this.selectedOptions, removedValue: lastValue }
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
break;
|
|
436
|
+
case 'Tab':
|
|
437
|
+
this.close();
|
|
438
|
+
break;
|
|
439
|
+
default:
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
focusNextOption() {
|
|
444
|
+
const options = this.filteredOptions;
|
|
445
|
+
let index = this.focusedIndex + 1;
|
|
446
|
+
while (index < options.length) {
|
|
447
|
+
if (!options[index].disabled) {
|
|
448
|
+
this.focusedIndex = index;
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
index++;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
focusPreviousOption() {
|
|
455
|
+
const options = this.filteredOptions;
|
|
456
|
+
let index = this.focusedIndex - 1;
|
|
457
|
+
while (index >= 0) {
|
|
458
|
+
if (!options[index].disabled) {
|
|
459
|
+
this.focusedIndex = index;
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
index--;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
findFirstEnabledIndex() {
|
|
466
|
+
return this.filteredOptions.findIndex((opt) => !opt.disabled);
|
|
467
|
+
}
|
|
468
|
+
updateValues(values) {
|
|
469
|
+
this._syncingValues = true;
|
|
470
|
+
this.values = values;
|
|
471
|
+
this._syncingValues = false;
|
|
472
|
+
}
|
|
473
|
+
areValuesEqual(left, right) {
|
|
474
|
+
if (left.length !== right.length)
|
|
475
|
+
return false;
|
|
476
|
+
for (let i = 0; i < left.length; i++) {
|
|
477
|
+
if (left[i] !== right[i])
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
AutocompleteComponent = __decorate([
|
|
484
|
+
MelodicComponent({
|
|
485
|
+
selector: 'ml-autocomplete',
|
|
486
|
+
template: autocompleteTemplate,
|
|
487
|
+
styles: autocompleteStyles,
|
|
488
|
+
attributes: ['label', 'placeholder', 'hint', 'error', 'size', 'disabled', 'required', 'value', 'multiple']
|
|
489
|
+
})
|
|
490
|
+
], AutocompleteComponent);
|
|
491
|
+
export { AutocompleteComponent };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"autocomplete.styles.d.ts","sourceRoot":"","sources":["../../../../src/components/forms/autocomplete/autocomplete.styles.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,kBAAkB,iDAgW9B,CAAC"}
|