bard-tag_field 0.1.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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/Appraisals +13 -0
- data/Gemfile +2 -0
- data/LICENSE +21 -0
- data/README.md +230 -0
- data/Rakefile +18 -0
- data/app/assets/javascripts/input-tag.js +1827 -0
- data/bard-tag/.gitignore +2 -0
- data/bard-tag/bun.lockb +0 -0
- data/bard-tag/index.js +1 -0
- data/bard-tag/package.json +15 -0
- data/bard-tag/rollup.config.js +19 -0
- data/bard-tag_field.gemspec +40 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/gemfiles/rails_7.1.gemfile +7 -0
- data/gemfiles/rails_7.2.gemfile +7 -0
- data/gemfiles/rails_8.0.gemfile +7 -0
- data/lib/bard/tag_field/field.rb +80 -0
- data/lib/bard/tag_field/form_builder.rb +33 -0
- data/lib/bard/tag_field/version.rb +7 -0
- data/lib/bard/tag_field.rb +20 -0
- metadata +124 -0
@@ -0,0 +1,1827 @@
|
|
1
|
+
/**
|
2
|
+
* Taggle - dependency-less tagging library
|
3
|
+
* @author Sean Coker <hello@sean.is>
|
4
|
+
* @version 1.15.0 (modified)
|
5
|
+
* @license MIT
|
6
|
+
*/
|
7
|
+
|
8
|
+
/////////////////////
|
9
|
+
// Default options //
|
10
|
+
/////////////////////
|
11
|
+
|
12
|
+
const BACKSPACE = 8;
|
13
|
+
const DELETE = 46;
|
14
|
+
const COMMA = 188;
|
15
|
+
const TAB = 9;
|
16
|
+
const ENTER = 13;
|
17
|
+
|
18
|
+
const DEFAULTS = {
|
19
|
+
/**
|
20
|
+
* Class added to the container div when focused
|
21
|
+
* @type {String}
|
22
|
+
*/
|
23
|
+
containerFocusClass: 'active',
|
24
|
+
|
25
|
+
/**
|
26
|
+
* Spaces will be removed from the tags by default
|
27
|
+
* @type {Boolean}
|
28
|
+
*/
|
29
|
+
trimTags: true,
|
30
|
+
|
31
|
+
/**
|
32
|
+
* Limit the number of tags that can be added
|
33
|
+
* @type {Number}
|
34
|
+
*/
|
35
|
+
maxTags: null,
|
36
|
+
|
37
|
+
/**
|
38
|
+
* Placeholder string to be placed in an empty taggle field
|
39
|
+
* @type {String}
|
40
|
+
*/
|
41
|
+
placeholder: 'Enter tags...',
|
42
|
+
|
43
|
+
/**
|
44
|
+
* Keycodes that will add a tag
|
45
|
+
* @type {Array}
|
46
|
+
*/
|
47
|
+
submitKeys: [COMMA, TAB, ENTER],
|
48
|
+
|
49
|
+
/**
|
50
|
+
* Preserve case of tags being added ie
|
51
|
+
* "tag" is different than "Tag"
|
52
|
+
* @type {Boolean}
|
53
|
+
*/
|
54
|
+
preserveCase: false,
|
55
|
+
|
56
|
+
/**
|
57
|
+
* Function hook called when a tag is added
|
58
|
+
* @param {Event} event Event triggered when tag was added
|
59
|
+
* @param {String} tag The tag added
|
60
|
+
*/
|
61
|
+
onTagAdd: () => {},
|
62
|
+
|
63
|
+
/**
|
64
|
+
* Function hook called when a tag is removed
|
65
|
+
* @param {Event} event Event triggered when tag was removed
|
66
|
+
* @param {String} tag The tag removed
|
67
|
+
*/
|
68
|
+
onTagRemove: () => {}
|
69
|
+
};
|
70
|
+
|
71
|
+
//////////////////////
|
72
|
+
// Helper functions //
|
73
|
+
//////////////////////
|
74
|
+
function _clamp(val, min, max) {
|
75
|
+
return Math.min(Math.max(val, min), max);
|
76
|
+
}
|
77
|
+
|
78
|
+
/**
|
79
|
+
* Taggle ES6 Class - Modern tagging library
|
80
|
+
*/
|
81
|
+
class Taggle {
|
82
|
+
/**
|
83
|
+
* Constructor
|
84
|
+
* @param {Mixed} el ID of an element or the actual element
|
85
|
+
* @param {Object} options
|
86
|
+
*/
|
87
|
+
constructor(el, options) {
|
88
|
+
this.settings = Object.assign({}, DEFAULTS, options);
|
89
|
+
this.measurements = {
|
90
|
+
container: {
|
91
|
+
rect: null,
|
92
|
+
style: null,
|
93
|
+
padding: null
|
94
|
+
}
|
95
|
+
};
|
96
|
+
this.container = el;
|
97
|
+
this.tag = {
|
98
|
+
values: [],
|
99
|
+
elements: []
|
100
|
+
};
|
101
|
+
this.inputContainer = options.inputContainer;
|
102
|
+
this.input = document.createElement('input');
|
103
|
+
this.sizer = document.createElement('div');
|
104
|
+
this.pasting = false;
|
105
|
+
this.placeholder = null;
|
106
|
+
|
107
|
+
if (this.settings.placeholder) {
|
108
|
+
this.placeholder = document.createElement('span');
|
109
|
+
}
|
110
|
+
|
111
|
+
this._backspacePressed = false;
|
112
|
+
this._inputPosition = 0;
|
113
|
+
this._setMeasurements();
|
114
|
+
this._setupTextarea();
|
115
|
+
this._attachEvents();
|
116
|
+
}
|
117
|
+
|
118
|
+
/**
|
119
|
+
* Gets all the layout measurements up front
|
120
|
+
*/
|
121
|
+
_setMeasurements() {
|
122
|
+
this.measurements.container.rect = this.container.getBoundingClientRect();
|
123
|
+
const style = window.getComputedStyle(this.container);
|
124
|
+
this.measurements.container.style = style;
|
125
|
+
|
126
|
+
const lpad = parseInt(style.paddingLeft, 10);
|
127
|
+
const rpad = parseInt(style.paddingRight, 10);
|
128
|
+
const lborder = parseInt(style.borderLeftWidth, 10);
|
129
|
+
const rborder = parseInt(style.borderRightWidth, 10);
|
130
|
+
|
131
|
+
this.measurements.container.padding = lpad + rpad + lborder + rborder;
|
132
|
+
}
|
133
|
+
|
134
|
+
/**
|
135
|
+
* Setup the div container for tags to be entered
|
136
|
+
*/
|
137
|
+
_setupTextarea() {
|
138
|
+
this.input.type = 'text';
|
139
|
+
// Make sure no left/right padding messes with the input sizing
|
140
|
+
this.input.style.paddingLeft = 0;
|
141
|
+
this.input.style.paddingRight = 0;
|
142
|
+
this.input.className = 'taggle_input';
|
143
|
+
this.input.tabIndex = 1;
|
144
|
+
this.sizer.className = 'taggle_sizer';
|
145
|
+
|
146
|
+
[...this.container.children].filter(child => child.tagName === 'TAG-OPTION').forEach(tagOption => {
|
147
|
+
this.tag.values.push(tagOption.value);
|
148
|
+
this.tag.elements.push(tagOption);
|
149
|
+
this._inputPosition = _clamp(this._inputPosition + 1, 0, this.tag.values.length);
|
150
|
+
});
|
151
|
+
|
152
|
+
|
153
|
+
if (this.placeholder) {
|
154
|
+
this._hidePlaceholder();
|
155
|
+
this.placeholder.classList.add('taggle_placeholder');
|
156
|
+
this.container.appendChild(this.placeholder);
|
157
|
+
this.placeholder.textContent = this.settings.placeholder;
|
158
|
+
|
159
|
+
if (!this.tag.values.length) {
|
160
|
+
this._showPlaceholder();
|
161
|
+
}
|
162
|
+
}
|
163
|
+
|
164
|
+
|
165
|
+
const div = document.createElement('div');
|
166
|
+
div.appendChild(this.input);
|
167
|
+
div.appendChild(this.sizer);
|
168
|
+
this.inputContainer.appendChild(div);
|
169
|
+
const fontSize = window.getComputedStyle(this.input).fontSize;
|
170
|
+
this.sizer.style.fontSize = fontSize;
|
171
|
+
}
|
172
|
+
|
173
|
+
/**
|
174
|
+
* Attaches neccessary events
|
175
|
+
*/
|
176
|
+
_attachEvents() {
|
177
|
+
if (this._eventsAttached) {
|
178
|
+
return false;
|
179
|
+
}
|
180
|
+
this._eventsAttached = true;
|
181
|
+
|
182
|
+
this._handleContainerClick = () => this.input.focus();
|
183
|
+
this.container.addEventListener('click', this._handleContainerClick);
|
184
|
+
|
185
|
+
this._handleFocus = this._setFocusStateForContainer.bind(this);
|
186
|
+
this._handleBlur = this._blurEvent.bind(this);
|
187
|
+
this._handleKeydown = this._keydownEvents.bind(this);
|
188
|
+
this._handleKeyup = this._keyupEvents.bind(this);
|
189
|
+
|
190
|
+
this.input.addEventListener('focus', this._handleFocus);
|
191
|
+
this.input.addEventListener('blur', this._handleBlur);
|
192
|
+
this.input.addEventListener('keydown', this._handleKeydown);
|
193
|
+
this.input.addEventListener('keyup', this._handleKeyup);
|
194
|
+
|
195
|
+
return true;
|
196
|
+
}
|
197
|
+
|
198
|
+
_detachEvents() {
|
199
|
+
if (!this._eventsAttached) {
|
200
|
+
return false;
|
201
|
+
}
|
202
|
+
this._eventsAttached = false;
|
203
|
+
|
204
|
+
this.container.removeEventListener('click', this._handleContainerClick);
|
205
|
+
this.input.removeEventListener('focus', this._handleFocus);
|
206
|
+
this.input.removeEventListener('blur', this._handleBlur);
|
207
|
+
this.input.removeEventListener('keydown', this._handleKeydown);
|
208
|
+
this.input.removeEventListener('keyup', this._handleKeyup);
|
209
|
+
|
210
|
+
return true;
|
211
|
+
}
|
212
|
+
|
213
|
+
/**
|
214
|
+
* Returns whether or not the specified tag text can be added
|
215
|
+
* @param {Event} e event causing the potentially added tag
|
216
|
+
* @param {String} text tag value
|
217
|
+
* @return {Boolean}
|
218
|
+
*/
|
219
|
+
_canAdd(e, text) {
|
220
|
+
if (!text) {
|
221
|
+
return false;
|
222
|
+
}
|
223
|
+
const limit = this.settings.maxTags;
|
224
|
+
if (limit !== null && limit <= this.getTagValues().length) {
|
225
|
+
return false;
|
226
|
+
}
|
227
|
+
|
228
|
+
// Check for duplicates
|
229
|
+
return this.tag.values.indexOf(text) === -1;
|
230
|
+
}
|
231
|
+
|
232
|
+
/**
|
233
|
+
* Appends tag with its corresponding input to the list
|
234
|
+
* @param {Event} e
|
235
|
+
* @param {String} text
|
236
|
+
* @param {Number} index
|
237
|
+
*/
|
238
|
+
_add(e, text, index) {
|
239
|
+
let values = text || '';
|
240
|
+
const delimiter = ',';
|
241
|
+
|
242
|
+
if (typeof text !== 'string') {
|
243
|
+
values = this.input.value;
|
244
|
+
|
245
|
+
if (this.settings.trimTags) {
|
246
|
+
if (values[0] === delimiter) {
|
247
|
+
values = values.replace(delimiter, '');
|
248
|
+
}
|
249
|
+
values = values.trim();
|
250
|
+
}
|
251
|
+
}
|
252
|
+
|
253
|
+
values.split(delimiter).map(val => {
|
254
|
+
if (this.settings.trimTags) {
|
255
|
+
val = val.trim();
|
256
|
+
}
|
257
|
+
return this._formatTag(val);
|
258
|
+
}).forEach(val => {
|
259
|
+
if (!this._canAdd(e, val)) {
|
260
|
+
return;
|
261
|
+
}
|
262
|
+
|
263
|
+
const currentTagLength = this.tag.values.length;
|
264
|
+
const tagIndex = _clamp(index || currentTagLength, 0, currentTagLength);
|
265
|
+
const tagOption = this._createTag(val, tagIndex);
|
266
|
+
this.container.append(tagOption);
|
267
|
+
|
268
|
+
val = this.tag.values[tagIndex];
|
269
|
+
|
270
|
+
this.settings.onTagAdd(e, val);
|
271
|
+
|
272
|
+
this.input.value = '';
|
273
|
+
this._setMeasurements();
|
274
|
+
this._setInputWidth();
|
275
|
+
this._setFocusStateForContainer();
|
276
|
+
});
|
277
|
+
}
|
278
|
+
|
279
|
+
/**
|
280
|
+
* Removes last tag if it has already been probed
|
281
|
+
* @param {Event} e
|
282
|
+
*/
|
283
|
+
_checkPrevOrNextTag(e) {
|
284
|
+
const taggles = this.container.querySelectorAll('tag-option');
|
285
|
+
const prevTagIndex = _clamp(this._inputPosition - 1, 0, taggles.length - 1);
|
286
|
+
const nextTagIndex = _clamp(this._inputPosition, 0, taggles.length - 1);
|
287
|
+
let index = prevTagIndex;
|
288
|
+
|
289
|
+
if (e.keyCode === DELETE) {
|
290
|
+
index = nextTagIndex;
|
291
|
+
}
|
292
|
+
|
293
|
+
const targetTaggle = taggles[index];
|
294
|
+
const hotClass = 'taggle_hot';
|
295
|
+
const isDeleteOrBackspace = [BACKSPACE, DELETE].includes(e.keyCode);
|
296
|
+
|
297
|
+
// prevent holding backspace from deleting all tags
|
298
|
+
if (this.input.value === '' && isDeleteOrBackspace && !this._backspacePressed) {
|
299
|
+
if (targetTaggle.classList.contains(hotClass)) {
|
300
|
+
this._backspacePressed = true;
|
301
|
+
this._remove(targetTaggle, e);
|
302
|
+
this._setMeasurements();
|
303
|
+
this._setInputWidth();
|
304
|
+
this._setFocusStateForContainer();
|
305
|
+
}
|
306
|
+
else {
|
307
|
+
targetTaggle.classList.add(hotClass);
|
308
|
+
}
|
309
|
+
}
|
310
|
+
else if (targetTaggle.classList.contains(hotClass)) {
|
311
|
+
targetTaggle.classList.remove(hotClass);
|
312
|
+
}
|
313
|
+
}
|
314
|
+
|
315
|
+
/**
|
316
|
+
* Setter for the hidden input.
|
317
|
+
* @param {Number} width
|
318
|
+
*/
|
319
|
+
_setInputWidth() {
|
320
|
+
const width = this.sizer.getBoundingClientRect().width;
|
321
|
+
const max = this.measurements.container.rect.width - this.measurements.container.padding;
|
322
|
+
const size = parseInt(this.sizer.style.fontSize, 10);
|
323
|
+
|
324
|
+
// 1.5 just seems to be a good multiplier here
|
325
|
+
const newWidth = Math.round(_clamp(width + (size * 1.5), 10, max));
|
326
|
+
|
327
|
+
this.input.style.width = `${newWidth}px`;
|
328
|
+
}
|
329
|
+
|
330
|
+
/**
|
331
|
+
* Handles focus state of div container.
|
332
|
+
*/
|
333
|
+
_setFocusStateForContainer() {
|
334
|
+
this._setMeasurements();
|
335
|
+
this._setInputWidth();
|
336
|
+
|
337
|
+
if (!this.container.classList.contains(this.settings.containerFocusClass)) {
|
338
|
+
this.container.classList.add(this.settings.containerFocusClass);
|
339
|
+
}
|
340
|
+
|
341
|
+
this._hidePlaceholder();
|
342
|
+
}
|
343
|
+
|
344
|
+
/**
|
345
|
+
* Runs all the events that need to happen on a blur
|
346
|
+
* @param {Event} e
|
347
|
+
*/
|
348
|
+
_blurEvent(e) {
|
349
|
+
if (this.container.classList.contains(this.settings.containerFocusClass)) {
|
350
|
+
this.container.classList.remove(this.settings.containerFocusClass);
|
351
|
+
}
|
352
|
+
|
353
|
+
if (!this.tag.values.length && !this.input.value) {
|
354
|
+
this._showPlaceholder();
|
355
|
+
}
|
356
|
+
}
|
357
|
+
|
358
|
+
/**
|
359
|
+
* Runs all the events that need to run on keydown
|
360
|
+
* @param {Event} e
|
361
|
+
*/
|
362
|
+
_keydownEvents(e) {
|
363
|
+
const key = e.keyCode;
|
364
|
+
this.pasting = false;
|
365
|
+
|
366
|
+
this._setInputWidth();
|
367
|
+
|
368
|
+
if (key === 86 && e.metaKey) {
|
369
|
+
this.pasting = true;
|
370
|
+
}
|
371
|
+
|
372
|
+
if (this.settings.submitKeys.includes(key) && this.input.value !== '') {
|
373
|
+
this._confirmValidTagEvent(e);
|
374
|
+
return;
|
375
|
+
}
|
376
|
+
|
377
|
+
if (this.tag.values.length) {
|
378
|
+
this._checkPrevOrNextTag(e);
|
379
|
+
}
|
380
|
+
}
|
381
|
+
|
382
|
+
/**
|
383
|
+
* Runs all the events that need to run on keyup
|
384
|
+
* @param {Event} e
|
385
|
+
*/
|
386
|
+
_keyupEvents(e) {
|
387
|
+
this._backspacePressed = false;
|
388
|
+
|
389
|
+
this.sizer.textContent = this.input.value;
|
390
|
+
|
391
|
+
// If we break to a new line because the text is too long
|
392
|
+
// and decide to delete everything, we should resize the input
|
393
|
+
// so it falls back inline
|
394
|
+
if (!this.input.value) {
|
395
|
+
this._setInputWidth();
|
396
|
+
}
|
397
|
+
|
398
|
+
if (this.pasting && this.input.value !== '') {
|
399
|
+
this._add(e);
|
400
|
+
this.pasting = false;
|
401
|
+
}
|
402
|
+
}
|
403
|
+
|
404
|
+
/**
|
405
|
+
* Confirms the inputted value to be converted to a tag
|
406
|
+
* @param {Event} e
|
407
|
+
*/
|
408
|
+
_confirmValidTagEvent(e) {
|
409
|
+
// prevents from jumping out of textarea
|
410
|
+
e.preventDefault();
|
411
|
+
|
412
|
+
this._add(e, null, this._inputPosition);
|
413
|
+
}
|
414
|
+
|
415
|
+
_createTag(text, index) {
|
416
|
+
const tagOption = document.createElement('tag-option');
|
417
|
+
|
418
|
+
text = this._formatTag(text);
|
419
|
+
tagOption.textContent = text;
|
420
|
+
tagOption.setAttribute('value', text);
|
421
|
+
|
422
|
+
this.tag.values.splice(index, 0, text);
|
423
|
+
this.tag.elements.splice(index, 0, tagOption);
|
424
|
+
this._inputPosition = _clamp(this._inputPosition + 1, 0, this.tag.values.length);
|
425
|
+
|
426
|
+
return tagOption;
|
427
|
+
}
|
428
|
+
|
429
|
+
_showPlaceholder() {
|
430
|
+
if (this.placeholder) {
|
431
|
+
this.placeholder.style.opacity = 1;
|
432
|
+
this.placeholder.setAttribute('aria-hidden', 'false');
|
433
|
+
}
|
434
|
+
}
|
435
|
+
|
436
|
+
_hidePlaceholder() {
|
437
|
+
if (this.placeholder) {
|
438
|
+
this.placeholder.style.opacity = 0;
|
439
|
+
this.placeholder.setAttribute('aria-hidden', 'true');
|
440
|
+
}
|
441
|
+
}
|
442
|
+
|
443
|
+
/**
|
444
|
+
* Removes tag from the tags collection
|
445
|
+
* @param {HTMLElement} tagOption List item to remove
|
446
|
+
* @param {Event} e
|
447
|
+
*/
|
448
|
+
_remove(tagOption, e) {
|
449
|
+
const index = this.tag.elements.indexOf(tagOption);
|
450
|
+
if (index === -1) return;
|
451
|
+
|
452
|
+
const text = this.tag.values[index];
|
453
|
+
|
454
|
+
tagOption.remove();
|
455
|
+
this.tag.elements.splice(index, 1);
|
456
|
+
this.tag.values.splice(index, 1);
|
457
|
+
this.settings.onTagRemove(e, text);
|
458
|
+
|
459
|
+
if (index < this._inputPosition) {
|
460
|
+
this._inputPosition = _clamp(this._inputPosition - 1, 0, this.tag.values.length);
|
461
|
+
}
|
462
|
+
|
463
|
+
this._setFocusStateForContainer();
|
464
|
+
}
|
465
|
+
|
466
|
+
/**
|
467
|
+
* Format the text for a tag
|
468
|
+
* @param {String} text Tag text
|
469
|
+
* @return {String}
|
470
|
+
*/
|
471
|
+
_formatTag(text) {
|
472
|
+
return this.settings.preserveCase ? text : text.toLowerCase();
|
473
|
+
}
|
474
|
+
|
475
|
+
// @todo
|
476
|
+
// @deprecated use getTags().values
|
477
|
+
getTagValues() {
|
478
|
+
return [...this.tag.values];
|
479
|
+
}
|
480
|
+
|
481
|
+
getInput() {
|
482
|
+
return this.input;
|
483
|
+
}
|
484
|
+
|
485
|
+
add(text, index) {
|
486
|
+
const isArr = Array.isArray(text);
|
487
|
+
|
488
|
+
if (isArr) {
|
489
|
+
text.forEach((tag, i) => {
|
490
|
+
if (typeof tag === 'string') {
|
491
|
+
this._add(null, tag, index ? index + i : index);
|
492
|
+
}
|
493
|
+
});
|
494
|
+
}
|
495
|
+
else {
|
496
|
+
this._add(null, text, index);
|
497
|
+
}
|
498
|
+
|
499
|
+
return this;
|
500
|
+
}
|
501
|
+
|
502
|
+
remove(text) {
|
503
|
+
const index = this.tag.values.indexOf(text);
|
504
|
+
if (index > -1) {
|
505
|
+
this._remove(this.tag.elements[index]);
|
506
|
+
}
|
507
|
+
return this;
|
508
|
+
}
|
509
|
+
|
510
|
+
removeAll() {
|
511
|
+
[...this.tag.elements].forEach(element => this._remove(element));
|
512
|
+
this._showPlaceholder();
|
513
|
+
return this;
|
514
|
+
}
|
515
|
+
|
516
|
+
_setDisabledState(disabled) {
|
517
|
+
const elements = [
|
518
|
+
...this.container.querySelectorAll('button'),
|
519
|
+
...this.container.querySelectorAll('input')
|
520
|
+
];
|
521
|
+
|
522
|
+
elements.forEach((el) => {
|
523
|
+
if (disabled) {
|
524
|
+
el.setAttribute('disabled', '');
|
525
|
+
} else {
|
526
|
+
el.removeAttribute('disabled');
|
527
|
+
}
|
528
|
+
});
|
529
|
+
|
530
|
+
return this;
|
531
|
+
}
|
532
|
+
|
533
|
+
enable() {
|
534
|
+
return this._setDisabledState(false);
|
535
|
+
}
|
536
|
+
|
537
|
+
disable() {
|
538
|
+
return this._setDisabledState(true);
|
539
|
+
}
|
540
|
+
|
541
|
+
destroy() {
|
542
|
+
this._detachEvents();
|
543
|
+
}
|
544
|
+
}
|
545
|
+
|
546
|
+
/**
|
547
|
+
* Copyright (c) 2016 Denis Taran
|
548
|
+
*
|
549
|
+
* Homepage: https://smartscheduling.com/en/documentation/autocomplete
|
550
|
+
* Source: https://github.com/denis-taran/autocomplete
|
551
|
+
*
|
552
|
+
* MIT License
|
553
|
+
*/
|
554
|
+
function autocomplete(settings) {
|
555
|
+
// just an alias to minimize JS file size
|
556
|
+
var doc = document;
|
557
|
+
var container = settings.container || doc.createElement('div');
|
558
|
+
var preventSubmit = settings.preventSubmit || 0 /* Never */;
|
559
|
+
container.id = container.id || 'autocomplete-' + uid();
|
560
|
+
var containerStyle = container.style;
|
561
|
+
var debounceWaitMs = settings.debounceWaitMs || 0;
|
562
|
+
var disableAutoSelect = settings.disableAutoSelect || false;
|
563
|
+
var customContainerParent = container.parentElement;
|
564
|
+
var items = [];
|
565
|
+
var inputValue = '';
|
566
|
+
var minLen = 2;
|
567
|
+
var showOnFocus = settings.showOnFocus;
|
568
|
+
var selected;
|
569
|
+
var fetchCounter = 0;
|
570
|
+
var debounceTimer;
|
571
|
+
var destroyed = false;
|
572
|
+
// Fixes #104: autocomplete selection is broken on Firefox for Android
|
573
|
+
var suppressAutocomplete = false;
|
574
|
+
if (settings.minLength !== undefined) {
|
575
|
+
minLen = settings.minLength;
|
576
|
+
}
|
577
|
+
if (!settings.input) {
|
578
|
+
throw new Error('input undefined');
|
579
|
+
}
|
580
|
+
var input = settings.input;
|
581
|
+
container.className = [container.className, 'autocomplete', settings.className || ''].join(' ').trim();
|
582
|
+
container.setAttribute('role', 'listbox');
|
583
|
+
input.setAttribute('role', 'combobox');
|
584
|
+
input.setAttribute('aria-expanded', 'false');
|
585
|
+
input.setAttribute('aria-autocomplete', 'list');
|
586
|
+
input.setAttribute('aria-controls', container.id);
|
587
|
+
input.setAttribute('aria-owns', container.id);
|
588
|
+
input.setAttribute('aria-activedescendant', '');
|
589
|
+
input.setAttribute('aria-haspopup', 'listbox');
|
590
|
+
// IOS implementation for fixed positioning has many bugs, so we will use absolute positioning
|
591
|
+
containerStyle.position = 'absolute';
|
592
|
+
/**
|
593
|
+
* Generate a very complex textual ID that greatly reduces the chance of a collision with another ID or text.
|
594
|
+
*/
|
595
|
+
function uid() {
|
596
|
+
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
597
|
+
}
|
598
|
+
/**
|
599
|
+
* Detach the container from DOM
|
600
|
+
*/
|
601
|
+
function detach() {
|
602
|
+
var parent = container.parentNode;
|
603
|
+
if (parent) {
|
604
|
+
parent.removeChild(container);
|
605
|
+
}
|
606
|
+
}
|
607
|
+
/**
|
608
|
+
* Clear debouncing timer if assigned
|
609
|
+
*/
|
610
|
+
function clearDebounceTimer() {
|
611
|
+
if (debounceTimer) {
|
612
|
+
window.clearTimeout(debounceTimer);
|
613
|
+
}
|
614
|
+
}
|
615
|
+
/**
|
616
|
+
* Attach the container to DOM
|
617
|
+
*/
|
618
|
+
function attach() {
|
619
|
+
if (!container.parentNode) {
|
620
|
+
(customContainerParent || doc.body).appendChild(container);
|
621
|
+
}
|
622
|
+
}
|
623
|
+
/**
|
624
|
+
* Check if container for autocomplete is displayed
|
625
|
+
*/
|
626
|
+
function containerDisplayed() {
|
627
|
+
return !!container.parentNode;
|
628
|
+
}
|
629
|
+
/**
|
630
|
+
* Clear autocomplete state and hide container
|
631
|
+
*/
|
632
|
+
function clear() {
|
633
|
+
// prevent the update call if there are pending AJAX requests
|
634
|
+
fetchCounter++;
|
635
|
+
items = [];
|
636
|
+
inputValue = '';
|
637
|
+
selected = undefined;
|
638
|
+
input.setAttribute('aria-activedescendant', '');
|
639
|
+
input.setAttribute('aria-expanded', 'false');
|
640
|
+
detach();
|
641
|
+
}
|
642
|
+
/**
|
643
|
+
* Update autocomplete position
|
644
|
+
*/
|
645
|
+
function updatePosition() {
|
646
|
+
if (!containerDisplayed()) {
|
647
|
+
return;
|
648
|
+
}
|
649
|
+
input.setAttribute('aria-expanded', 'true');
|
650
|
+
containerStyle.height = 'auto';
|
651
|
+
containerStyle.width = input.offsetWidth + 'px';
|
652
|
+
var maxHeight = 0;
|
653
|
+
var inputRect;
|
654
|
+
function calc() {
|
655
|
+
var docEl = doc.documentElement;
|
656
|
+
var clientTop = docEl.clientTop || doc.body.clientTop || 0;
|
657
|
+
var clientLeft = docEl.clientLeft || doc.body.clientLeft || 0;
|
658
|
+
var scrollTop = window.pageYOffset || docEl.scrollTop;
|
659
|
+
var scrollLeft = window.pageXOffset || docEl.scrollLeft;
|
660
|
+
inputRect = input.getBoundingClientRect();
|
661
|
+
var top = inputRect.top + input.offsetHeight + scrollTop - clientTop;
|
662
|
+
var left = inputRect.left + scrollLeft - clientLeft;
|
663
|
+
containerStyle.top = top + 'px';
|
664
|
+
containerStyle.left = left + 'px';
|
665
|
+
maxHeight = window.innerHeight - (inputRect.top + input.offsetHeight);
|
666
|
+
if (maxHeight < 0) {
|
667
|
+
maxHeight = 0;
|
668
|
+
}
|
669
|
+
containerStyle.top = top + 'px';
|
670
|
+
containerStyle.bottom = '';
|
671
|
+
containerStyle.left = left + 'px';
|
672
|
+
containerStyle.maxHeight = maxHeight + 'px';
|
673
|
+
}
|
674
|
+
// the calc method must be called twice, otherwise the calculation may be wrong on resize event (chrome browser)
|
675
|
+
calc();
|
676
|
+
calc();
|
677
|
+
if (settings.customize && inputRect) {
|
678
|
+
settings.customize(input, inputRect, container, maxHeight);
|
679
|
+
}
|
680
|
+
}
|
681
|
+
/**
|
682
|
+
* Redraw the autocomplete div element with suggestions
|
683
|
+
*/
|
684
|
+
function update() {
|
685
|
+
container.textContent = '';
|
686
|
+
input.setAttribute('aria-activedescendant', '');
|
687
|
+
// function for rendering autocomplete suggestions
|
688
|
+
var render = function (item, _, __) {
|
689
|
+
var itemElement = doc.createElement('div');
|
690
|
+
itemElement.textContent = item.label || '';
|
691
|
+
return itemElement;
|
692
|
+
};
|
693
|
+
if (settings.render) {
|
694
|
+
render = settings.render;
|
695
|
+
}
|
696
|
+
// function to render autocomplete groups
|
697
|
+
var renderGroup = function (groupName, _) {
|
698
|
+
var groupDiv = doc.createElement('div');
|
699
|
+
groupDiv.textContent = groupName;
|
700
|
+
return groupDiv;
|
701
|
+
};
|
702
|
+
if (settings.renderGroup) {
|
703
|
+
renderGroup = settings.renderGroup;
|
704
|
+
}
|
705
|
+
var fragment = doc.createDocumentFragment();
|
706
|
+
var prevGroup = uid();
|
707
|
+
items.forEach(function (item, index) {
|
708
|
+
if (item.group && item.group !== prevGroup) {
|
709
|
+
prevGroup = item.group;
|
710
|
+
var groupDiv = renderGroup(item.group, inputValue);
|
711
|
+
if (groupDiv) {
|
712
|
+
groupDiv.className += ' group';
|
713
|
+
fragment.appendChild(groupDiv);
|
714
|
+
}
|
715
|
+
}
|
716
|
+
var div = render(item, inputValue, index);
|
717
|
+
if (div) {
|
718
|
+
div.id = container.id + "_" + index;
|
719
|
+
div.setAttribute('role', 'option');
|
720
|
+
div.addEventListener('click', function (ev) {
|
721
|
+
suppressAutocomplete = true;
|
722
|
+
try {
|
723
|
+
settings.onSelect(item, input);
|
724
|
+
}
|
725
|
+
finally {
|
726
|
+
suppressAutocomplete = false;
|
727
|
+
}
|
728
|
+
clear();
|
729
|
+
ev.preventDefault();
|
730
|
+
ev.stopPropagation();
|
731
|
+
});
|
732
|
+
if (item === selected) {
|
733
|
+
div.className += ' selected';
|
734
|
+
div.setAttribute('aria-selected', 'true');
|
735
|
+
input.setAttribute('aria-activedescendant', div.id);
|
736
|
+
}
|
737
|
+
fragment.appendChild(div);
|
738
|
+
}
|
739
|
+
});
|
740
|
+
container.appendChild(fragment);
|
741
|
+
if (items.length < 1) {
|
742
|
+
if (settings.emptyMsg) {
|
743
|
+
var empty = doc.createElement('div');
|
744
|
+
empty.id = container.id + "_" + uid();
|
745
|
+
empty.className = 'empty';
|
746
|
+
empty.textContent = settings.emptyMsg;
|
747
|
+
container.appendChild(empty);
|
748
|
+
input.setAttribute('aria-activedescendant', empty.id);
|
749
|
+
}
|
750
|
+
else {
|
751
|
+
clear();
|
752
|
+
return;
|
753
|
+
}
|
754
|
+
}
|
755
|
+
attach();
|
756
|
+
updatePosition();
|
757
|
+
updateScroll();
|
758
|
+
}
|
759
|
+
function updateIfDisplayed() {
|
760
|
+
if (containerDisplayed()) {
|
761
|
+
update();
|
762
|
+
}
|
763
|
+
}
|
764
|
+
function resizeEventHandler() {
|
765
|
+
updateIfDisplayed();
|
766
|
+
}
|
767
|
+
function scrollEventHandler(e) {
|
768
|
+
if (e.target !== container) {
|
769
|
+
updateIfDisplayed();
|
770
|
+
}
|
771
|
+
else {
|
772
|
+
e.preventDefault();
|
773
|
+
}
|
774
|
+
}
|
775
|
+
function inputEventHandler() {
|
776
|
+
if (!suppressAutocomplete) {
|
777
|
+
fetch(0 /* Keyboard */);
|
778
|
+
}
|
779
|
+
}
|
780
|
+
/**
|
781
|
+
* Automatically move scroll bar if selected item is not visible
|
782
|
+
*/
|
783
|
+
function updateScroll() {
|
784
|
+
var elements = container.getElementsByClassName('selected');
|
785
|
+
if (elements.length > 0) {
|
786
|
+
var element = elements[0];
|
787
|
+
// make group visible
|
788
|
+
var previous = element.previousElementSibling;
|
789
|
+
if (previous && previous.className.indexOf('group') !== -1 && !previous.previousElementSibling) {
|
790
|
+
element = previous;
|
791
|
+
}
|
792
|
+
if (element.offsetTop < container.scrollTop) {
|
793
|
+
container.scrollTop = element.offsetTop;
|
794
|
+
}
|
795
|
+
else {
|
796
|
+
var selectBottom = element.offsetTop + element.offsetHeight;
|
797
|
+
var containerBottom = container.scrollTop + container.offsetHeight;
|
798
|
+
if (selectBottom > containerBottom) {
|
799
|
+
container.scrollTop += selectBottom - containerBottom;
|
800
|
+
}
|
801
|
+
}
|
802
|
+
}
|
803
|
+
}
|
804
|
+
function selectPreviousSuggestion() {
|
805
|
+
var index = items.indexOf(selected);
|
806
|
+
selected = index === -1
|
807
|
+
? undefined
|
808
|
+
: items[(index + items.length - 1) % items.length];
|
809
|
+
updateSelectedSuggestion(index);
|
810
|
+
}
|
811
|
+
function selectNextSuggestion() {
|
812
|
+
var index = items.indexOf(selected);
|
813
|
+
selected = items.length < 1
|
814
|
+
? undefined
|
815
|
+
: index === -1
|
816
|
+
? items[0]
|
817
|
+
: items[(index + 1) % items.length];
|
818
|
+
updateSelectedSuggestion(index);
|
819
|
+
}
|
820
|
+
function updateSelectedSuggestion(index) {
|
821
|
+
if (items.length > 0) {
|
822
|
+
unselectSuggestion(index);
|
823
|
+
selectSuggestion(items.indexOf(selected));
|
824
|
+
updateScroll();
|
825
|
+
}
|
826
|
+
}
|
827
|
+
function selectSuggestion(index) {
|
828
|
+
var element = doc.getElementById(container.id + "_" + index);
|
829
|
+
if (element) {
|
830
|
+
element.classList.add('selected');
|
831
|
+
element.setAttribute('aria-selected', 'true');
|
832
|
+
input.setAttribute('aria-activedescendant', element.id);
|
833
|
+
}
|
834
|
+
}
|
835
|
+
function unselectSuggestion(index) {
|
836
|
+
var element = doc.getElementById(container.id + "_" + index);
|
837
|
+
if (element) {
|
838
|
+
element.classList.remove('selected');
|
839
|
+
element.removeAttribute('aria-selected');
|
840
|
+
input.removeAttribute('aria-activedescendant');
|
841
|
+
}
|
842
|
+
}
|
843
|
+
function handleArrowAndEscapeKeys(ev, key) {
|
844
|
+
var containerIsDisplayed = containerDisplayed();
|
845
|
+
if (key === 'Escape') {
|
846
|
+
clear();
|
847
|
+
}
|
848
|
+
else {
|
849
|
+
if (!containerIsDisplayed || items.length < 1) {
|
850
|
+
return;
|
851
|
+
}
|
852
|
+
key === 'ArrowUp'
|
853
|
+
? selectPreviousSuggestion()
|
854
|
+
: selectNextSuggestion();
|
855
|
+
}
|
856
|
+
ev.preventDefault();
|
857
|
+
if (containerIsDisplayed) {
|
858
|
+
ev.stopPropagation();
|
859
|
+
}
|
860
|
+
}
|
861
|
+
function handleEnterKey(ev) {
|
862
|
+
if (selected) {
|
863
|
+
if (preventSubmit === 2 /* OnSelect */) {
|
864
|
+
ev.preventDefault();
|
865
|
+
}
|
866
|
+
suppressAutocomplete = true;
|
867
|
+
try {
|
868
|
+
settings.onSelect(selected, input);
|
869
|
+
}
|
870
|
+
finally {
|
871
|
+
suppressAutocomplete = false;
|
872
|
+
}
|
873
|
+
clear();
|
874
|
+
}
|
875
|
+
if (preventSubmit === 1 /* Always */) {
|
876
|
+
ev.preventDefault();
|
877
|
+
}
|
878
|
+
}
|
879
|
+
function keydownEventHandler(ev) {
|
880
|
+
var key = ev.key;
|
881
|
+
switch (key) {
|
882
|
+
case 'ArrowUp':
|
883
|
+
case 'ArrowDown':
|
884
|
+
case 'Escape':
|
885
|
+
handleArrowAndEscapeKeys(ev, key);
|
886
|
+
break;
|
887
|
+
case 'Enter':
|
888
|
+
handleEnterKey(ev);
|
889
|
+
break;
|
890
|
+
}
|
891
|
+
}
|
892
|
+
function focusEventHandler() {
|
893
|
+
if (showOnFocus) {
|
894
|
+
fetch(1 /* Focus */);
|
895
|
+
}
|
896
|
+
}
|
897
|
+
function fetch(trigger) {
|
898
|
+
if (input.value.length >= minLen || trigger === 1 /* Focus */) {
|
899
|
+
clearDebounceTimer();
|
900
|
+
debounceTimer = window.setTimeout(function () { return startFetch(input.value, trigger, input.selectionStart || 0); }, trigger === 0 /* Keyboard */ || trigger === 2 /* Mouse */ ? debounceWaitMs : 0);
|
901
|
+
}
|
902
|
+
else {
|
903
|
+
clear();
|
904
|
+
}
|
905
|
+
}
|
906
|
+
function startFetch(inputText, trigger, cursorPos) {
|
907
|
+
if (destroyed)
|
908
|
+
return;
|
909
|
+
var savedFetchCounter = ++fetchCounter;
|
910
|
+
settings.fetch(inputText, function (elements) {
|
911
|
+
if (fetchCounter === savedFetchCounter && elements) {
|
912
|
+
items = elements;
|
913
|
+
inputValue = inputText;
|
914
|
+
selected = (items.length < 1 || disableAutoSelect) ? undefined : items[0];
|
915
|
+
update();
|
916
|
+
}
|
917
|
+
}, trigger, cursorPos);
|
918
|
+
}
|
919
|
+
function keyupEventHandler(e) {
|
920
|
+
if (settings.keyup) {
|
921
|
+
settings.keyup({
|
922
|
+
event: e,
|
923
|
+
fetch: function () { return fetch(0 /* Keyboard */); }
|
924
|
+
});
|
925
|
+
return;
|
926
|
+
}
|
927
|
+
if (!containerDisplayed() && e.key === 'ArrowDown') {
|
928
|
+
fetch(0 /* Keyboard */);
|
929
|
+
}
|
930
|
+
}
|
931
|
+
function clickEventHandler(e) {
|
932
|
+
settings.click && settings.click({
|
933
|
+
event: e,
|
934
|
+
fetch: function () { return fetch(2 /* Mouse */); }
|
935
|
+
});
|
936
|
+
}
|
937
|
+
function blurEventHandler() {
|
938
|
+
// when an item is selected by mouse click, the blur event will be initiated before the click event and remove DOM elements,
|
939
|
+
// so that the click event will never be triggered. In order to avoid this issue, DOM removal should be delayed.
|
940
|
+
setTimeout(function () {
|
941
|
+
if (doc.activeElement !== input) {
|
942
|
+
clear();
|
943
|
+
}
|
944
|
+
}, 200);
|
945
|
+
}
|
946
|
+
function manualFetch() {
|
947
|
+
startFetch(input.value, 3 /* Manual */, input.selectionStart || 0);
|
948
|
+
}
|
949
|
+
/**
|
950
|
+
* Fixes #26: on long clicks focus will be lost and onSelect method will not be called
|
951
|
+
*/
|
952
|
+
container.addEventListener('mousedown', function (evt) {
|
953
|
+
evt.stopPropagation();
|
954
|
+
evt.preventDefault();
|
955
|
+
});
|
956
|
+
/**
|
957
|
+
* Fixes #30: autocomplete closes when scrollbar is clicked in IE
|
958
|
+
* See: https://stackoverflow.com/a/9210267/13172349
|
959
|
+
*/
|
960
|
+
container.addEventListener('focus', function () { return input.focus(); });
|
961
|
+
// If the custom autocomplete container is already appended to the DOM during widget initialization, detach it.
|
962
|
+
detach();
|
963
|
+
/**
|
964
|
+
* This function will remove DOM elements and clear event handlers
|
965
|
+
*/
|
966
|
+
function destroy() {
|
967
|
+
input.removeEventListener('focus', focusEventHandler);
|
968
|
+
input.removeEventListener('keyup', keyupEventHandler);
|
969
|
+
input.removeEventListener('click', clickEventHandler);
|
970
|
+
input.removeEventListener('keydown', keydownEventHandler);
|
971
|
+
input.removeEventListener('input', inputEventHandler);
|
972
|
+
input.removeEventListener('blur', blurEventHandler);
|
973
|
+
window.removeEventListener('resize', resizeEventHandler);
|
974
|
+
doc.removeEventListener('scroll', scrollEventHandler, true);
|
975
|
+
input.removeAttribute('role');
|
976
|
+
input.removeAttribute('aria-expanded');
|
977
|
+
input.removeAttribute('aria-autocomplete');
|
978
|
+
input.removeAttribute('aria-controls');
|
979
|
+
input.removeAttribute('aria-activedescendant');
|
980
|
+
input.removeAttribute('aria-owns');
|
981
|
+
input.removeAttribute('aria-haspopup');
|
982
|
+
clearDebounceTimer();
|
983
|
+
clear();
|
984
|
+
destroyed = true;
|
985
|
+
}
|
986
|
+
// setup event handlers
|
987
|
+
input.addEventListener('keyup', keyupEventHandler);
|
988
|
+
input.addEventListener('click', clickEventHandler);
|
989
|
+
input.addEventListener('keydown', keydownEventHandler);
|
990
|
+
input.addEventListener('input', inputEventHandler);
|
991
|
+
input.addEventListener('blur', blurEventHandler);
|
992
|
+
input.addEventListener('focus', focusEventHandler);
|
993
|
+
window.addEventListener('resize', resizeEventHandler);
|
994
|
+
doc.addEventListener('scroll', scrollEventHandler, true);
|
995
|
+
return {
|
996
|
+
destroy: destroy,
|
997
|
+
fetch: manualFetch
|
998
|
+
};
|
999
|
+
}
|
1000
|
+
|
1001
|
+
class TagOption extends HTMLElement {
|
1002
|
+
constructor() {
|
1003
|
+
super();
|
1004
|
+
this._shadowRoot = this.attachShadow({ mode: "open" });
|
1005
|
+
}
|
1006
|
+
|
1007
|
+
connectedCallback() {
|
1008
|
+
this._shadowRoot.innerHTML = `
|
1009
|
+
<style>
|
1010
|
+
:host {
|
1011
|
+
background: #588a00;
|
1012
|
+
padding: 3px 10px 3px 10px !important;
|
1013
|
+
margin-right: 4px !important;
|
1014
|
+
margin-bottom: 2px !important;
|
1015
|
+
display: inline-flex;
|
1016
|
+
align-items: center;
|
1017
|
+
float: none;
|
1018
|
+
font-size: 1.25em;
|
1019
|
+
line-height: 1;
|
1020
|
+
min-height: 32px;
|
1021
|
+
color: #fff;
|
1022
|
+
text-transform: none;
|
1023
|
+
border-radius: 3px;
|
1024
|
+
position: relative;
|
1025
|
+
cursor: pointer;
|
1026
|
+
}
|
1027
|
+
button {
|
1028
|
+
z-index: 1;
|
1029
|
+
border: none;
|
1030
|
+
background: none;
|
1031
|
+
font-size: 1.4em;
|
1032
|
+
display: inline-block;
|
1033
|
+
color: rgba(255, 255, 255, 0.6);
|
1034
|
+
right: 10px;
|
1035
|
+
height: 100%;
|
1036
|
+
cursor: pointer;
|
1037
|
+
}
|
1038
|
+
</style>
|
1039
|
+
<slot></slot>
|
1040
|
+
<button type="button">×</button>
|
1041
|
+
`;
|
1042
|
+
|
1043
|
+
this.buttonTarget = this._shadowRoot.querySelector("button");
|
1044
|
+
this.buttonTarget.onclick = event => {
|
1045
|
+
this.parentNode._taggle._remove(this, event);
|
1046
|
+
};
|
1047
|
+
}
|
1048
|
+
|
1049
|
+
get value() {
|
1050
|
+
return this.getAttribute("value") || this.innerText
|
1051
|
+
}
|
1052
|
+
|
1053
|
+
get label() {
|
1054
|
+
return this.innerText
|
1055
|
+
}
|
1056
|
+
}
|
1057
|
+
customElements.define("tag-option", TagOption);
|
1058
|
+
|
1059
|
+
|
1060
|
+
class InputTag extends HTMLElement {
|
1061
|
+
static get formAssociated() {
|
1062
|
+
return true;
|
1063
|
+
}
|
1064
|
+
|
1065
|
+
static get observedAttributes() {
|
1066
|
+
return ['name', 'multiple', 'required', 'list'];
|
1067
|
+
}
|
1068
|
+
|
1069
|
+
constructor() {
|
1070
|
+
super();
|
1071
|
+
this._internals = this.attachInternals();
|
1072
|
+
this._shadowRoot = this.attachShadow({ mode: "open" });
|
1073
|
+
|
1074
|
+
this.observer = new MutationObserver(mutations => {
|
1075
|
+
let needsTagOptionsUpdate = false;
|
1076
|
+
let needsAutocompleteUpdate = false;
|
1077
|
+
|
1078
|
+
for (const mutation of mutations) {
|
1079
|
+
if (mutation.type === 'childList') {
|
1080
|
+
const addedRemovedNodes = [...mutation.addedNodes, ...mutation.removedNodes];
|
1081
|
+
if (addedRemovedNodes.some(node => node.tagName === 'TAG-OPTION')) {
|
1082
|
+
needsTagOptionsUpdate = true;
|
1083
|
+
}
|
1084
|
+
if (addedRemovedNodes.some(node => node.tagName === 'DATALIST')) {
|
1085
|
+
needsAutocompleteUpdate = true;
|
1086
|
+
}
|
1087
|
+
} else if (mutation.type === 'attributes') {
|
1088
|
+
// Handle attribute changes on tag-option elements
|
1089
|
+
if (mutation.target !== this && mutation.target.tagName === 'TAG-OPTION') {
|
1090
|
+
needsTagOptionsUpdate = true;
|
1091
|
+
}
|
1092
|
+
}
|
1093
|
+
}
|
1094
|
+
|
1095
|
+
if (needsTagOptionsUpdate || needsAutocompleteUpdate) {
|
1096
|
+
this.unobserve();
|
1097
|
+
if (needsTagOptionsUpdate) {
|
1098
|
+
this.processTagOptions();
|
1099
|
+
}
|
1100
|
+
if (needsAutocompleteUpdate && this.initialized) {
|
1101
|
+
this.setupAutocomplete();
|
1102
|
+
}
|
1103
|
+
this.observe();
|
1104
|
+
}
|
1105
|
+
});
|
1106
|
+
}
|
1107
|
+
|
1108
|
+
unobserve() {
|
1109
|
+
this.observer.disconnect();
|
1110
|
+
}
|
1111
|
+
|
1112
|
+
observe() {
|
1113
|
+
this.observer.observe(this, {
|
1114
|
+
childList: true,
|
1115
|
+
attributes: true,
|
1116
|
+
subtree: true,
|
1117
|
+
attributeFilter: ["value"],
|
1118
|
+
});
|
1119
|
+
}
|
1120
|
+
|
1121
|
+
processTagOptions() {
|
1122
|
+
if(!this._taggle || !this._taggle.tag) return
|
1123
|
+
const tagOptions = Array.from(this.children).filter(e => e.tagName === 'TAG-OPTION');
|
1124
|
+
const values = tagOptions.map(e => e.value).filter(value => value !== null && value !== undefined);
|
1125
|
+
this._taggle.tag.elements = tagOptions;
|
1126
|
+
this._taggle.tag.values = values;
|
1127
|
+
this._inputPosition = this._taggle.tag.values.length;
|
1128
|
+
|
1129
|
+
// Update the taggle display elements to match the current values
|
1130
|
+
const taggleElements = this._taggle.tag.elements;
|
1131
|
+
taggleElements.forEach((element, index) => {
|
1132
|
+
if (element && element.setAttribute) {
|
1133
|
+
element.setAttribute('data-value', values[index]);
|
1134
|
+
}
|
1135
|
+
});
|
1136
|
+
|
1137
|
+
// Update internal value to match
|
1138
|
+
this.updateValue();
|
1139
|
+
}
|
1140
|
+
|
1141
|
+
get form() {
|
1142
|
+
return this._internals.form;
|
1143
|
+
}
|
1144
|
+
|
1145
|
+
get name() {
|
1146
|
+
return this.getAttribute("name");
|
1147
|
+
}
|
1148
|
+
|
1149
|
+
get value() {
|
1150
|
+
return this._internals.value;
|
1151
|
+
}
|
1152
|
+
|
1153
|
+
set value(values) {
|
1154
|
+
const oldValues = this._internals.value;
|
1155
|
+
this._internals.value = values;
|
1156
|
+
|
1157
|
+
const formData = new FormData();
|
1158
|
+
values.forEach(value => formData.append(this.name, value));
|
1159
|
+
// For single mode, append empty string when no values (like standard HTML inputs)
|
1160
|
+
// For multiple mode, leave empty (like standard HTML multiple selects)
|
1161
|
+
if (values.length === 0 && !this.hasAttribute('multiple')) {
|
1162
|
+
formData.append(this.name, "");
|
1163
|
+
}
|
1164
|
+
this._internals.setFormValue(formData);
|
1165
|
+
|
1166
|
+
// Update taggle to match the new values
|
1167
|
+
if (this._taggle && this.initialized) {
|
1168
|
+
this.suppressEvents = true; // Prevent infinite loops
|
1169
|
+
this._taggle.removeAll();
|
1170
|
+
if (values.length > 0) {
|
1171
|
+
this._taggle.add(values);
|
1172
|
+
}
|
1173
|
+
this.suppressEvents = false;
|
1174
|
+
}
|
1175
|
+
|
1176
|
+
if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) {
|
1177
|
+
this.dispatchEvent(new CustomEvent("change", {
|
1178
|
+
bubbles: true,
|
1179
|
+
composed: true,
|
1180
|
+
}));
|
1181
|
+
}
|
1182
|
+
}
|
1183
|
+
|
1184
|
+
reset() {
|
1185
|
+
this._taggle.removeAll();
|
1186
|
+
this._taggleInputTarget.value = '';
|
1187
|
+
}
|
1188
|
+
|
1189
|
+
get options() {
|
1190
|
+
const datalistId = this.getAttribute("list");
|
1191
|
+
if(datalistId) {
|
1192
|
+
const datalist = document.getElementById(datalistId);
|
1193
|
+
if(datalist) {
|
1194
|
+
return [...datalist.options].map(option => option.value).filter(value => value !== null && value !== undefined)
|
1195
|
+
}
|
1196
|
+
}
|
1197
|
+
|
1198
|
+
// Fall back to nested datalist
|
1199
|
+
const nestedDatalist = this.querySelector('datalist');
|
1200
|
+
if(nestedDatalist) {
|
1201
|
+
return [...nestedDatalist.options].map(option => option.hasAttribute('value') ? option.value : option.textContent).filter(value => value !== null && value !== undefined)
|
1202
|
+
}
|
1203
|
+
|
1204
|
+
return []
|
1205
|
+
}
|
1206
|
+
|
1207
|
+
_getOptionsWithLabels() {
|
1208
|
+
const datalistId = this.getAttribute("list");
|
1209
|
+
if(datalistId) {
|
1210
|
+
const datalist = document.getElementById(datalistId);
|
1211
|
+
if(datalist) {
|
1212
|
+
return [...datalist.options].map(option => ({
|
1213
|
+
value: option.value,
|
1214
|
+
label: option.textContent || option.value
|
1215
|
+
})).filter(item => item.value !== null && item.value !== undefined)
|
1216
|
+
}
|
1217
|
+
}
|
1218
|
+
|
1219
|
+
// Fall back to nested datalist
|
1220
|
+
const nestedDatalist = this.querySelector('datalist');
|
1221
|
+
if(nestedDatalist) {
|
1222
|
+
return [...nestedDatalist.options].map(option => ({
|
1223
|
+
value: option.hasAttribute('value') ? option.value : option.textContent,
|
1224
|
+
label: option.textContent || option.value
|
1225
|
+
})).filter(item => item.value !== null && item.value !== undefined)
|
1226
|
+
}
|
1227
|
+
|
1228
|
+
return []
|
1229
|
+
}
|
1230
|
+
|
1231
|
+
async connectedCallback() {
|
1232
|
+
this.setAttribute('tabindex', '0');
|
1233
|
+
this.addEventListener("focus", e => this.focus(e));
|
1234
|
+
|
1235
|
+
// Wait for child tag-option elements to be fully connected
|
1236
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
1237
|
+
|
1238
|
+
this._shadowRoot.innerHTML = `
|
1239
|
+
<style>
|
1240
|
+
:host { display: block; }
|
1241
|
+
:host *{
|
1242
|
+
position: relative;
|
1243
|
+
box-sizing: border-box;
|
1244
|
+
margin: 0;
|
1245
|
+
padding: 0;
|
1246
|
+
}
|
1247
|
+
#container {
|
1248
|
+
background: rgba(255, 255, 255, 0.8);
|
1249
|
+
padding: 6px 6px 3px;
|
1250
|
+
max-height: none;
|
1251
|
+
display: flex;
|
1252
|
+
margin: 0;
|
1253
|
+
flex-wrap: wrap;
|
1254
|
+
align-items: flex-start;
|
1255
|
+
min-height: 48px;
|
1256
|
+
line-height: 48px;
|
1257
|
+
width: 100%;
|
1258
|
+
border: 1px solid #d0d0d0;
|
1259
|
+
outline: 1px solid transparent;
|
1260
|
+
box-shadow: #ccc 0 1px 4px 0 inset;
|
1261
|
+
border-radius: 2px;
|
1262
|
+
cursor: text;
|
1263
|
+
color: #333;
|
1264
|
+
list-style: none;
|
1265
|
+
padding-right: 32px;
|
1266
|
+
}
|
1267
|
+
input {
|
1268
|
+
display: block;
|
1269
|
+
height: 32px;
|
1270
|
+
float: none;
|
1271
|
+
margin: 0;
|
1272
|
+
padding-left: 10px !important;
|
1273
|
+
padding-right: 30px !important;
|
1274
|
+
width: auto !important;
|
1275
|
+
min-width: 70px;
|
1276
|
+
font-size: 1.25em;
|
1277
|
+
width: 100%;
|
1278
|
+
line-height: 2;
|
1279
|
+
padding: 0 0 0 10px;
|
1280
|
+
border: 1px dashed #d0d0d0;
|
1281
|
+
outline: 1px solid transparent;
|
1282
|
+
background: #fff;
|
1283
|
+
box-shadow: none;
|
1284
|
+
border-radius: 2px;
|
1285
|
+
cursor: text;
|
1286
|
+
color: #333;
|
1287
|
+
}
|
1288
|
+
button {
|
1289
|
+
width: 30px;
|
1290
|
+
text-align: center;
|
1291
|
+
line-height: 30px;
|
1292
|
+
border: 1px solid #e0e0e0;
|
1293
|
+
font-size: 2em;
|
1294
|
+
color: #666;
|
1295
|
+
position: absolute !important;
|
1296
|
+
z-index: 10;
|
1297
|
+
right: 0px;
|
1298
|
+
top: 0;
|
1299
|
+
font-weight: 400;
|
1300
|
+
cursor: pointer;
|
1301
|
+
background: none;
|
1302
|
+
}
|
1303
|
+
.taggle_sizer{
|
1304
|
+
padding: 0;
|
1305
|
+
margin: 0;
|
1306
|
+
position: absolute;
|
1307
|
+
top: -500px;
|
1308
|
+
z-index: -1;
|
1309
|
+
visibility: hidden;
|
1310
|
+
}
|
1311
|
+
.ui-autocomplete{
|
1312
|
+
position: static !important;
|
1313
|
+
width: 100% !important;
|
1314
|
+
margin-top: 2px;
|
1315
|
+
}
|
1316
|
+
.ui-menu{
|
1317
|
+
margin: 0;
|
1318
|
+
padding: 6px;
|
1319
|
+
box-shadow: #ccc 0 1px 6px;
|
1320
|
+
z-index: 2;
|
1321
|
+
display: flex;
|
1322
|
+
flex-wrap: wrap;
|
1323
|
+
background: #fff;
|
1324
|
+
list-style: none;
|
1325
|
+
font-size: 1.25em;
|
1326
|
+
min-width: 200px;
|
1327
|
+
}
|
1328
|
+
.ui-menu .ui-menu-item{
|
1329
|
+
display: inline-block;
|
1330
|
+
margin: 0 0 2px;
|
1331
|
+
line-height: 30px;
|
1332
|
+
border: none;
|
1333
|
+
padding: 0 10px;
|
1334
|
+
text-indent: 0;
|
1335
|
+
border-radius: 2px;
|
1336
|
+
width: auto;
|
1337
|
+
cursor: pointer;
|
1338
|
+
color: #555;
|
1339
|
+
}
|
1340
|
+
.ui-menu .ui-menu-item::before{ display: none; }
|
1341
|
+
.ui-menu .ui-menu-item:hover{ background: #e0e0e0; }
|
1342
|
+
.ui-state-active{
|
1343
|
+
padding: 0;
|
1344
|
+
border: none;
|
1345
|
+
background: none;
|
1346
|
+
color: inherit;
|
1347
|
+
}
|
1348
|
+
</style>
|
1349
|
+
<div style="position: relative;">
|
1350
|
+
<div id="container">
|
1351
|
+
<slot></slot>
|
1352
|
+
</div>
|
1353
|
+
<input
|
1354
|
+
id="inputTarget"
|
1355
|
+
type="hidden"
|
1356
|
+
name="${this.name}"
|
1357
|
+
/>
|
1358
|
+
</div>
|
1359
|
+
`;
|
1360
|
+
|
1361
|
+
this.form?.addEventListener("reset", this.reset.bind(this));
|
1362
|
+
|
1363
|
+
this.containerTarget = this.shadowRoot.querySelector("#container");
|
1364
|
+
this.inputTarget = this.shadowRoot.querySelector("#inputTarget");
|
1365
|
+
|
1366
|
+
this.required = this.hasAttribute("required");
|
1367
|
+
this.multiple = this.hasAttribute("multiple");
|
1368
|
+
|
1369
|
+
const maxTags = this.multiple ? undefined : 1;
|
1370
|
+
const placeholder = this.inputTarget.getAttribute("placeholder");
|
1371
|
+
|
1372
|
+
this.inputTarget.value = "";
|
1373
|
+
this.inputTarget.id = "";
|
1374
|
+
|
1375
|
+
this._taggle = new Taggle(this, {
|
1376
|
+
inputContainer: this.containerTarget,
|
1377
|
+
preserveCase: true,
|
1378
|
+
hiddenInputName: this.name,
|
1379
|
+
maxTags: maxTags,
|
1380
|
+
placeholder: placeholder,
|
1381
|
+
onTagAdd: (event, tag) => this.onTagAdd(event, tag),
|
1382
|
+
onTagRemove: (event, tag) => this.onTagRemove(event, tag),
|
1383
|
+
});
|
1384
|
+
this._taggleInputTarget = this._taggle.getInput();
|
1385
|
+
this._taggleInputTarget.id = this.id;
|
1386
|
+
this._taggleInputTarget.autocomplete = "off";
|
1387
|
+
this._taggleInputTarget.setAttribute("data-turbo-permanent", true);
|
1388
|
+
this._taggleInputTarget.addEventListener("keyup", e => this.keyup(e));
|
1389
|
+
|
1390
|
+
// Set initial value after taggle is initialized
|
1391
|
+
this.value = this._taggle.getTagValues();
|
1392
|
+
|
1393
|
+
this.checkRequired();
|
1394
|
+
|
1395
|
+
this.buttonTarget = h(`<button class="add">+</button>`);
|
1396
|
+
this.buttonTarget.addEventListener("click", e => this._add(e));
|
1397
|
+
this._taggleInputTarget.insertAdjacentElement("afterend", this.buttonTarget);
|
1398
|
+
|
1399
|
+
this.autocompleteContainerTarget = h(`<ul>`);
|
1400
|
+
// Insert autocomplete container into the positioned wrapper div
|
1401
|
+
const wrapperDiv = this.shadowRoot.querySelector('div[style*="position: relative"]');
|
1402
|
+
wrapperDiv.appendChild(this.autocompleteContainerTarget);
|
1403
|
+
|
1404
|
+
this.setupAutocomplete();
|
1405
|
+
|
1406
|
+
this.observe(); // Start observing after taggle is set up
|
1407
|
+
this.initialized = true;
|
1408
|
+
|
1409
|
+
// Update visibility based on current state
|
1410
|
+
this.updateInputVisibility();
|
1411
|
+
}
|
1412
|
+
|
1413
|
+
setupAutocomplete() {
|
1414
|
+
const optionsWithLabels = this._getOptionsWithLabels();
|
1415
|
+
|
1416
|
+
autocomplete({
|
1417
|
+
input: this._taggleInputTarget,
|
1418
|
+
container: this.autocompleteContainerTarget,
|
1419
|
+
className: "ui-menu ui-autocomplete",
|
1420
|
+
fetch: (text, update) => {
|
1421
|
+
const currentTags = this._taggle.getTagValues();
|
1422
|
+
const suggestions = optionsWithLabels.filter(option =>
|
1423
|
+
option.label.toLowerCase().includes(text.toLowerCase()) &&
|
1424
|
+
!currentTags.includes(option.value)
|
1425
|
+
);
|
1426
|
+
// Store the suggestions for testing (can't assign to getter, tests read from DOM)
|
1427
|
+
update(suggestions);
|
1428
|
+
},
|
1429
|
+
render: item => h(`<li class="ui-menu-item" data-value="${item.value}">${item.label}</li>`),
|
1430
|
+
onSelect: item => {
|
1431
|
+
// Create a tag-option element with proper value/label separation
|
1432
|
+
const tagOption = document.createElement('tag-option');
|
1433
|
+
tagOption.setAttribute('value', item.value);
|
1434
|
+
tagOption.textContent = item.label;
|
1435
|
+
this.appendChild(tagOption);
|
1436
|
+
|
1437
|
+
// Clear input
|
1438
|
+
this._taggleInputTarget.value = '';
|
1439
|
+
},
|
1440
|
+
minLength: 1,
|
1441
|
+
customize: (input, inputRect, container, maxHeight) => {
|
1442
|
+
// Position autocomplete below the input-tag container, accounting for dynamic height
|
1443
|
+
this._updateAutocompletePosition(container);
|
1444
|
+
|
1445
|
+
// Store reference to update positioning when container height changes
|
1446
|
+
this._autocompleteContainer = container;
|
1447
|
+
}
|
1448
|
+
});
|
1449
|
+
}
|
1450
|
+
|
1451
|
+
disconnectedCallback() {
|
1452
|
+
this.form?.removeEventListener("reset", this.reset.bind(this));
|
1453
|
+
this.unobserve();
|
1454
|
+
}
|
1455
|
+
|
1456
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
1457
|
+
if (oldValue === newValue) return;
|
1458
|
+
|
1459
|
+
// Only handle changes after the component is connected and initialized
|
1460
|
+
if (!this._taggle) return;
|
1461
|
+
|
1462
|
+
switch (name) {
|
1463
|
+
case 'name':
|
1464
|
+
this.handleNameChange(newValue);
|
1465
|
+
break;
|
1466
|
+
case 'multiple':
|
1467
|
+
this.handleMultipleChange(newValue !== null);
|
1468
|
+
break;
|
1469
|
+
case 'required':
|
1470
|
+
this.handleRequiredChange(newValue !== null);
|
1471
|
+
break;
|
1472
|
+
case 'list':
|
1473
|
+
this.handleListChange(newValue);
|
1474
|
+
break;
|
1475
|
+
}
|
1476
|
+
}
|
1477
|
+
|
1478
|
+
checkRequired() {
|
1479
|
+
const flag = this.required && this._taggle.getTagValues().length == 0;
|
1480
|
+
this._taggleInputTarget.required = flag;
|
1481
|
+
|
1482
|
+
// Update ElementInternals validity to match internal input
|
1483
|
+
if (flag) {
|
1484
|
+
this._internals.setValidity({ valueMissing: true }, 'Please fill out this field.', this._taggleInputTarget);
|
1485
|
+
} else {
|
1486
|
+
this._internals.setValidity({});
|
1487
|
+
}
|
1488
|
+
}
|
1489
|
+
|
1490
|
+
// monkeypatch support for android comma
|
1491
|
+
keyup(event) {
|
1492
|
+
const key = event.which || event.keyCode;
|
1493
|
+
const normalKeyboard = key != 229;
|
1494
|
+
if(normalKeyboard) return
|
1495
|
+
const value = this._taggleInputTarget.value;
|
1496
|
+
|
1497
|
+
// backspace
|
1498
|
+
if(value.length == 0) {
|
1499
|
+
const values = this._taggle.tag.values;
|
1500
|
+
this._taggle.remove(values[values.length - 1]);
|
1501
|
+
return
|
1502
|
+
}
|
1503
|
+
|
1504
|
+
// comma
|
1505
|
+
if(/,$/.test(value)) {
|
1506
|
+
const tag = value.replace(',', '');
|
1507
|
+
this._taggle.add(tag);
|
1508
|
+
this._taggleInputTarget.value = '';
|
1509
|
+
return
|
1510
|
+
}
|
1511
|
+
}
|
1512
|
+
|
1513
|
+
_add(event) {
|
1514
|
+
event.preventDefault();
|
1515
|
+
this._taggle.add(this._taggleInputTarget.value);
|
1516
|
+
this._taggleInputTarget.value = '';
|
1517
|
+
}
|
1518
|
+
|
1519
|
+
onTagAdd(event, tag) {
|
1520
|
+
if (!this.suppressEvents) {
|
1521
|
+
const isNew = !this.options.includes(tag);
|
1522
|
+
this.dispatchEvent(new CustomEvent("update", {
|
1523
|
+
detail: { tag, isNew },
|
1524
|
+
bubbles: true,
|
1525
|
+
composed: true,
|
1526
|
+
}));
|
1527
|
+
}
|
1528
|
+
this.syncValue();
|
1529
|
+
this.checkRequired();
|
1530
|
+
this.updateInputVisibility();
|
1531
|
+
|
1532
|
+
// Update autocomplete position if it's currently open
|
1533
|
+
if (this._autocompleteContainer) {
|
1534
|
+
// Use setTimeout to allow DOM to update first
|
1535
|
+
setTimeout(() => this._updateAutocompletePosition(this._autocompleteContainer), 0);
|
1536
|
+
}
|
1537
|
+
}
|
1538
|
+
|
1539
|
+
onTagRemove(event, tag) {
|
1540
|
+
if (!this.suppressEvents) {
|
1541
|
+
this.dispatchEvent(new CustomEvent("update", {
|
1542
|
+
detail: { tag },
|
1543
|
+
bubbles: true,
|
1544
|
+
composed: true,
|
1545
|
+
}));
|
1546
|
+
}
|
1547
|
+
this.syncValue();
|
1548
|
+
this.checkRequired();
|
1549
|
+
this.updateInputVisibility();
|
1550
|
+
|
1551
|
+
// Update autocomplete position if it's currently open
|
1552
|
+
if (this._autocompleteContainer) {
|
1553
|
+
// Use setTimeout to allow DOM to update first
|
1554
|
+
setTimeout(() => this._updateAutocompletePosition(this._autocompleteContainer), 0);
|
1555
|
+
}
|
1556
|
+
}
|
1557
|
+
|
1558
|
+
syncValue() {
|
1559
|
+
// Directly update internals without triggering the setter
|
1560
|
+
const values = this._taggle.getTagValues();
|
1561
|
+
const oldValues = this._internals.value;
|
1562
|
+
this._internals.value = values;
|
1563
|
+
|
1564
|
+
const formData = new FormData();
|
1565
|
+
values.forEach(value => formData.append(this.name, value));
|
1566
|
+
// For single mode, append empty string when no values (like standard HTML inputs)
|
1567
|
+
// For multiple mode, leave empty (like standard HTML multiple selects)
|
1568
|
+
if (values.length === 0 && !this.hasAttribute('multiple')) {
|
1569
|
+
formData.append(this.name, "");
|
1570
|
+
}
|
1571
|
+
this._internals.setFormValue(formData);
|
1572
|
+
|
1573
|
+
if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) {
|
1574
|
+
this.dispatchEvent(new CustomEvent("change", {
|
1575
|
+
bubbles: true,
|
1576
|
+
composed: true,
|
1577
|
+
}));
|
1578
|
+
}
|
1579
|
+
}
|
1580
|
+
|
1581
|
+
// Public API methods
|
1582
|
+
add(tags) {
|
1583
|
+
if (!this._taggle) return
|
1584
|
+
this._taggle.add(tags);
|
1585
|
+
}
|
1586
|
+
|
1587
|
+
remove(tag) {
|
1588
|
+
if (!this._taggle) return
|
1589
|
+
this._taggle.remove(tag);
|
1590
|
+
}
|
1591
|
+
|
1592
|
+
removeAll() {
|
1593
|
+
if (!this._taggle) return
|
1594
|
+
this._taggle.removeAll();
|
1595
|
+
}
|
1596
|
+
|
1597
|
+
has(tag) {
|
1598
|
+
if (!this._taggle) return false
|
1599
|
+
return this._taggle.getTagValues().includes(tag)
|
1600
|
+
}
|
1601
|
+
|
1602
|
+
get tags() {
|
1603
|
+
if (!this._taggle) return []
|
1604
|
+
return this._taggle.getTagValues()
|
1605
|
+
}
|
1606
|
+
|
1607
|
+
// Private getter for testing autocomplete suggestions
|
1608
|
+
get _autocompleteSuggestions() {
|
1609
|
+
if (!this.autocompleteContainerTarget) return []
|
1610
|
+
const items = this.autocompleteContainerTarget.querySelectorAll('.ui-menu-item');
|
1611
|
+
return Array.from(items).map(item => item.textContent.trim())
|
1612
|
+
}
|
1613
|
+
|
1614
|
+
// Update autocomplete position based on current container height
|
1615
|
+
_updateAutocompletePosition(container) {
|
1616
|
+
if (!container) return
|
1617
|
+
|
1618
|
+
const inputTagRect = this.containerTarget.getBoundingClientRect();
|
1619
|
+
|
1620
|
+
container.style.setProperty('position', 'absolute', 'important');
|
1621
|
+
container.style.setProperty('top', `${inputTagRect.height}px`, 'important');
|
1622
|
+
container.style.setProperty('left', '0', 'important');
|
1623
|
+
container.style.setProperty('right', '0', 'important');
|
1624
|
+
container.style.setProperty('width', '100%', 'important');
|
1625
|
+
container.style.setProperty('z-index', '1000', 'important');
|
1626
|
+
}
|
1627
|
+
|
1628
|
+
updateInputVisibility() {
|
1629
|
+
if (!this._taggleInputTarget || !this.buttonTarget) return;
|
1630
|
+
|
1631
|
+
const isMultiple = this.hasAttribute('multiple');
|
1632
|
+
const hasTags = this._taggle && this._taggle.getTagValues().length > 0;
|
1633
|
+
|
1634
|
+
if (isMultiple) {
|
1635
|
+
// Multiple mode: always show input and button
|
1636
|
+
this._taggleInputTarget.style.display = '';
|
1637
|
+
this.buttonTarget.style.display = '';
|
1638
|
+
} else {
|
1639
|
+
// Single mode: hide input and button when tag exists
|
1640
|
+
if (hasTags) {
|
1641
|
+
this._taggleInputTarget.style.display = 'none';
|
1642
|
+
this.buttonTarget.style.display = 'none';
|
1643
|
+
} else {
|
1644
|
+
this._taggleInputTarget.style.display = '';
|
1645
|
+
this.buttonTarget.style.display = '';
|
1646
|
+
}
|
1647
|
+
}
|
1648
|
+
}
|
1649
|
+
|
1650
|
+
addAt(tag, index) {
|
1651
|
+
if (!this._taggle) return
|
1652
|
+
this._taggle.add(tag, index);
|
1653
|
+
}
|
1654
|
+
|
1655
|
+
disable() {
|
1656
|
+
if (this._taggle) {
|
1657
|
+
this._taggle.disable();
|
1658
|
+
}
|
1659
|
+
}
|
1660
|
+
|
1661
|
+
enable() {
|
1662
|
+
if (this._taggle) {
|
1663
|
+
this._taggle.enable();
|
1664
|
+
}
|
1665
|
+
}
|
1666
|
+
|
1667
|
+
focus() {
|
1668
|
+
if (this._taggleInputTarget) {
|
1669
|
+
this._taggleInputTarget.focus();
|
1670
|
+
}
|
1671
|
+
}
|
1672
|
+
|
1673
|
+
checkValidity() {
|
1674
|
+
if (this._taggle) {
|
1675
|
+
this.checkRequired();
|
1676
|
+
}
|
1677
|
+
return this._internals.checkValidity()
|
1678
|
+
}
|
1679
|
+
|
1680
|
+
reportValidity() {
|
1681
|
+
if (this._taggle) {
|
1682
|
+
this.checkRequired();
|
1683
|
+
}
|
1684
|
+
return this._internals.reportValidity()
|
1685
|
+
}
|
1686
|
+
|
1687
|
+
handleNameChange(newName) {
|
1688
|
+
// Update the hidden input name to match
|
1689
|
+
const hiddenInput = this._shadowRoot.querySelector('input[type="hidden"]');
|
1690
|
+
if (hiddenInput) {
|
1691
|
+
hiddenInput.name = newName || '';
|
1692
|
+
}
|
1693
|
+
|
1694
|
+
// Update the form value with the new name
|
1695
|
+
if (this._internals.value) {
|
1696
|
+
this.value = this._internals.value; // This will recreate FormData with new name
|
1697
|
+
}
|
1698
|
+
}
|
1699
|
+
|
1700
|
+
handleMultipleChange(isMultiple) {
|
1701
|
+
if (!this._taggle) return;
|
1702
|
+
|
1703
|
+
// Update the internal multiple state
|
1704
|
+
this.multiple = isMultiple;
|
1705
|
+
|
1706
|
+
// Get current tags
|
1707
|
+
const currentTags = this._taggle.getTagValues();
|
1708
|
+
|
1709
|
+
if (!isMultiple && currentTags.length > 1) {
|
1710
|
+
// Single mode: remove excess tag-option elements from DOM
|
1711
|
+
const tagOptions = Array.from(this.children);
|
1712
|
+
// Keep only the first tag-option element, remove the rest
|
1713
|
+
tagOptions.forEach((tagOption, i) => {
|
1714
|
+
if (i > 0 && tagOption) {
|
1715
|
+
this.removeChild(tagOption);
|
1716
|
+
}
|
1717
|
+
});
|
1718
|
+
}
|
1719
|
+
|
1720
|
+
// Reinitialize taggle with new multiple setting
|
1721
|
+
this.reinitializeTaggle();
|
1722
|
+
|
1723
|
+
// Restore tags, respecting the new multiple constraint
|
1724
|
+
if (isMultiple) {
|
1725
|
+
// Multiple mode: restore all remaining tags
|
1726
|
+
if (currentTags.length > 0) {
|
1727
|
+
this._taggle.add(currentTags);
|
1728
|
+
}
|
1729
|
+
} else {
|
1730
|
+
// Single mode: keep only the first tag
|
1731
|
+
if (currentTags.length > 0) {
|
1732
|
+
this._taggle.add(currentTags[0]);
|
1733
|
+
}
|
1734
|
+
}
|
1735
|
+
|
1736
|
+
this.updateValue();
|
1737
|
+
this.updateInputVisibility();
|
1738
|
+
}
|
1739
|
+
|
1740
|
+
handleRequiredChange(isRequired) {
|
1741
|
+
if (!this._taggle) return;
|
1742
|
+
|
1743
|
+
// Update the internal required state
|
1744
|
+
this.required = isRequired;
|
1745
|
+
|
1746
|
+
// Update validation
|
1747
|
+
this.checkRequired();
|
1748
|
+
}
|
1749
|
+
|
1750
|
+
handleListChange(newListId) {
|
1751
|
+
if (!this._taggle) return;
|
1752
|
+
|
1753
|
+
// Re-setup autocomplete with new datalist
|
1754
|
+
this.setupAutocomplete();
|
1755
|
+
}
|
1756
|
+
|
1757
|
+
reinitializeTaggle() {
|
1758
|
+
// Clean up existing taggle if it exists
|
1759
|
+
if (this._taggle && this._taggle.destroy) {
|
1760
|
+
this._taggle.destroy();
|
1761
|
+
}
|
1762
|
+
|
1763
|
+
// Get current configuration
|
1764
|
+
const maxTags = this.hasAttribute("multiple") ? undefined : 1;
|
1765
|
+
const placeholder = this.getAttribute("placeholder") || "";
|
1766
|
+
|
1767
|
+
// Create new taggle instance using original configuration pattern
|
1768
|
+
this._taggle = new Taggle(this, {
|
1769
|
+
inputContainer: this.containerTarget,
|
1770
|
+
preserveCase: true,
|
1771
|
+
hiddenInputName: this.name,
|
1772
|
+
maxTags: maxTags,
|
1773
|
+
placeholder: placeholder,
|
1774
|
+
onTagAdd: (event, tag) => this.onTagAdd(event, tag),
|
1775
|
+
onTagRemove: (event, tag) => this.onTagRemove(event, tag),
|
1776
|
+
});
|
1777
|
+
|
1778
|
+
// Re-get references since taggle was recreated
|
1779
|
+
this._taggleInputTarget = this._taggle.getInput();
|
1780
|
+
this._taggleInputTarget.id = this.id || "";
|
1781
|
+
this._taggleInputTarget.autocomplete = "off";
|
1782
|
+
this._taggleInputTarget.setAttribute("data-turbo-permanent", true);
|
1783
|
+
this._taggleInputTarget.addEventListener("keyup", e => this.keyup(e));
|
1784
|
+
|
1785
|
+
// Re-setup autocomplete
|
1786
|
+
this.setupAutocomplete();
|
1787
|
+
|
1788
|
+
// Re-process existing tag options
|
1789
|
+
this.processTagOptions();
|
1790
|
+
}
|
1791
|
+
|
1792
|
+
updateValue() {
|
1793
|
+
if (!this._taggle) return;
|
1794
|
+
|
1795
|
+
// Update the internal value to match taggle state
|
1796
|
+
const values = this._taggle.getTagValues();
|
1797
|
+
const oldValues = this._internals.value;
|
1798
|
+
this._internals.value = values;
|
1799
|
+
|
1800
|
+
const formData = new FormData();
|
1801
|
+
values.forEach(value => formData.append(this.name, value));
|
1802
|
+
// For single mode, append empty string when no values (like standard HTML inputs)
|
1803
|
+
// For multiple mode, leave empty (like standard HTML multiple selects)
|
1804
|
+
if (values.length === 0 && !this.hasAttribute('multiple')) {
|
1805
|
+
formData.append(this.name, "");
|
1806
|
+
}
|
1807
|
+
this._internals.setFormValue(formData);
|
1808
|
+
|
1809
|
+
// Check validity after updating
|
1810
|
+
this.checkRequired();
|
1811
|
+
|
1812
|
+
if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) {
|
1813
|
+
this.dispatchEvent(new CustomEvent("change", {
|
1814
|
+
bubbles: true,
|
1815
|
+
composed: true,
|
1816
|
+
}));
|
1817
|
+
}
|
1818
|
+
}
|
1819
|
+
}
|
1820
|
+
customElements.define("input-tag", InputTag);
|
1821
|
+
|
1822
|
+
|
1823
|
+
function h(html) {
|
1824
|
+
const container = document.createElement("div");
|
1825
|
+
container.innerHTML = html;
|
1826
|
+
return container.firstElementChild
|
1827
|
+
}
|