slim-select 2.13.0 → 3.0.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.
Files changed (55) hide show
  1. package/README.md +184 -43
  2. package/dist/index.d.ts +304 -52
  3. package/dist/react/index.d.ts +304 -0
  4. package/dist/react/index.js +1549 -0
  5. package/dist/react/index.umd.js +22 -0
  6. package/dist/slimselect.cjs.js +1 -2030
  7. package/dist/slimselect.css +1 -1
  8. package/dist/slimselect.css.map +1 -1
  9. package/dist/slimselect.es.js +1224 -2012
  10. package/dist/slimselect.umd.js +1 -2036
  11. package/dist/vue/index.d.ts +304 -0
  12. package/dist/vue/index.js +1341 -0
  13. package/dist/vue/index.umd.js +1 -0
  14. package/index.html +1 -0
  15. package/package.json +59 -37
  16. package/release.js +0 -7
  17. package/src/slim-select/accessibility.test.ts +782 -0
  18. package/src/slim-select/classes.test.ts +7 -6
  19. package/src/slim-select/classes.ts +8 -6
  20. package/src/slim-select/helpers.test.ts +10 -8
  21. package/src/slim-select/index.test.ts +509 -14
  22. package/src/slim-select/index.ts +58 -31
  23. package/src/slim-select/react/index.tsx +9 -0
  24. package/src/slim-select/react/react.tsx +112 -0
  25. package/src/slim-select/render.test.ts +479 -119
  26. package/src/slim-select/render.ts +213 -91
  27. package/src/slim-select/select.test.ts +28 -10
  28. package/src/slim-select/select.ts +35 -12
  29. package/src/slim-select/settings.test.ts +19 -8
  30. package/src/slim-select/settings.ts +2 -4
  31. package/src/slim-select/slimselect.scss +30 -15
  32. package/src/slim-select/store.test.ts +13 -16
  33. package/src/slim-select/store.ts +97 -86
  34. package/src/slim-select/vue/index.ts +9 -0
  35. package/src/slim-select/vue/vue.test.ts +832 -0
  36. package/src/slim-select/{vue.vue → vue/vue.vue} +45 -23
  37. package/tsconfig.json +6 -4
  38. package/{vite.config.mts → vite.config.docs.mts} +10 -0
  39. package/vite.config.lib.mts +42 -0
  40. package/vite.config.react.mts +40 -0
  41. package/vite.config.vue.mts +39 -0
  42. package/vitest.config.ts +29 -0
  43. package/dist/classes.d.ts +0 -43
  44. package/dist/helpers.d.ts +0 -5
  45. package/dist/render.d.ts +0 -81
  46. package/dist/select.d.ts +0 -30
  47. package/dist/settings.d.ts +0 -35
  48. package/dist/slimselect.global.js +0 -2033
  49. package/dist/slimselect.js +0 -2036
  50. package/dist/slimselect.min.js +0 -1
  51. package/dist/slimselect.umd.min.js +0 -1
  52. package/dist/store.d.ts +0 -84
  53. package/jest.config.js +0 -14
  54. package/src/slim-select/rollup.config.mjs +0 -127
  55. package/src/slim-select/tsconfig.json +0 -14
