bard-tag_field 0.5.1 → 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 +1 -1
- data/app/assets/javascripts/input-tag.js +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/version.rb +1 -1
- metadata +23 -4
- 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,425 @@
|
|
|
1
|
+
import { expect } from '@esm-bundle/chai'
|
|
2
|
+
import { spy } from 'sinon'
|
|
3
|
+
import '../src/input-tag.js'
|
|
4
|
+
import {
|
|
5
|
+
setupGlobalTestHooks,
|
|
6
|
+
setupInputTag,
|
|
7
|
+
waitForUpdate,
|
|
8
|
+
expectEventToFire,
|
|
9
|
+
simulateFocus,
|
|
10
|
+
simulateBlur,
|
|
11
|
+
getTagElements,
|
|
12
|
+
getTagValues,
|
|
13
|
+
clickTagRemoveButton
|
|
14
|
+
} from './lib/test-utils.js'
|
|
15
|
+
|
|
16
|
+
describe('Events', () => {
|
|
17
|
+
setupGlobalTestHooks()
|
|
18
|
+
|
|
19
|
+
describe('Change Events', () => {
|
|
20
|
+
it('should fire change event when tags are added', async () => {
|
|
21
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
22
|
+
|
|
23
|
+
const eventPromise = expectEventToFire(inputTag, 'change')
|
|
24
|
+
|
|
25
|
+
inputTag.add('new-tag')
|
|
26
|
+
await waitForUpdate()
|
|
27
|
+
|
|
28
|
+
const { eventFired, eventData } = await eventPromise
|
|
29
|
+
expect(eventFired).to.be.true
|
|
30
|
+
expect(eventData.bubbles).to.be.true
|
|
31
|
+
expect(eventData.composed).to.be.true
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should fire change event when tags are removed', async () => {
|
|
35
|
+
const inputTag = await setupInputTag(`
|
|
36
|
+
<input-tag name="tags" multiple>
|
|
37
|
+
<tag-option value="remove-me">Remove Me</tag-option>
|
|
38
|
+
</input-tag>
|
|
39
|
+
`)
|
|
40
|
+
|
|
41
|
+
const eventPromise = expectEventToFire(inputTag, 'change')
|
|
42
|
+
|
|
43
|
+
inputTag.remove('remove-me')
|
|
44
|
+
await waitForUpdate()
|
|
45
|
+
|
|
46
|
+
const { eventFired } = await eventPromise
|
|
47
|
+
expect(eventFired).to.be.true
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should fire change event when array value is set programmatically in multiple mode', async () => {
|
|
51
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
52
|
+
|
|
53
|
+
const eventPromise = expectEventToFire(inputTag, 'change')
|
|
54
|
+
|
|
55
|
+
inputTag.value = ['programmatic-tag']
|
|
56
|
+
await waitForUpdate()
|
|
57
|
+
|
|
58
|
+
const { eventFired } = await eventPromise
|
|
59
|
+
expect(eventFired).to.be.true
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should fire change event when string value is set programmatically in single mode', async () => {
|
|
63
|
+
const inputTag = await setupInputTag('<input-tag name="tag"></input-tag>')
|
|
64
|
+
|
|
65
|
+
const eventPromise = expectEventToFire(inputTag, 'change')
|
|
66
|
+
|
|
67
|
+
inputTag.value = 'programmatic-tag'
|
|
68
|
+
await waitForUpdate()
|
|
69
|
+
|
|
70
|
+
const { eventFired } = await eventPromise
|
|
71
|
+
expect(eventFired).to.be.true
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should not fire change event during initialization', async () => {
|
|
75
|
+
let changeEventFired = false
|
|
76
|
+
|
|
77
|
+
document.body.innerHTML = `
|
|
78
|
+
<input-tag name="tags" multiple>
|
|
79
|
+
<tag-option value="initial">Initial</tag-option>
|
|
80
|
+
</input-tag>
|
|
81
|
+
`
|
|
82
|
+
const inputTag = document.querySelector('input-tag')
|
|
83
|
+
|
|
84
|
+
inputTag.addEventListener('change', () => {
|
|
85
|
+
changeEventFired = true
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await waitForUpdate(200) // Wait longer for initialization
|
|
89
|
+
|
|
90
|
+
expect(changeEventFired).to.be.false
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should not fire change event when suppressEvents is true', async () => {
|
|
94
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
95
|
+
|
|
96
|
+
let changeEventFired = false
|
|
97
|
+
inputTag.addEventListener('change', () => {
|
|
98
|
+
changeEventFired = true
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
inputTag.suppressEvents = true
|
|
102
|
+
inputTag.add('suppressed-tag')
|
|
103
|
+
await waitForUpdate()
|
|
104
|
+
|
|
105
|
+
expect(changeEventFired).to.be.false
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('should not fire change event when array values do not actually change in multiple mode', async () => {
|
|
109
|
+
const inputTag = await setupInputTag(`
|
|
110
|
+
<input-tag name="tags" multiple>
|
|
111
|
+
<tag-option value="existing">Existing</tag-option>
|
|
112
|
+
</input-tag>
|
|
113
|
+
`)
|
|
114
|
+
|
|
115
|
+
let changeEventFired = false
|
|
116
|
+
inputTag.addEventListener('change', () => {
|
|
117
|
+
changeEventFired = true
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Set the same value again
|
|
121
|
+
inputTag.value = ['existing']
|
|
122
|
+
await waitForUpdate()
|
|
123
|
+
|
|
124
|
+
expect(changeEventFired).to.be.false
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should not fire change event when string value does not actually change in single mode', async () => {
|
|
128
|
+
const inputTag = await setupInputTag(`
|
|
129
|
+
<input-tag name="tag">
|
|
130
|
+
<tag-option value="existing">Existing</tag-option>
|
|
131
|
+
</input-tag>
|
|
132
|
+
`)
|
|
133
|
+
|
|
134
|
+
let changeEventFired = false
|
|
135
|
+
inputTag.addEventListener('change', () => {
|
|
136
|
+
changeEventFired = true
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// Set the same value again
|
|
140
|
+
inputTag.value = 'existing'
|
|
141
|
+
await waitForUpdate()
|
|
142
|
+
|
|
143
|
+
expect(changeEventFired).to.be.false
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('Update Events', () => {
|
|
148
|
+
it('should fire update event when tag is added with correct detail', async () => {
|
|
149
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
150
|
+
|
|
151
|
+
const eventPromise = expectEventToFire(inputTag, 'update')
|
|
152
|
+
|
|
153
|
+
inputTag.add('test-tag')
|
|
154
|
+
await waitForUpdate()
|
|
155
|
+
|
|
156
|
+
const { eventFired, eventData } = await eventPromise
|
|
157
|
+
expect(eventFired).to.be.true
|
|
158
|
+
expect(eventData.detail.tag).to.equal('test-tag')
|
|
159
|
+
expect(eventData.bubbles).to.be.true
|
|
160
|
+
expect(eventData.composed).to.be.true
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should fire update event when tag is removed with correct detail', async () => {
|
|
164
|
+
const inputTag = await setupInputTag(`
|
|
165
|
+
<input-tag name="tags" multiple>
|
|
166
|
+
<tag-option value="remove-me">Remove Me</tag-option>
|
|
167
|
+
</input-tag>
|
|
168
|
+
`)
|
|
169
|
+
|
|
170
|
+
const eventPromise = expectEventToFire(inputTag, 'update')
|
|
171
|
+
|
|
172
|
+
inputTag.remove('remove-me')
|
|
173
|
+
await waitForUpdate()
|
|
174
|
+
|
|
175
|
+
const { eventFired, eventData } = await eventPromise
|
|
176
|
+
expect(eventFired).to.be.true
|
|
177
|
+
expect(eventData.detail.tag).to.equal('remove-me')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('should indicate if tag is new in update event detail', async () => {
|
|
181
|
+
const inputTag = await setupInputTag(`
|
|
182
|
+
<input-tag name="frameworks" list="suggestions" multiple></input-tag>
|
|
183
|
+
<datalist id="suggestions">
|
|
184
|
+
<option value="react">React</option>
|
|
185
|
+
<option value="vue">Vue</option>
|
|
186
|
+
</datalist>
|
|
187
|
+
`)
|
|
188
|
+
|
|
189
|
+
// Add a tag that exists in options (not new)
|
|
190
|
+
let eventPromise = expectEventToFire(inputTag, 'update')
|
|
191
|
+
inputTag.add('react')
|
|
192
|
+
await waitForUpdate()
|
|
193
|
+
|
|
194
|
+
let { eventData } = await eventPromise
|
|
195
|
+
expect(eventData.detail.isNew).to.be.false
|
|
196
|
+
|
|
197
|
+
// Add a tag that doesn't exist in options (is new)
|
|
198
|
+
eventPromise = expectEventToFire(inputTag, 'update')
|
|
199
|
+
inputTag.add('custom-framework')
|
|
200
|
+
await waitForUpdate()
|
|
201
|
+
|
|
202
|
+
const result = await eventPromise
|
|
203
|
+
expect(result.eventData.detail.isNew).to.be.true
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('should not fire update event when suppressEvents is true', async () => {
|
|
207
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
208
|
+
|
|
209
|
+
let updateEventFired = false
|
|
210
|
+
inputTag.addEventListener('update', () => {
|
|
211
|
+
updateEventFired = true
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
inputTag.suppressEvents = true
|
|
215
|
+
inputTag.add('suppressed-tag')
|
|
216
|
+
await waitForUpdate()
|
|
217
|
+
|
|
218
|
+
expect(updateEventFired).to.be.false
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('should fire separate update events for multiple tag operations', async () => {
|
|
222
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
223
|
+
|
|
224
|
+
const updateEvents = []
|
|
225
|
+
inputTag.addEventListener('update', (e) => {
|
|
226
|
+
updateEvents.push(e.detail.tag)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
inputTag.add('first')
|
|
230
|
+
await waitForUpdate()
|
|
231
|
+
inputTag.add('second')
|
|
232
|
+
await waitForUpdate()
|
|
233
|
+
|
|
234
|
+
expect(updateEvents).to.deep.equal(['first', 'second'])
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('Focus and Blur Events', () => {
|
|
239
|
+
it('should handle focus on input-tag element', async () => {
|
|
240
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
241
|
+
|
|
242
|
+
const eventPromise = expectEventToFire(inputTag, 'focus')
|
|
243
|
+
|
|
244
|
+
simulateFocus(inputTag)
|
|
245
|
+
|
|
246
|
+
const { eventFired } = await eventPromise
|
|
247
|
+
expect(eventFired).to.be.true
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('should focus internal input when input-tag is focused', async () => {
|
|
251
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
252
|
+
|
|
253
|
+
const focusSpy = spy(inputTag._taggleInputTarget, 'focus')
|
|
254
|
+
|
|
255
|
+
inputTag.focus()
|
|
256
|
+
await waitForUpdate()
|
|
257
|
+
|
|
258
|
+
expect(focusSpy.called).to.be.true
|
|
259
|
+
focusSpy.restore()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('should handle blur events properly', async () => {
|
|
263
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
264
|
+
|
|
265
|
+
inputTag.focus()
|
|
266
|
+
await waitForUpdate()
|
|
267
|
+
|
|
268
|
+
const eventPromise = expectEventToFire(inputTag._taggleInputTarget, 'blur')
|
|
269
|
+
|
|
270
|
+
simulateBlur(inputTag._taggleInputTarget)
|
|
271
|
+
|
|
272
|
+
const { eventFired } = await eventPromise
|
|
273
|
+
expect(eventFired).to.be.true
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
describe('Event Bubbling and Composition', () => {
|
|
278
|
+
it('should have change events that bubble', async () => {
|
|
279
|
+
document.body.innerHTML = `
|
|
280
|
+
<div id="parent">
|
|
281
|
+
<input-tag name="tags" multiple></input-tag>
|
|
282
|
+
</div>
|
|
283
|
+
`
|
|
284
|
+
const parent = document.querySelector('#parent')
|
|
285
|
+
const inputTag = document.querySelector('input-tag')
|
|
286
|
+
|
|
287
|
+
await waitForUpdate(100) // Wait for initialization
|
|
288
|
+
|
|
289
|
+
const eventPromise = expectEventToFire(parent, 'change')
|
|
290
|
+
|
|
291
|
+
inputTag.add('bubbling-tag')
|
|
292
|
+
await waitForUpdate()
|
|
293
|
+
|
|
294
|
+
const { eventFired } = await eventPromise
|
|
295
|
+
expect(eventFired).to.be.true
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('should have update events that bubble', async () => {
|
|
299
|
+
document.body.innerHTML = `
|
|
300
|
+
<div id="parent">
|
|
301
|
+
<input-tag name="tags" multiple></input-tag>
|
|
302
|
+
</div>
|
|
303
|
+
`
|
|
304
|
+
const parent = document.querySelector('#parent')
|
|
305
|
+
const inputTag = document.querySelector('input-tag')
|
|
306
|
+
|
|
307
|
+
await waitForUpdate(100) // Wait for initialization
|
|
308
|
+
|
|
309
|
+
const eventPromise = expectEventToFire(parent, 'update')
|
|
310
|
+
|
|
311
|
+
inputTag.add('bubbling-update')
|
|
312
|
+
await waitForUpdate()
|
|
313
|
+
|
|
314
|
+
const { eventFired } = await eventPromise
|
|
315
|
+
expect(eventFired).to.be.true
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('should have composed events that cross shadow DOM boundaries', async () => {
|
|
319
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
320
|
+
|
|
321
|
+
let composedEventReceived = false
|
|
322
|
+
document.addEventListener('change', (e) => {
|
|
323
|
+
if (e.target === inputTag) {
|
|
324
|
+
composedEventReceived = true
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
inputTag.add('composed-tag')
|
|
329
|
+
await waitForUpdate()
|
|
330
|
+
|
|
331
|
+
expect(composedEventReceived).to.be.true
|
|
332
|
+
|
|
333
|
+
// Clean up
|
|
334
|
+
document.removeEventListener('change', () => {})
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
describe('Event Timing and Sequence', () => {
|
|
339
|
+
it('should fire update event before change event', async () => {
|
|
340
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
341
|
+
|
|
342
|
+
const eventSequence = []
|
|
343
|
+
|
|
344
|
+
inputTag.addEventListener('update', () => {
|
|
345
|
+
eventSequence.push('update')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
inputTag.addEventListener('change', () => {
|
|
349
|
+
eventSequence.push('change')
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
inputTag.add('sequence-test')
|
|
353
|
+
await waitForUpdate()
|
|
354
|
+
|
|
355
|
+
expect(eventSequence).to.deep.equal(['update', 'change'])
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('should handle rapid event firing without issues', async () => {
|
|
359
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
360
|
+
|
|
361
|
+
const updateEvents = []
|
|
362
|
+
const changeEvents = []
|
|
363
|
+
|
|
364
|
+
inputTag.addEventListener('update', (e) => {
|
|
365
|
+
updateEvents.push(e.detail.tag)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
inputTag.addEventListener('change', () => {
|
|
369
|
+
changeEvents.push('change')
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
// Rapid additions
|
|
373
|
+
inputTag.add('rapid1')
|
|
374
|
+
inputTag.add('rapid2')
|
|
375
|
+
inputTag.add('rapid3')
|
|
376
|
+
await waitForUpdate(100)
|
|
377
|
+
|
|
378
|
+
expect(updateEvents).to.have.length(3)
|
|
379
|
+
expect(changeEvents).to.have.length(3)
|
|
380
|
+
})
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
describe('Event Cleanup and Memory Management', () => {
|
|
384
|
+
it('should remove event listeners on disconnect', async () => {
|
|
385
|
+
const inputTag = await setupInputTag('<input-tag name="tags" multiple></input-tag>')
|
|
386
|
+
|
|
387
|
+
const form = document.createElement('form')
|
|
388
|
+
form.appendChild(inputTag)
|
|
389
|
+
|
|
390
|
+
// Simulate disconnect
|
|
391
|
+
inputTag.disconnectedCallback()
|
|
392
|
+
|
|
393
|
+
// Should not throw or cause memory leaks
|
|
394
|
+
expect(() => {
|
|
395
|
+
form.reset()
|
|
396
|
+
}).to.not.throw()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('should handle form reset events properly after disconnect and reconnect', async () => {
|
|
400
|
+
document.body.innerHTML = `
|
|
401
|
+
<form>
|
|
402
|
+
<input-tag name="tags" multiple>
|
|
403
|
+
<tag-option value="test">Test</tag-option>
|
|
404
|
+
</input-tag>
|
|
405
|
+
</form>
|
|
406
|
+
`
|
|
407
|
+
const form = document.querySelector('form')
|
|
408
|
+
const inputTag = document.querySelector('input-tag')
|
|
409
|
+
|
|
410
|
+
await waitForUpdate(100)
|
|
411
|
+
|
|
412
|
+
// Remove and re-add to DOM
|
|
413
|
+
form.removeChild(inputTag)
|
|
414
|
+
await waitForUpdate()
|
|
415
|
+
form.appendChild(inputTag)
|
|
416
|
+
await waitForUpdate()
|
|
417
|
+
|
|
418
|
+
// Should still work
|
|
419
|
+
form.reset()
|
|
420
|
+
await waitForUpdate()
|
|
421
|
+
|
|
422
|
+
expect(getTagElements(inputTag)).to.have.length(0)
|
|
423
|
+
})
|
|
424
|
+
})
|
|
425
|
+
})
|