bard-tag_field 0.5.0 → 0.5.2
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 +4 -4
- data/CLAUDE.md +9 -8
- data/Rakefile +4 -2
- data/app/assets/javascripts/input-tag.js +1 -0
- data/bard-tag_field.gemspec +5 -0
- data/cucumber.yml +1 -0
- data/input-tag/.gitignore +6 -2
- data/input-tag/CLAUDE.md +87 -0
- data/input-tag/LICENSE +21 -0
- data/input-tag/README.md +135 -0
- data/input-tag/TESTING.md +99 -0
- data/input-tag/bun.lock +821 -0
- data/input-tag/index.html +331 -0
- data/input-tag/package.json +52 -8
- data/input-tag/rollup.config.js +4 -4
- data/input-tag/src/input-tag.js +849 -0
- data/input-tag/src/taggle.js +546 -0
- data/input-tag/test/api-methods.test.js +684 -0
- data/input-tag/test/autocomplete.test.js +615 -0
- data/input-tag/test/basic-functionality.test.js +567 -0
- data/input-tag/test/dom-mutation.test.js +466 -0
- data/input-tag/test/edge-cases.test.js +524 -0
- data/input-tag/test/events.test.js +425 -0
- data/input-tag/test/form-integration.test.js +447 -0
- data/input-tag/test/input-tag.test.js +90 -0
- data/input-tag/test/lib/fail-only.mjs +24 -0
- data/input-tag/test/lib/test-utils.js +187 -0
- data/input-tag/test/nested-datalist.test.js +328 -0
- data/input-tag/test/value-label-separation.test.js +357 -0
- data/input-tag/web-test-runner.config.mjs +20 -0
- data/lib/bard/tag_field/cucumber.rb +13 -2
- data/lib/bard/tag_field/field.rb +3 -2
- data/lib/bard/tag_field/version.rb +1 -1
- metadata +97 -7
- data/app/assets/javascripts/input-tag.js +0 -1859
- data/input-tag/bun.lockb +0 -0
- data/input-tag/index.js +0 -1
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import Taggle from "./taggle.js"
|
|
2
|
+
import autocomplete from "autocompleter"
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TagOption extends HTMLElement {
|
|
6
|
+
constructor() {
|
|
7
|
+
super();
|
|
8
|
+
this._shadowRoot = this.attachShadow({ mode: "open" });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
connectedCallback() {
|
|
12
|
+
this._shadowRoot.innerHTML = `
|
|
13
|
+
<style>
|
|
14
|
+
:host {
|
|
15
|
+
background: #588a00;
|
|
16
|
+
padding: 3px 10px 3px 10px !important;
|
|
17
|
+
margin-right: 4px !important;
|
|
18
|
+
margin-bottom: 2px !important;
|
|
19
|
+
display: inline-flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
float: none;
|
|
22
|
+
font-size: 14px;
|
|
23
|
+
line-height: 1;
|
|
24
|
+
min-height: 32px;
|
|
25
|
+
color: #fff;
|
|
26
|
+
text-transform: none;
|
|
27
|
+
border-radius: 3px;
|
|
28
|
+
position: relative;
|
|
29
|
+
cursor: pointer;
|
|
30
|
+
}
|
|
31
|
+
button {
|
|
32
|
+
z-index: 1;
|
|
33
|
+
border: none;
|
|
34
|
+
background: none;
|
|
35
|
+
font-size: 20px;
|
|
36
|
+
display: inline-block;
|
|
37
|
+
color: rgba(255, 255, 255, 0.6);
|
|
38
|
+
right: 10px;
|
|
39
|
+
height: 100%;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
}
|
|
42
|
+
</style>
|
|
43
|
+
<slot></slot>
|
|
44
|
+
<button type="button">×</button>
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
this.buttonTarget = this._shadowRoot.querySelector("button")
|
|
48
|
+
this.buttonTarget.onclick = event => {
|
|
49
|
+
this.parentNode._taggle._remove(this, event)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get value() {
|
|
54
|
+
return this.getAttribute("value") || this.innerText
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get label() {
|
|
58
|
+
return this.innerText
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
customElements.define("tag-option", TagOption);
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class InputTag extends HTMLElement {
|
|
65
|
+
static get formAssociated() {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static get observedAttributes() {
|
|
70
|
+
return ['name', 'multiple', 'required', 'list'];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
constructor() {
|
|
74
|
+
super();
|
|
75
|
+
this._internals = this.attachInternals();
|
|
76
|
+
this._shadowRoot = this.attachShadow({ mode: "open" });
|
|
77
|
+
|
|
78
|
+
this.observer = new MutationObserver(mutations => {
|
|
79
|
+
let needsTagOptionsUpdate = false;
|
|
80
|
+
let needsAutocompleteUpdate = false;
|
|
81
|
+
|
|
82
|
+
for (const mutation of mutations) {
|
|
83
|
+
if (mutation.type === 'childList') {
|
|
84
|
+
const addedRemovedNodes = [...mutation.addedNodes, ...mutation.removedNodes]
|
|
85
|
+
if (addedRemovedNodes.some(node => node.tagName === 'TAG-OPTION')) {
|
|
86
|
+
needsTagOptionsUpdate = true;
|
|
87
|
+
}
|
|
88
|
+
if (addedRemovedNodes.some(node => node.tagName === 'DATALIST')) {
|
|
89
|
+
needsAutocompleteUpdate = true;
|
|
90
|
+
}
|
|
91
|
+
} else if (mutation.type === 'attributes') {
|
|
92
|
+
// Handle attribute changes on tag-option elements
|
|
93
|
+
if (mutation.target !== this && mutation.target.tagName === 'TAG-OPTION') {
|
|
94
|
+
needsTagOptionsUpdate = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (needsTagOptionsUpdate || needsAutocompleteUpdate) {
|
|
100
|
+
this.unobserve();
|
|
101
|
+
if (needsTagOptionsUpdate) {
|
|
102
|
+
this.processTagOptions();
|
|
103
|
+
}
|
|
104
|
+
if (needsAutocompleteUpdate && this.initialized) {
|
|
105
|
+
this.setupAutocomplete();
|
|
106
|
+
}
|
|
107
|
+
this.observe();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
unobserve() {
|
|
113
|
+
this.observer.disconnect();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
observe() {
|
|
117
|
+
this.observer.observe(this, {
|
|
118
|
+
childList: true,
|
|
119
|
+
attributes: true,
|
|
120
|
+
subtree: true,
|
|
121
|
+
attributeFilter: ["value"],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
processTagOptions() {
|
|
126
|
+
if(!this._taggle || !this._taggle.tag) return
|
|
127
|
+
let tagOptions = Array.from(this.children).filter(e => e.tagName === 'TAG-OPTION')
|
|
128
|
+
let values = tagOptions.map(e => e.value).filter(value => value !== null && value !== undefined)
|
|
129
|
+
|
|
130
|
+
// Enforce maxTags constraint for single mode
|
|
131
|
+
if (!this.multiple && values.length > 1) {
|
|
132
|
+
// Remove excess tag-options from DOM (keep only the first one)
|
|
133
|
+
tagOptions.slice(1).forEach(el => el.remove())
|
|
134
|
+
tagOptions = tagOptions.slice(0, 1)
|
|
135
|
+
values = values.slice(0, 1)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this._taggle.tag.elements = tagOptions
|
|
139
|
+
this._taggle.tag.values = values
|
|
140
|
+
this._inputPosition = this._taggle.tag.values.length;
|
|
141
|
+
|
|
142
|
+
// Update the taggle display elements to match the current values
|
|
143
|
+
const taggleElements = this._taggle.tag.elements;
|
|
144
|
+
taggleElements.forEach((element, index) => {
|
|
145
|
+
if (element && element.setAttribute) {
|
|
146
|
+
element.setAttribute('data-value', values[index]);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Update internal value to match
|
|
151
|
+
this.updateValue();
|
|
152
|
+
|
|
153
|
+
// Ensure input visibility is updated when tags change via DOM
|
|
154
|
+
this.updateInputVisibility();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get form() {
|
|
158
|
+
return this._internals.form;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_setFormValue(values) {
|
|
162
|
+
this._internals.value = values;
|
|
163
|
+
|
|
164
|
+
const formData = new FormData();
|
|
165
|
+
values.forEach(value => formData.append(this.name, value));
|
|
166
|
+
// Always append empty string when no values so server knows to clear the field
|
|
167
|
+
// (like Rails multiple checkboxes which prepend an empty hidden field)
|
|
168
|
+
if (values.length === 0) {
|
|
169
|
+
formData.append(this.name, "");
|
|
170
|
+
}
|
|
171
|
+
this._internals.setFormValue(formData);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
get name() {
|
|
175
|
+
return this.getAttribute("name");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
get multiple() {
|
|
179
|
+
return this.hasAttribute('multiple');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
get value() {
|
|
183
|
+
const internalValue = this._internals.value;
|
|
184
|
+
if (this.multiple) {
|
|
185
|
+
return internalValue; // Return array for multiple mode
|
|
186
|
+
} else {
|
|
187
|
+
return internalValue.length > 0 ? internalValue[0] : ''; // Return string for single mode
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
set value(input) {
|
|
192
|
+
// Convert input to array format for internal storage
|
|
193
|
+
let values;
|
|
194
|
+
if (Array.isArray(input)) {
|
|
195
|
+
values = input;
|
|
196
|
+
} else if (typeof input === 'string') {
|
|
197
|
+
values = input === '' ? [] : [input];
|
|
198
|
+
} else {
|
|
199
|
+
values = [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const oldValues = this._internals.value;
|
|
203
|
+
this._setFormValue(values);
|
|
204
|
+
|
|
205
|
+
// Update taggle to match the new values
|
|
206
|
+
if (this._taggle && this.initialized) {
|
|
207
|
+
this.suppressEvents = true; // Prevent infinite loops
|
|
208
|
+
this._taggle.removeAll();
|
|
209
|
+
if (values.length > 0) {
|
|
210
|
+
this._taggle.add(values);
|
|
211
|
+
}
|
|
212
|
+
this.suppressEvents = false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) {
|
|
216
|
+
this.dispatchEvent(new CustomEvent("change", {
|
|
217
|
+
bubbles: true,
|
|
218
|
+
composed: true,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
reset() {
|
|
224
|
+
this._taggle.removeAll()
|
|
225
|
+
this._taggleInputTarget.value = ''
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
get options() {
|
|
229
|
+
const datalistId = this.getAttribute("list")
|
|
230
|
+
if(datalistId) {
|
|
231
|
+
const datalist = document.getElementById(datalistId)
|
|
232
|
+
if(datalist) {
|
|
233
|
+
return [...datalist.options].map(option => option.value).filter(value => value !== null && value !== undefined)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Fall back to nested datalist
|
|
238
|
+
const nestedDatalist = this.querySelector('datalist')
|
|
239
|
+
if(nestedDatalist) {
|
|
240
|
+
return [...nestedDatalist.options].map(option => option.hasAttribute('value') ? option.value : option.textContent).filter(value => value !== null && value !== undefined)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return []
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_getOptionsWithLabels() {
|
|
247
|
+
const datalistId = this.getAttribute("list")
|
|
248
|
+
if(datalistId) {
|
|
249
|
+
const datalist = document.getElementById(datalistId)
|
|
250
|
+
if(datalist) {
|
|
251
|
+
return [...datalist.options].map(option => ({
|
|
252
|
+
value: option.value,
|
|
253
|
+
label: option.textContent || option.value
|
|
254
|
+
})).filter(item => item.value !== null && item.value !== undefined)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Fall back to nested datalist
|
|
259
|
+
const nestedDatalist = this.querySelector('datalist')
|
|
260
|
+
if(nestedDatalist) {
|
|
261
|
+
return [...nestedDatalist.options].map(option => ({
|
|
262
|
+
value: option.hasAttribute('value') ? option.value : option.textContent,
|
|
263
|
+
label: option.textContent || option.value
|
|
264
|
+
})).filter(item => item.value !== null && item.value !== undefined)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return []
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async connectedCallback() {
|
|
271
|
+
this.setAttribute('tabindex', '0');
|
|
272
|
+
this.addEventListener("focus", e => this.focus(e));
|
|
273
|
+
|
|
274
|
+
// Wait for child tag-option elements to be fully connected
|
|
275
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
276
|
+
|
|
277
|
+
this._shadowRoot.innerHTML = `
|
|
278
|
+
<style>
|
|
279
|
+
:host { display: block; }
|
|
280
|
+
:host *{
|
|
281
|
+
position: relative;
|
|
282
|
+
box-sizing: border-box;
|
|
283
|
+
margin: 0;
|
|
284
|
+
padding: 0;
|
|
285
|
+
}
|
|
286
|
+
#container {
|
|
287
|
+
background: rgba(255, 255, 255, 0.8);
|
|
288
|
+
padding: 6px 6px 3px;
|
|
289
|
+
max-height: none;
|
|
290
|
+
display: flex;
|
|
291
|
+
margin: 0;
|
|
292
|
+
flex-wrap: wrap;
|
|
293
|
+
align-items: flex-start;
|
|
294
|
+
min-height: 48px;
|
|
295
|
+
line-height: 48px;
|
|
296
|
+
width: 100%;
|
|
297
|
+
border: 1px solid #d0d0d0;
|
|
298
|
+
outline: 1px solid transparent;
|
|
299
|
+
box-shadow: #ccc 0 1px 4px 0 inset;
|
|
300
|
+
border-radius: 2px;
|
|
301
|
+
cursor: text;
|
|
302
|
+
color: #333;
|
|
303
|
+
list-style: none;
|
|
304
|
+
padding-right: 32px;
|
|
305
|
+
}
|
|
306
|
+
input {
|
|
307
|
+
display: block;
|
|
308
|
+
height: 38px;
|
|
309
|
+
float: none;
|
|
310
|
+
margin: 0;
|
|
311
|
+
padding-left: 10px !important;
|
|
312
|
+
padding-right: 30px !important;
|
|
313
|
+
width: auto !important;
|
|
314
|
+
min-width: 70px;
|
|
315
|
+
font-size: 14px;
|
|
316
|
+
width: 100%;
|
|
317
|
+
line-height: 2;
|
|
318
|
+
padding: 0 0 0 10px;
|
|
319
|
+
border: 1px dashed #d0d0d0;
|
|
320
|
+
outline: 1px solid transparent;
|
|
321
|
+
background: #fff;
|
|
322
|
+
box-shadow: none;
|
|
323
|
+
border-radius: 2px;
|
|
324
|
+
cursor: text;
|
|
325
|
+
color: #333;
|
|
326
|
+
}
|
|
327
|
+
button {
|
|
328
|
+
width: 38px;
|
|
329
|
+
text-align: center;
|
|
330
|
+
line-height: 36px;
|
|
331
|
+
border: 1px solid #e0e0e0;
|
|
332
|
+
font-size: 20px;
|
|
333
|
+
color: #666;
|
|
334
|
+
position: absolute !important;
|
|
335
|
+
z-index: 10;
|
|
336
|
+
right: 0px;
|
|
337
|
+
top: 0;
|
|
338
|
+
font-weight: 400;
|
|
339
|
+
cursor: pointer;
|
|
340
|
+
background: none;
|
|
341
|
+
}
|
|
342
|
+
.taggle_sizer{
|
|
343
|
+
padding: 0;
|
|
344
|
+
margin: 0;
|
|
345
|
+
position: absolute;
|
|
346
|
+
top: -500px;
|
|
347
|
+
z-index: -1;
|
|
348
|
+
visibility: hidden;
|
|
349
|
+
}
|
|
350
|
+
.ui-autocomplete{
|
|
351
|
+
position: static !important;
|
|
352
|
+
width: 100% !important;
|
|
353
|
+
margin-top: 2px;
|
|
354
|
+
}
|
|
355
|
+
.ui-menu{
|
|
356
|
+
margin: 0;
|
|
357
|
+
padding: 6px;
|
|
358
|
+
box-shadow: #ccc 0 1px 6px;
|
|
359
|
+
z-index: 2;
|
|
360
|
+
display: flex;
|
|
361
|
+
flex-wrap: wrap;
|
|
362
|
+
background: #fff;
|
|
363
|
+
list-style: none;
|
|
364
|
+
font-size: 14px;
|
|
365
|
+
min-width: 200px;
|
|
366
|
+
}
|
|
367
|
+
.ui-menu .ui-menu-item{
|
|
368
|
+
display: inline-block;
|
|
369
|
+
margin: 0 0 2px;
|
|
370
|
+
line-height: 30px;
|
|
371
|
+
border: none;
|
|
372
|
+
padding: 0 10px;
|
|
373
|
+
text-indent: 0;
|
|
374
|
+
border-radius: 2px;
|
|
375
|
+
width: auto;
|
|
376
|
+
cursor: pointer;
|
|
377
|
+
color: #555;
|
|
378
|
+
}
|
|
379
|
+
.ui-menu .ui-menu-item::before{ display: none; }
|
|
380
|
+
.ui-menu .ui-menu-item:hover{ background: #e0e0e0; }
|
|
381
|
+
.ui-state-active{
|
|
382
|
+
padding: 0;
|
|
383
|
+
border: none;
|
|
384
|
+
background: none;
|
|
385
|
+
color: inherit;
|
|
386
|
+
}
|
|
387
|
+
</style>
|
|
388
|
+
<div style="position: relative;">
|
|
389
|
+
<div id="container">
|
|
390
|
+
<slot></slot>
|
|
391
|
+
</div>
|
|
392
|
+
<input
|
|
393
|
+
id="inputTarget"
|
|
394
|
+
type="hidden"
|
|
395
|
+
name="${this.name}"
|
|
396
|
+
/>
|
|
397
|
+
</div>
|
|
398
|
+
`;
|
|
399
|
+
|
|
400
|
+
this.form?.addEventListener("reset", this.reset.bind(this));
|
|
401
|
+
|
|
402
|
+
this.containerTarget = this.shadowRoot.querySelector("#container");
|
|
403
|
+
this.inputTarget = this.shadowRoot.querySelector("#inputTarget");
|
|
404
|
+
|
|
405
|
+
this.required = this.hasAttribute("required")
|
|
406
|
+
|
|
407
|
+
const maxTags = this.multiple ? undefined : 1
|
|
408
|
+
const placeholder = this.inputTarget.getAttribute("placeholder")
|
|
409
|
+
|
|
410
|
+
this.inputTarget.value = ""
|
|
411
|
+
this.inputTarget.id = ""
|
|
412
|
+
|
|
413
|
+
this._taggle = new Taggle(this, {
|
|
414
|
+
inputContainer: this.containerTarget,
|
|
415
|
+
preserveCase: true,
|
|
416
|
+
hiddenInputName: this.name,
|
|
417
|
+
maxTags: maxTags,
|
|
418
|
+
placeholder: placeholder,
|
|
419
|
+
onTagAdd: (event, tag) => this.onTagAdd(event, tag),
|
|
420
|
+
onTagRemove: (event, tag) => this.onTagRemove(event, tag),
|
|
421
|
+
})
|
|
422
|
+
this._taggleInputTarget = this._taggle.getInput()
|
|
423
|
+
this._taggleInputTarget.id = this.id
|
|
424
|
+
this._taggleInputTarget.autocomplete = "off"
|
|
425
|
+
this._taggleInputTarget.setAttribute("data-turbo-permanent", true)
|
|
426
|
+
this._taggleInputTarget.addEventListener("keyup", e => this.keyup(e))
|
|
427
|
+
|
|
428
|
+
// Set initial value after taggle is initialized
|
|
429
|
+
this.value = this._taggle.getTagValues()
|
|
430
|
+
|
|
431
|
+
this.checkRequired()
|
|
432
|
+
|
|
433
|
+
this.buttonTarget = h(`<button class="add">+</button>`)
|
|
434
|
+
this.buttonTarget.addEventListener("click", e => this._add(e))
|
|
435
|
+
this._taggleInputTarget.insertAdjacentElement("afterend", this.buttonTarget)
|
|
436
|
+
|
|
437
|
+
this.autocompleteContainerTarget = h(`<ul>`);
|
|
438
|
+
// Insert autocomplete container into the positioned wrapper div
|
|
439
|
+
const wrapperDiv = this.shadowRoot.querySelector('div[style*="position: relative"]');
|
|
440
|
+
wrapperDiv.appendChild(this.autocompleteContainerTarget)
|
|
441
|
+
|
|
442
|
+
this.setupAutocomplete()
|
|
443
|
+
|
|
444
|
+
this.observe() // Start observing after taggle is set up
|
|
445
|
+
this.initialized = true
|
|
446
|
+
|
|
447
|
+
// Update visibility based on current state
|
|
448
|
+
this.updateInputVisibility()
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
setupAutocomplete() {
|
|
452
|
+
const optionsWithLabels = this._getOptionsWithLabels()
|
|
453
|
+
|
|
454
|
+
autocomplete({
|
|
455
|
+
input: this._taggleInputTarget,
|
|
456
|
+
container: this.autocompleteContainerTarget,
|
|
457
|
+
className: "ui-menu ui-autocomplete",
|
|
458
|
+
fetch: (text, update) => {
|
|
459
|
+
const currentTags = this._taggle.getTagValues()
|
|
460
|
+
const suggestions = optionsWithLabels.filter(option =>
|
|
461
|
+
option.label.toLowerCase().includes(text.toLowerCase()) &&
|
|
462
|
+
!currentTags.includes(option.value)
|
|
463
|
+
)
|
|
464
|
+
// Store the suggestions for testing (can't assign to getter, tests read from DOM)
|
|
465
|
+
update(suggestions)
|
|
466
|
+
},
|
|
467
|
+
render: item => h(`<li class="ui-menu-item" data-value="${item.value}">${item.label}</li>`),
|
|
468
|
+
onSelect: item => {
|
|
469
|
+
// Prevent adding multiple tags in single mode
|
|
470
|
+
if (!this.multiple && this._taggle.getTagValues().length > 0) {
|
|
471
|
+
this._taggleInputTarget.value = ''
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Create a tag-option element with proper value/label separation
|
|
476
|
+
const tagOption = document.createElement('tag-option')
|
|
477
|
+
tagOption.setAttribute('value', item.value)
|
|
478
|
+
tagOption.textContent = item.label
|
|
479
|
+
this.appendChild(tagOption)
|
|
480
|
+
|
|
481
|
+
// Clear input
|
|
482
|
+
this._taggleInputTarget.value = ''
|
|
483
|
+
},
|
|
484
|
+
minLength: 1,
|
|
485
|
+
customize: (input, inputRect, container, maxHeight) => {
|
|
486
|
+
// Position autocomplete below the input-tag container, accounting for dynamic height
|
|
487
|
+
this._updateAutocompletePosition(container);
|
|
488
|
+
|
|
489
|
+
// Store reference to update positioning when container height changes
|
|
490
|
+
this._autocompleteContainer = container;
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
disconnectedCallback() {
|
|
496
|
+
this.form?.removeEventListener("reset", this.reset.bind(this));
|
|
497
|
+
this.unobserve();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
501
|
+
if (oldValue === newValue) return;
|
|
502
|
+
|
|
503
|
+
// Only handle changes after the component is connected and initialized
|
|
504
|
+
if (!this._taggle) return;
|
|
505
|
+
|
|
506
|
+
switch (name) {
|
|
507
|
+
case 'name':
|
|
508
|
+
this.handleNameChange(newValue);
|
|
509
|
+
break;
|
|
510
|
+
case 'multiple':
|
|
511
|
+
this.handleMultipleChange(newValue !== null);
|
|
512
|
+
break;
|
|
513
|
+
case 'required':
|
|
514
|
+
this.handleRequiredChange(newValue !== null);
|
|
515
|
+
break;
|
|
516
|
+
case 'list':
|
|
517
|
+
this.handleListChange(newValue);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
checkRequired() {
|
|
523
|
+
const flag = this.required && this._taggle.getTagValues().length == 0
|
|
524
|
+
this._taggleInputTarget.required = flag
|
|
525
|
+
|
|
526
|
+
// Update ElementInternals validity to match internal input
|
|
527
|
+
if (flag) {
|
|
528
|
+
this._internals.setValidity({ valueMissing: true }, 'Please fill out this field.', this._taggleInputTarget)
|
|
529
|
+
} else {
|
|
530
|
+
this._internals.setValidity({})
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// monkeypatch support for android comma
|
|
535
|
+
keyup(event) {
|
|
536
|
+
const key = event.which || event.keyCode
|
|
537
|
+
const normalKeyboard = key != 229
|
|
538
|
+
if(normalKeyboard) return
|
|
539
|
+
const value = this._taggleInputTarget.value
|
|
540
|
+
|
|
541
|
+
// backspace
|
|
542
|
+
if(value.length == 0) {
|
|
543
|
+
const values = this._taggle.tag.values
|
|
544
|
+
this._taggle.remove(values[values.length - 1])
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// comma
|
|
549
|
+
if(/,$/.test(value)) {
|
|
550
|
+
const tag = value.replace(',', '')
|
|
551
|
+
this._taggle.add(tag)
|
|
552
|
+
this._taggleInputTarget.value = ''
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
_add(event) {
|
|
558
|
+
event.preventDefault()
|
|
559
|
+
this._taggle.add(this._taggleInputTarget.value)
|
|
560
|
+
this._taggleInputTarget.value = ''
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
onTagAdd(event, tag) {
|
|
564
|
+
if (!this.suppressEvents) {
|
|
565
|
+
const isNew = !this.options.includes(tag)
|
|
566
|
+
this.dispatchEvent(new CustomEvent("update", {
|
|
567
|
+
detail: { tag, isNew },
|
|
568
|
+
bubbles: true,
|
|
569
|
+
composed: true,
|
|
570
|
+
}));
|
|
571
|
+
}
|
|
572
|
+
this.syncValue()
|
|
573
|
+
this.checkRequired()
|
|
574
|
+
this.updateInputVisibility()
|
|
575
|
+
|
|
576
|
+
// Update autocomplete position if it's currently open
|
|
577
|
+
if (this._autocompleteContainer) {
|
|
578
|
+
// Use setTimeout to allow DOM to update first
|
|
579
|
+
setTimeout(() => this._updateAutocompletePosition(this._autocompleteContainer), 0)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
onTagRemove(event, tag) {
|
|
584
|
+
if (!this.suppressEvents) {
|
|
585
|
+
this.dispatchEvent(new CustomEvent("update", {
|
|
586
|
+
detail: { tag },
|
|
587
|
+
bubbles: true,
|
|
588
|
+
composed: true,
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
this.syncValue()
|
|
592
|
+
this.checkRequired()
|
|
593
|
+
this.updateInputVisibility()
|
|
594
|
+
|
|
595
|
+
// Update autocomplete position if it's currently open
|
|
596
|
+
if (this._autocompleteContainer) {
|
|
597
|
+
// Use setTimeout to allow DOM to update first
|
|
598
|
+
setTimeout(() => this._updateAutocompletePosition(this._autocompleteContainer), 0)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
syncValue() {
|
|
603
|
+
// Directly update internals without triggering the setter
|
|
604
|
+
const values = this._taggle.getTagValues()
|
|
605
|
+
const oldValues = this._internals.value;
|
|
606
|
+
this._setFormValue(values);
|
|
607
|
+
|
|
608
|
+
if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) {
|
|
609
|
+
this.dispatchEvent(new CustomEvent("change", {
|
|
610
|
+
bubbles: true,
|
|
611
|
+
composed: true,
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Public API methods
|
|
617
|
+
add(tags) {
|
|
618
|
+
if (!this._taggle) return
|
|
619
|
+
this._taggle.add(tags)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
remove(tag) {
|
|
623
|
+
if (!this._taggle) return
|
|
624
|
+
this._taggle.remove(tag)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
removeAll() {
|
|
628
|
+
if (!this._taggle) return
|
|
629
|
+
this._taggle.removeAll()
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
has(tag) {
|
|
633
|
+
if (!this._taggle) return false
|
|
634
|
+
return this._taggle.getTagValues().includes(tag)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
get tags() {
|
|
638
|
+
if (!this._taggle) return []
|
|
639
|
+
return this._taggle.getTagValues()
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Private getter for testing autocomplete suggestions
|
|
643
|
+
get _autocompleteSuggestions() {
|
|
644
|
+
if (!this.autocompleteContainerTarget) return []
|
|
645
|
+
const items = this.autocompleteContainerTarget.querySelectorAll('.ui-menu-item')
|
|
646
|
+
return Array.from(items).map(item => item.textContent.trim())
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Update autocomplete position based on current container height
|
|
650
|
+
_updateAutocompletePosition(container) {
|
|
651
|
+
if (!container) return
|
|
652
|
+
|
|
653
|
+
const inputTagRect = this.containerTarget.getBoundingClientRect();
|
|
654
|
+
|
|
655
|
+
container.style.setProperty('position', 'absolute', 'important');
|
|
656
|
+
container.style.setProperty('top', `${inputTagRect.height}px`, 'important');
|
|
657
|
+
container.style.setProperty('left', '0', 'important');
|
|
658
|
+
container.style.setProperty('right', '0', 'important');
|
|
659
|
+
container.style.setProperty('width', '100%', 'important');
|
|
660
|
+
container.style.setProperty('z-index', '1000', 'important');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
updateInputVisibility() {
|
|
664
|
+
if (!this._taggleInputTarget || !this.buttonTarget) return;
|
|
665
|
+
|
|
666
|
+
const hasTags = this._taggle && this._taggle.getTagValues().length > 0;
|
|
667
|
+
|
|
668
|
+
if (this.multiple) {
|
|
669
|
+
// Multiple mode: always show input and button
|
|
670
|
+
this._taggleInputTarget.style.display = '';
|
|
671
|
+
this.buttonTarget.style.display = '';
|
|
672
|
+
} else {
|
|
673
|
+
// Single mode: hide input and button when tag exists
|
|
674
|
+
if (hasTags) {
|
|
675
|
+
this._taggleInputTarget.style.display = 'none';
|
|
676
|
+
this.buttonTarget.style.display = 'none';
|
|
677
|
+
} else {
|
|
678
|
+
this._taggleInputTarget.style.display = '';
|
|
679
|
+
this.buttonTarget.style.display = '';
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
addAt(tag, index) {
|
|
685
|
+
if (!this._taggle) return
|
|
686
|
+
this._taggle.add(tag, index)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
disable() {
|
|
690
|
+
if (this._taggle) {
|
|
691
|
+
this._taggle.disable()
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
enable() {
|
|
696
|
+
if (this._taggle) {
|
|
697
|
+
this._taggle.enable()
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
focus() {
|
|
702
|
+
if (this._taggleInputTarget) {
|
|
703
|
+
this._taggleInputTarget.focus()
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
checkValidity() {
|
|
708
|
+
if (this._taggle) {
|
|
709
|
+
this.checkRequired()
|
|
710
|
+
}
|
|
711
|
+
return this._internals.checkValidity()
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
reportValidity() {
|
|
715
|
+
if (this._taggle) {
|
|
716
|
+
this.checkRequired()
|
|
717
|
+
}
|
|
718
|
+
return this._internals.reportValidity()
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
handleNameChange(newName) {
|
|
722
|
+
// Update the hidden input name to match
|
|
723
|
+
const hiddenInput = this._shadowRoot.querySelector('input[type="hidden"]');
|
|
724
|
+
if (hiddenInput) {
|
|
725
|
+
hiddenInput.name = newName || '';
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Update the form value with the new name
|
|
729
|
+
if (this._internals.value) {
|
|
730
|
+
this.value = this._internals.value; // This will recreate FormData with new name
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
handleMultipleChange(isMultiple) {
|
|
735
|
+
if (!this._taggle) return;
|
|
736
|
+
|
|
737
|
+
// Get current tags
|
|
738
|
+
const currentTags = this._taggle.getTagValues();
|
|
739
|
+
|
|
740
|
+
if (!isMultiple && currentTags.length > 1) {
|
|
741
|
+
// Single mode: remove excess tag-option elements from DOM
|
|
742
|
+
const tagOptions = Array.from(this.children);
|
|
743
|
+
// Keep only the first tag-option element, remove the rest
|
|
744
|
+
tagOptions.forEach((tagOption, i) => {
|
|
745
|
+
if (i > 0 && tagOption) {
|
|
746
|
+
this.removeChild(tagOption);
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Reinitialize taggle with new multiple setting
|
|
752
|
+
this.reinitializeTaggle();
|
|
753
|
+
|
|
754
|
+
// Restore tags, respecting the new multiple constraint
|
|
755
|
+
if (isMultiple) {
|
|
756
|
+
// Multiple mode: restore all remaining tags
|
|
757
|
+
if (currentTags.length > 0) {
|
|
758
|
+
this._taggle.add(currentTags);
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
// Single mode: keep only the first tag
|
|
762
|
+
if (currentTags.length > 0) {
|
|
763
|
+
this._taggle.add(currentTags[0]);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
this.updateValue();
|
|
768
|
+
this.updateInputVisibility();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
handleRequiredChange(isRequired) {
|
|
772
|
+
if (!this._taggle) return;
|
|
773
|
+
|
|
774
|
+
// Update the internal required state
|
|
775
|
+
this.required = isRequired;
|
|
776
|
+
|
|
777
|
+
// Update validation
|
|
778
|
+
this.checkRequired();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
handleListChange(newListId) {
|
|
782
|
+
if (!this._taggle) return;
|
|
783
|
+
|
|
784
|
+
// Re-setup autocomplete with new datalist
|
|
785
|
+
this.setupAutocomplete();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
reinitializeTaggle() {
|
|
789
|
+
// Clean up existing taggle if it exists
|
|
790
|
+
if (this._taggle && this._taggle.destroy) {
|
|
791
|
+
this._taggle.destroy();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Get current configuration
|
|
795
|
+
const maxTags = this.hasAttribute("multiple") ? undefined : 1;
|
|
796
|
+
const placeholder = this.getAttribute("placeholder") || "";
|
|
797
|
+
|
|
798
|
+
// Create new taggle instance using original configuration pattern
|
|
799
|
+
this._taggle = new Taggle(this, {
|
|
800
|
+
inputContainer: this.containerTarget,
|
|
801
|
+
preserveCase: true,
|
|
802
|
+
hiddenInputName: this.name,
|
|
803
|
+
maxTags: maxTags,
|
|
804
|
+
placeholder: placeholder,
|
|
805
|
+
onTagAdd: (event, tag) => this.onTagAdd(event, tag),
|
|
806
|
+
onTagRemove: (event, tag) => this.onTagRemove(event, tag),
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Re-get references since taggle was recreated
|
|
810
|
+
this._taggleInputTarget = this._taggle.getInput();
|
|
811
|
+
this._taggleInputTarget.id = this.id || "";
|
|
812
|
+
this._taggleInputTarget.autocomplete = "off";
|
|
813
|
+
this._taggleInputTarget.setAttribute("data-turbo-permanent", true);
|
|
814
|
+
this._taggleInputTarget.addEventListener("keyup", e => this.keyup(e));
|
|
815
|
+
|
|
816
|
+
// Re-setup autocomplete
|
|
817
|
+
this.setupAutocomplete();
|
|
818
|
+
|
|
819
|
+
// Re-process existing tag options
|
|
820
|
+
this.processTagOptions();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
updateValue() {
|
|
824
|
+
if (!this._taggle) return;
|
|
825
|
+
|
|
826
|
+
// Update the internal value to match taggle state
|
|
827
|
+
const values = this._taggle.getTagValues();
|
|
828
|
+
const oldValues = this._internals.value;
|
|
829
|
+
this._setFormValue(values);
|
|
830
|
+
|
|
831
|
+
// Check validity after updating
|
|
832
|
+
this.checkRequired();
|
|
833
|
+
|
|
834
|
+
if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) {
|
|
835
|
+
this.dispatchEvent(new CustomEvent("change", {
|
|
836
|
+
bubbles: true,
|
|
837
|
+
composed: true,
|
|
838
|
+
}));
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
customElements.define("input-tag", InputTag);
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
function h(html) {
|
|
846
|
+
const container = document.createElement("div")
|
|
847
|
+
container.innerHTML = html
|
|
848
|
+
return container.firstElementChild
|
|
849
|
+
}
|