@@ -0,0 +1,832 @@
1
+ import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest'
2
+ import { mount, VueWrapper } from '@vue/test-utils'
3
+ import { nextTick, PropType } from 'vue'
4
+ import SlimSelect, { Option, Events } from '@/slim-select'
5
+ import SlimSelectVue from './vue.vue'
6
+
7
+ describe('SlimSelect Vue Component', () => {
8
+ let wrapper: VueWrapper<any>
9
+ let consoleInfoSpy: ReturnType<typeof vi.spyOn>
10
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn>
11
+
12
+ beforeEach(() => {
13
+ // Mock console methods
14
+ consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
15
+ consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
16
+ })
17
+
18
+ afterEach(() => {
19
+ if (wrapper) {
20
+ wrapper.unmount()
21
+ }
22
+ // Restore console methods
23
+ consoleInfoSpy.mockRestore()
24
+ consoleWarnSpy.mockRestore()
25
+ })
26
+
27
+ describe('Basic Rendering', () => {
28
+ test('renders a select element', () => {
29
+ wrapper = mount(SlimSelectVue)
30
+ const select = wrapper.find('select')
31
+ expect(select.exists()).toBe(true)
32
+ })
33
+
34
+ test('renders with single select by default', () => {
35
+ wrapper = mount(SlimSelectVue)
36
+ const select = wrapper.find('select')
37
+ expect(select.attributes('multiple')).toBeUndefined()
38
+ })
39
+
40
+ test('renders with multiple attribute when multiple prop is true', () => {
41
+ wrapper = mount(SlimSelectVue, {
42
+ props: {
43
+ multiple: true
44
+ }
45
+ })
46
+ const select = wrapper.find('select')
47
+ expect(select.attributes('multiple')).toBeDefined()
48
+ })
49
+ })
50
+
51
+ describe('Slot Content', () => {
52
+ test('renders slot content', () => {
53
+ wrapper = mount(SlimSelectVue, {
54
+ slots: {
55
+ default: `
56
+ <option value="1">Option 1</option>
57
+ <option value="2">Option 2</option>
58
+ `
59
+ }
60
+ })
61
+
62
+ const options = wrapper.findAll('option')
63
+ expect(options).toHaveLength(2)
64
+ expect(options[0].text()).toBe('Option 1')
65
+ expect(options[1].text()).toBe('Option 2')
66
+ })
67
+
68
+ test('shows warning when both slot and data prop are provided', () => {
69
+ wrapper = mount(SlimSelectVue, {
70
+ props: {
71
+ data: [{ value: '1', text: 'Data Option' }]
72
+ },
73
+ slots: {
74
+ default: '<option value="2">Slot Option</option>'
75
+ }
76
+ })
77
+
78
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
79
+ expect.stringContaining('Both slot content and data prop are provided')
80
+ )
81
+ })
82
+
83
+ test('reactive slot content WITHOUT :selected attribute works with v-model', async () => {
84
+ // Test that v-model auto-syncs without needing :selected
85
+ const TestComponent = {
86
+ components: { SlimSelectVue },
87
+ template: `
88
+ <SlimSelectVue v-model="selected" multiple>
89
+ <option v-for="opt in options" :key="opt.value" :value="opt.value">
90
+ {{ opt.text }}
91
+ </option>
92
+ </SlimSelectVue>
93
+ `,
94
+ data() {
95
+ return {
96
+ selected: ['v1'] as string[],
97
+ options: [
98
+ { value: 'v1', text: 'Value 1' },
99
+ { value: 'v2', text: 'Value 2' },
100
+ { value: 'v3', text: 'Value 3' }
101
+ ]
102
+ }
103
+ }
104
+ }
105
+
106
+ const wrapper = mount(TestComponent)
107
+ await nextTick()
108
+
109
+ // Verify initial selection works without :selected
110
+ const slimComponent = wrapper.findComponent(SlimSelectVue)
111
+ const slim = (slimComponent.vm as any).slim as SlimSelect
112
+ expect(slim.getSelected()).toEqual(['v1'])
113
+
114
+ // Parent component updates selected
115
+ ;(wrapper.vm.selected as string[]) = ['v2', 'v3']
116
+ await nextTick()
117
+
118
+ // Verify SlimSelect reflects the change
119
+ expect(slim.getSelected()).toEqual(['v2', 'v3'])
120
+ })
121
+ })
122
+
123
+ describe('v-model binding', () => {
124
+ test('accepts initial modelValue for single select', async () => {
125
+ wrapper = mount(SlimSelectVue, {
126
+ props: {
127
+ modelValue: '2',
128
+ data: [
129
+ { value: '1', text: 'Option 1' },
130
+ { value: '2', text: 'Option 2' },
131
+ { value: '3', text: 'Option 3' }
132
+ ]
133
+ }
134
+ })
135
+
136
+ await nextTick()
137
+ const slim = (wrapper.vm as any).slim as SlimSelect
138
+ const selected = slim.getSelected()
139
+ expect(selected[0]).toBe('2')
140
+ })
141
+
142
+ test('accepts initial modelValue for multiple select', async () => {
143
+ wrapper = mount(SlimSelectVue, {
144
+ props: {
145
+ modelValue: ['2', '3'],
146
+ multiple: true,
147
+ data: [
148
+ { value: '1', text: 'Option 1' },
149
+ { value: '2', text: 'Option 2' },
150
+ { value: '3', text: 'Option 3' }
151
+ ]
152
+ }
153
+ })
154
+
155
+ await nextTick()
156
+ const slim = (wrapper.vm as any).slim as SlimSelect
157
+ const selected = slim.getSelected()
158
+ expect(selected).toEqual(['2', '3'])
159
+ })
160
+
161
+ test('emits update:modelValue when selection changes', async () => {
162
+ wrapper = mount(SlimSelectVue, {
163
+ props: {
164
+ modelValue: '1',
165
+ data: [
166
+ { value: '1', text: 'Option 1' },
167
+ { value: '2', text: 'Option 2' }
168
+ ]
169
+ }
170
+ })
171
+
172
+ await nextTick()
173
+ const slim = (wrapper.vm as any).slim as SlimSelect
174
+
175
+ // Simulate selection change
176
+ slim.setSelected('2')
177
+ await nextTick()
178
+
179
+ const emitted = wrapper.emitted('update:modelValue')
180
+ expect(emitted).toBeTruthy()
181
+ expect(emitted?.[0]).toEqual(['2'])
182
+ })
183
+
184
+ test('updates selection when modelValue prop changes', async () => {
185
+ wrapper = mount(SlimSelectVue, {
186
+ props: {
187
+ modelValue: '1',
188
+ data: [
189
+ { value: '1', text: 'Option 1' },
190
+ { value: '2', text: 'Option 2' }
191
+ ]
192
+ }
193
+ })
194
+
195
+ await nextTick()
196
+
197
+ // Update modelValue
198
+ await wrapper.setProps({ modelValue: '2' })
199
+ await nextTick()
200
+
201
+ const slim = (wrapper.vm as any).slim as SlimSelect
202
+ const selected = slim.getSelected()
203
+ expect(selected[0]).toBe('2')
204
+ })
205
+ })
206
+
207
+ describe('Data Prop', () => {
208
+ test('initializes with data prop', async () => {
209
+ const testData = [
210
+ { value: '1', text: 'Option 1' },
211
+ { value: '2', text: 'Option 2' },
212
+ { value: '3', text: 'Option 3' }
213
+ ]
214
+
215
+ wrapper = mount(SlimSelectVue, {
216
+ props: {
217
+ data: testData
218
+ }
219
+ })
220
+
221
+ await nextTick()
222
+ const slim = (wrapper.vm as any).slim as SlimSelect
223
+ const data = slim.getData()
224
+
225
+ expect(data).toHaveLength(3)
226
+ })
227
+
228
+ test('updates data when data prop changes', async () => {
229
+ wrapper = mount(SlimSelectVue, {
230
+ props: {
231
+ data: [
232
+ { value: '1', text: 'Option 1' },
233
+ { value: '2', text: 'Option 2' }
234
+ ]
235
+ }
236
+ })
237
+
238
+ await nextTick()
239
+
240
+ // Update data prop
241
+ await wrapper.setProps({
242
+ data: [
243
+ { value: '3', text: 'Option 3' },
244
+ { value: '4', text: 'Option 4' },
245
+ { value: '5', text: 'Option 5' }
246
+ ]
247
+ })
248
+
249
+ await nextTick()
250
+ const slim = (wrapper.vm as any).slim as SlimSelect
251
+ const data = slim.getData()
252
+
253
+ expect(data).toHaveLength(3)
254
+ })
255
+ })
256
+
257
+ describe('Settings Prop', () => {
258
+ test('applies settings prop', async () => {
259
+ wrapper = mount(SlimSelectVue, {
260
+ props: {
261
+ data: [{ value: '1', text: 'Option 1' }],
262
+ settings: {
263
+ showSearch: false,
264
+ placeholderText: 'Custom Placeholder'
265
+ }
266
+ }
267
+ })
268
+
269
+ await nextTick()
270
+ const slim = (wrapper.vm as any).slim as SlimSelect
271
+
272
+ expect(slim.settings.showSearch).toBe(false)
273
+ expect(slim.settings.placeholderText).toBe('Custom Placeholder')
274
+ })
275
+ })
276
+
277
+ describe('Events Prop', () => {
278
+ test('calls afterChange event from events prop', async () => {
279
+ const afterChangeMock = vi.fn()
280
+
281
+ wrapper = mount(SlimSelectVue, {
282
+ props: {
283
+ modelValue: '1',
284
+ data: [
285
+ { value: '1', text: 'Option 1' },
286
+ { value: '2', text: 'Option 2' }
287
+ ],
288
+ events: {
289
+ afterChange: afterChangeMock
290
+ }
291
+ }
292
+ })
293
+
294
+ await nextTick()
295
+ const slim = (wrapper.vm as any).slim as SlimSelect
296
+
297
+ // Trigger a change
298
+ slim.setSelected('2')
299
+ await nextTick()
300
+
301
+ expect(afterChangeMock).toHaveBeenCalled()
302
+ })
303
+
304
+ test('calls both custom afterChange and emits update:modelValue', async () => {
305
+ const afterChangeMock = vi.fn()
306
+
307
+ wrapper = mount(SlimSelectVue, {
308
+ props: {
309
+ modelValue: '1',
310
+ data: [
311
+ { value: '1', text: 'Option 1' },
312
+ { value: '2', text: 'Option 2' }
313
+ ],
314
+ events: {
315
+ afterChange: afterChangeMock
316
+ }
317
+ }
318
+ })
319
+
320
+ await nextTick()
321
+ const slim = (wrapper.vm as any).slim as SlimSelect
322
+
323
+ // Trigger a change
324
+ slim.setSelected('2')
325
+ await nextTick()
326
+
327
+ // Both should be called
328
+ expect(afterChangeMock).toHaveBeenCalled()
329
+ const emitted = wrapper.emitted('update:modelValue')
330
+ expect(emitted).toBeTruthy()
331
+ expect(emitted?.[0]).toEqual(['2'])
332
+ })
333
+ })
334
+
335
+ describe('Component Methods', () => {
336
+ test('getSlimSelect returns SlimSelect instance', async () => {
337
+ wrapper = mount(SlimSelectVue, {
338
+ props: {
339
+ data: [{ value: '1', text: 'Option 1' }]
340
+ }
341
+ })
342
+
343
+ await nextTick()
344
+ const slimInstance = (wrapper.vm as any).getSlimSelect()
345
+
346
+ expect(slimInstance).toBeInstanceOf(SlimSelect)
347
+ })
348
+
349
+ test('getCleanValue handles string values correctly', () => {
350
+ wrapper = mount(SlimSelectVue, {
351
+ props: {
352
+ multiple: false
353
+ }
354
+ })
355
+
356
+ const result = (wrapper.vm as any).getCleanValue('test')
357
+ expect(result).toBe('test')
358
+ })
359
+
360
+ test('getCleanValue converts string to array for multiple select', () => {
361
+ wrapper = mount(SlimSelectVue, {
362
+ props: {
363
+ multiple: true
364
+ }
365
+ })
366
+
367
+ const result = (wrapper.vm as any).getCleanValue('test')
368
+ expect(result).toEqual(['test'])
369
+ })
370
+
371
+ test('getCleanValue converts array to string for single select', () => {
372
+ wrapper = mount(SlimSelectVue, {
373
+ props: {
374
+ multiple: false
375
+ }
376
+ })
377
+
378
+ const result = (wrapper.vm as any).getCleanValue(['test1', 'test2'])
379
+ expect(result).toBe('test1')
380
+ })
381
+
382
+ test('getCleanValue returns empty array for undefined value in multiple select', () => {
383
+ wrapper = mount(SlimSelectVue, {
384
+ props: {
385
+ multiple: true
386
+ }
387
+ })
388
+
389
+ const result = (wrapper.vm as any).getCleanValue(undefined)
390
+ expect(result).toEqual([])
391
+ })
392
+
393
+ test('getCleanValue returns empty string for undefined value in single select', () => {
394
+ wrapper = mount(SlimSelectVue, {
395
+ props: {
396
+ multiple: false
397
+ }
398
+ })
399
+
400
+ const result = (wrapper.vm as any).getCleanValue(undefined)
401
+ expect(result).toBe('')
402
+ })
403
+ })
404
+
405
+ describe('Lifecycle', () => {
406
+ test('initializes SlimSelect on mount', async () => {
407
+ wrapper = mount(SlimSelectVue, {
408
+ props: {
409
+ data: [{ value: '1', text: 'Option 1' }]
410
+ }
411
+ })
412
+
413
+ await nextTick()
414
+ const slim = (wrapper.vm as any).slim
415
+
416
+ expect(slim).not.toBeNull()
417
+ expect(slim).toBeInstanceOf(SlimSelect)
418
+ })
419
+
420
+ test('destroys SlimSelect on unmount', async () => {
421
+ wrapper = mount(SlimSelectVue, {
422
+ props: {
423
+ data: [{ value: '1', text: 'Option 1' }]
424
+ }
425
+ })
426
+
427
+ await nextTick()
428
+ const slim = (wrapper.vm as any).slim as SlimSelect
429
+ const destroySpy = vi.spyOn(slim, 'destroy')
430
+
431
+ wrapper.unmount()
432
+
433
+ expect(destroySpy).toHaveBeenCalled()
434
+ })
435
+ })
436
+
437
+ describe('Value Type Handling', () => {
438
+ test('handles empty string as modelValue for single select', async () => {
439
+ wrapper = mount(SlimSelectVue, {
440
+ props: {
441
+ modelValue: '',
442
+ data: [
443
+ { value: '1', text: 'Option 1' },
444
+ { value: '2', text: 'Option 2' }
445
+ ]
446
+ }
447
+ })
448
+
449
+ await nextTick()
450
+ expect(wrapper.props('modelValue')).toBe('')
451
+ })
452
+
453
+ test('handles empty array as modelValue for multiple select', async () => {
454
+ wrapper = mount(SlimSelectVue, {
455
+ props: {
456
+ modelValue: [],
457
+ multiple: true,
458
+ data: [
459
+ { value: '1', text: 'Option 1' },
460
+ { value: '2', text: 'Option 2' }
461
+ ]
462
+ }
463
+ })
464
+
465
+ await nextTick()
466
+ expect(wrapper.props('modelValue')).toEqual([])
467
+ })
468
+ })
469
+
470
+ describe('Reactivity Edge Cases', () => {
471
+ test('does not cause conflicts with v-once on slot', async () => {
472
+ // This test ensures v-once prevents reactivity issues
473
+ wrapper = mount(SlimSelectVue, {
474
+ slots: {
475
+ default: `
476
+ <option value="1">Option 1</option>
477
+ <option value="2">Option 2</option>
478
+ `
479
+ }
480
+ })
481
+
482
+ await nextTick()
483
+
484
+ // The slot should render only once
485
+ const initialOptions = wrapper.findAll('option')
486
+ expect(initialOptions).toHaveLength(2)
487
+
488
+ // SlimSelect should be initialized without errors
489
+ const slim = (wrapper.vm as any).slim
490
+ expect(slim).toBeInstanceOf(SlimSelect)
491
+ })
492
+
493
+ test('data prop changes are reactive', async () => {
494
+ const initialData = [
495
+ { value: '1', text: 'Option 1' },
496
+ { value: '2', text: 'Option 2' }
497
+ ]
498
+
499
+ wrapper = mount(SlimSelectVue, {
500
+ props: {
501
+ data: initialData
502
+ }
503
+ })
504
+
505
+ await nextTick()
506
+ let slim = (wrapper.vm as any).slim as SlimSelect
507
+ let data = slim.getData()
508
+ expect(data).toHaveLength(2)
509
+
510
+ // Update data prop
511
+ const newData = [
512
+ { value: '3', text: 'Option 3' },
513
+ { value: '4', text: 'Option 4' },
514
+ { value: '5', text: 'Option 5' }
515
+ ]
516
+
517
+ await wrapper.setProps({ data: newData })
518
+ await nextTick()
519
+
520
+ slim = (wrapper.vm as any).slim as SlimSelect
521
+ data = slim.getData()
522
+ expect(data).toHaveLength(3)
523
+ })
524
+ })
525
+
526
+ describe('Complex Parent-Child Reactivity Scenario', () => {
527
+ test('handles computed props from parent with bidirectional v-model updates', async () => {
528
+ // Simulate a parent component with computed values based on modelValue
529
+ // Similar to the CustomFields use case
530
+ const parentModelValue = {
531
+ field1: ['option1', 'option2'],
532
+ field2: ['option3']
533
+ }
534
+
535
+ const updateCallback = vi.fn()
536
+
537
+ // Create wrapper with data computed from parent's modelValue
538
+ wrapper = mount(SlimSelectVue, {
539
+ props: {
540
+ modelValue: parentModelValue.field1,
541
+ multiple: true,
542
+ data: [
543
+ { value: 'option1', text: 'Option 1' },
544
+ { value: 'option2', text: 'Option 2' },
545
+ { value: 'option3', text: 'Option 3' },
546
+ { value: 'option4', text: 'Option 4' }
547
+ ],
548
+ events: {
549
+ afterChange: updateCallback
550
+ }
551
+ }
552
+ })
553
+
554
+ await nextTick()
555
+
556
+ // Verify initial selection from parent's computed value
557
+ const slim = (wrapper.vm as any).slim as SlimSelect
558
+ let selected = slim.getSelected()
559
+ expect(selected).toEqual(['option1', 'option2'])
560
+
561
+ // User makes a change in SlimSelect
562
+ slim.setSelected(['option2', 'option3', 'option4'])
563
+ await nextTick()
564
+
565
+ // Verify the change callback was called
566
+ expect(updateCallback).toHaveBeenCalled()
567
+
568
+ // Verify v-model emitted the update
569
+ const emitted = wrapper.emitted('update:modelValue')
570
+ expect(emitted).toBeTruthy()
571
+ expect(emitted?.[emitted.length - 1]).toEqual([['option2', 'option3', 'option4']])
572
+ })
573
+
574
+ test('updates when parent changes computed data based on new modelValue', async () => {
575
+ // Start with initial values
576
+ const fieldValues = [
577
+ { value: 'opt1', text: 'Option 1' },
578
+ { value: 'opt2', text: 'Option 2' },
579
+ { value: 'opt3', text: 'Option 3' }
580
+ ]
581
+
582
+ wrapper = mount(SlimSelectVue, {
583
+ props: {
584
+ modelValue: ['opt1'],
585
+ multiple: true,
586
+ data: fieldValues.map((v) => ({
587
+ value: v.value,
588
+ text: v.text,
589
+ selected: ['opt1'].includes(v.value)
590
+ }))
591
+ }
592
+ })
593
+
594
+ await nextTick()
595
+ let slim = (wrapper.vm as any).slim as SlimSelect
596
+ let selected = slim.getSelected()
597
+ expect(selected).toEqual(['opt1'])
598
+
599
+ // Parent updates its modelValue (e.g., from API or another form field)
600
+ // This should update the computed values and SlimSelect
601
+ await wrapper.setProps({
602
+ modelValue: ['opt2', 'opt3'],
603
+ data: fieldValues.map((v) => ({
604
+ value: v.value,
605
+ text: v.text,
606
+ selected: ['opt2', 'opt3'].includes(v.value)
607
+ }))
608
+ })
609
+
610
+ await nextTick()
611
+
612
+ // Verify SlimSelect reflects the new selection
613
+ slim = (wrapper.vm as any).slim as SlimSelect
614
+ selected = slim.getSelected()
615
+ expect(selected).toEqual(['opt2', 'opt3'])
616
+ })
617
+
618
+ test('handles afterChange callback that triggers parent valueChange method', async () => {
619
+ // Simulates the pattern: user changes SlimSelect -> afterChange -> parent's valueChange() -> emit
620
+ let parentValues = {
621
+ customField1: ['value1']
622
+ }
623
+
624
+ const valueChangeCallback = vi.fn(() => {
625
+ // Simulate parent's valueChange method that formats and emits
626
+ const formatted = { customField1: parentValues.customField1 }
627
+ return formatted
628
+ })
629
+
630
+ wrapper = mount(SlimSelectVue, {
631
+ props: {
632
+ modelValue: parentValues.customField1,
633
+ multiple: true,
634
+ data: [
635
+ { value: 'value1', text: 'Value 1' },
636
+ { value: 'value2', text: 'Value 2' },
637
+ { value: 'value3', text: 'Value 3' }
638
+ ],
639
+ events: {
640
+ afterChange: (newVal: Option[]) => {
641
+ // Update parent's local values
642
+ parentValues.customField1 = newVal.map((o: Option) => o.value)
643
+ // Call parent's valueChange method
644
+ valueChangeCallback()
645
+ }
646
+ } as Events
647
+ }
648
+ })
649
+
650
+ await nextTick()
651
+
652
+ // User changes selection
653
+ const slim = (wrapper.vm as any).slim as SlimSelect
654
+ slim.setSelected(['value2', 'value3'])
655
+ await nextTick()
656
+
657
+ // Verify the callback chain was triggered
658
+ expect(valueChangeCallback).toHaveBeenCalled()
659
+ expect(parentValues.customField1).toEqual(['value2', 'value3'])
660
+
661
+ // Verify v-model emission
662
+ const emitted = wrapper.emitted('update:modelValue')
663
+ expect(emitted).toBeTruthy()
664
+ expect(emitted?.[emitted.length - 1]).toEqual([['value2', 'value3']])
665
+ })
666
+
667
+ test('maintains reactivity through multiple prop updates from parent computed', async () => {
668
+ // Test that reactivity works through multiple cycles
669
+ wrapper = mount(SlimSelectVue, {
670
+ props: {
671
+ modelValue: ['a'],
672
+ multiple: true,
673
+ data: [
674
+ { value: 'a', text: 'A' },
675
+ { value: 'b', text: 'B' },
676
+ { value: 'c', text: 'C' }
677
+ ]
678
+ }
679
+ })
680
+
681
+ await nextTick()
682
+ let slim = (wrapper.vm as any).slim as SlimSelect
683
+ expect(slim.getSelected()).toEqual(['a'])
684
+
685
+ // First parent update
686
+ await wrapper.setProps({ modelValue: ['b'] })
687
+ await nextTick()
688
+ expect(slim.getSelected()).toEqual(['b'])
689
+
690
+ // Second parent update
691
+ await wrapper.setProps({ modelValue: ['a', 'c'] })
692
+ await nextTick()
693
+ expect(slim.getSelected()).toEqual(['a', 'c'])
694
+
695
+ // User makes a change
696
+ slim.setSelected(['b', 'c'])
697
+ await nextTick()
698
+
699
+ // Verify emission
700
+ const emitted = wrapper.emitted('update:modelValue')
701
+ expect(emitted?.[emitted.length - 1]).toEqual([['b', 'c']])
702
+
703
+ // Parent responds to the change
704
+ await wrapper.setProps({ modelValue: ['b', 'c'] })
705
+ await nextTick()
706
+ expect(slim.getSelected()).toEqual(['b', 'c'])
707
+ })
708
+
709
+ test('handles data prop updates from parent computed with selected state', async () => {
710
+ // Simulates when parent recomputes data array with updated selected states
711
+ const allOptions = [
712
+ { value: 'v1', text: 'Value 1' },
713
+ { value: 'v2', text: 'Value 2' },
714
+ { value: 'v3', text: 'Value 3' }
715
+ ]
716
+
717
+ const computeDataProp = (selectedValues: string[]) => {
718
+ return allOptions.map((opt) => ({
719
+ value: opt.value,
720
+ text: opt.text,
721
+ selected: selectedValues.includes(opt.value)
722
+ }))
723
+ }
724
+
725
+ wrapper = mount(SlimSelectVue, {
726
+ props: {
727
+ modelValue: ['v1'],
728
+ multiple: true,
729
+ data: computeDataProp(['v1'])
730
+ }
731
+ })
732
+
733
+ await nextTick()
734
+ let slim = (wrapper.vm as any).slim as SlimSelect
735
+ expect(slim.getSelected()).toEqual(['v1'])
736
+
737
+ // Parent's modelValue changes, triggering recompute of data prop
738
+ await wrapper.setProps({
739
+ modelValue: ['v2', 'v3'],
740
+ data: computeDataProp(['v2', 'v3'])
741
+ })
742
+
743
+ await nextTick()
744
+ slim = (wrapper.vm as any).slim as SlimSelect
745
+ expect(slim.getSelected()).toEqual(['v2', 'v3'])
746
+
747
+ // Verify data was updated
748
+ const data = slim.getData()
749
+ expect(data).toHaveLength(3)
750
+ })
751
+
752
+ test('CustomFields use case: parent-child pattern with reactive slot content', async () => {
753
+ // Recreate the actual working pattern: parent passes value to child via v-model
754
+ const ChildComponent = {
755
+ components: { SlimSelectVue },
756
+ template: `
757
+ <SlimSelectVue
758
+ v-model="value"
759
+ multiple
760
+ :events="{ afterChange: () => handleChange() }"
761
+ >
762
+ <option v-for="opt in fieldOptions" :key="opt.value" :value="opt.value">
763
+ {{ opt.name }}
764
+ </option>
765
+ </SlimSelectVue>
766
+ `,
767
+ props: {
768
+ modelValue: { type: Array as PropType<string[]>, required: true },
769
+ fieldOptions: { type: Array, required: true }
770
+ },
771
+ emits: ['update:modelValue'],
772
+ computed: {
773
+ value: {
774
+ get(this: any): string[] {
775
+ return this.modelValue || []
776
+ },
777
+ set(this: any, newValue: string[]) {
778
+ this.$emit('update:modelValue', newValue)
779
+ }
780
+ }
781
+ },
782
+ methods: {
783
+ handleChange() {
784
+ // Handle selection change
785
+ }
786
+ }
787
+ }
788
+
789
+ const ParentComponent = {
790
+ components: { ChildComponent },
791
+ template: `
792
+ <ChildComponent
793
+ v-model="selectedValues"
794
+ :field-options="options"
795
+ />
796
+ `,
797
+ data() {
798
+ return {
799
+ selectedValues: ['value1'] as string[],
800
+ options: [
801
+ { value: 'value1', name: 'Value 1' },
802
+ { value: 'value2', name: 'Value 2' },
803
+ { value: 'value3', name: 'Value 3' }
804
+ ]
805
+ }
806
+ }
807
+ }
808
+
809
+ const wrapper = mount(ParentComponent)
810
+ await nextTick()
811
+
812
+ // Verify initial state
813
+ const slimComponent = wrapper.findComponent(SlimSelectVue)
814
+ const slim = (slimComponent.vm as any).slim as SlimSelect
815
+ expect(slim.getSelected()).toEqual(['value1'])
816
+
817
+ // User changes selection via SlimSelect
818
+ slim.setSelected(['value2', 'value3'])
819
+ await nextTick()
820
+
821
+ // Verify parent data updated
822
+ expect((wrapper.vm as any).selectedValues).toEqual(['value2', 'value3'])
823
+
824
+ // Parent's data changes (from API or other form)
825
+ ;((wrapper.vm as any).selectedValues as string[]) = ['value1', 'value3']
826
+ await nextTick()
827
+
828
+ // Verify SlimSelect reflects the new selection
829
+ expect(slim.getSelected()).toEqual(['value1', 'value3'])
830
+ })
831
+ })
832
+ })