slim-select 3.0.6 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/index.d.ts +8 -0
- package/dist/react/index.d.ts +8 -0
- package/dist/react/index.js +415 -328
- package/dist/react/index.umd.js +4 -4
- package/dist/slimselect.cjs.js +1 -1
- package/dist/slimselect.cjs.js.map +1 -1
- package/dist/slimselect.es.js +108 -39
- package/dist/slimselect.es.js.map +1 -1
- package/dist/slimselect.iife.js +1 -1
- package/dist/slimselect.iife.js.map +1 -1
- package/dist/slimselect.js +1 -1
- package/dist/slimselect.js.map +1 -1
- package/dist/vue/index.d.ts +8 -0
- package/dist/vue/index.js +139 -54
- package/dist/vue/index.umd.js +1 -1
- package/package.json +2 -1
- package/src/slim-select/index.test.ts +356 -0
- package/src/slim-select/index.ts +17 -2
- package/src/slim-select/react/react.test.tsx +498 -0
- package/src/slim-select/react/react.tsx +102 -5
- package/src/slim-select/select.test.ts +234 -0
- package/src/slim-select/select.ts +196 -0
- package/src/slim-select/vue/vue.test.ts +259 -0
- package/src/slim-select/vue/vue.vue +85 -25
- package/vitest.config.ts +3 -2
package/src/slim-select/index.ts
CHANGED
|
@@ -119,6 +119,10 @@ export default class SlimSelect {
|
|
|
119
119
|
|
|
120
120
|
// Set select class
|
|
121
121
|
this.select = new Select(this.selectEl)
|
|
122
|
+
// Ensure the select has an id for label associations
|
|
123
|
+
if (!this.selectEl.id) {
|
|
124
|
+
this.selectEl.id = this.settings.id
|
|
125
|
+
}
|
|
122
126
|
this.select.updateSelect(this.settings.id, this.settings.style, this.settings.class)
|
|
123
127
|
this.select.hideUI() // Hide the original select element
|
|
124
128
|
|
|
@@ -142,8 +146,16 @@ export default class SlimSelect {
|
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
148
|
this.select.onOptionsChange = (data: (Option | Optgroup)[]) => {
|
|
145
|
-
//
|
|
146
|
-
|
|
149
|
+
// Process the data (including empty data for clearing all options)
|
|
150
|
+
// Empty data is safe to process here because if we were updating, the change would be queued
|
|
151
|
+
// and onOptionsChange wouldn't be called directly
|
|
152
|
+
this.setData(data || [])
|
|
153
|
+
}
|
|
154
|
+
// Set up label click handler to open SlimSelect
|
|
155
|
+
this.select.onLabelClick = () => {
|
|
156
|
+
if (!this.settings.disabled) {
|
|
157
|
+
this.open()
|
|
158
|
+
}
|
|
147
159
|
}
|
|
148
160
|
|
|
149
161
|
// Set store class
|
|
@@ -209,6 +221,9 @@ export default class SlimSelect {
|
|
|
209
221
|
this.open()
|
|
210
222
|
}
|
|
211
223
|
|
|
224
|
+
// Set up label handlers to open SlimSelect when label is clicked
|
|
225
|
+
this.select.setupLabelHandlers()
|
|
226
|
+
|
|
212
227
|
// Add SlimSelect to select element
|
|
213
228
|
;(this.selectEl as any).slim = this
|
|
214
229
|
}
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { render, cleanup, act, waitFor } from '@testing-library/react'
|
|
3
|
+
import { useState, useEffect, useRef } from 'react'
|
|
4
|
+
import SlimSelect, { Option } from '../index'
|
|
5
|
+
import SlimSelectReact, { SlimSelectRef } from './react'
|
|
6
|
+
|
|
7
|
+
describe('SlimSelect React Component', () => {
|
|
8
|
+
let consoleInfoSpy: any
|
|
9
|
+
let consoleWarnSpy: any
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
// Mock console methods
|
|
13
|
+
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
14
|
+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cleanup()
|
|
19
|
+
// Restore console methods
|
|
20
|
+
consoleInfoSpy.mockRestore()
|
|
21
|
+
consoleWarnSpy.mockRestore()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('Basic Rendering', () => {
|
|
25
|
+
test('renders a select element', () => {
|
|
26
|
+
const { container } = render(<SlimSelectReact />)
|
|
27
|
+
const select = container.querySelector('select')
|
|
28
|
+
expect(select).toBeTruthy()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('renders with single select by default', () => {
|
|
32
|
+
const { container } = render(<SlimSelectReact />)
|
|
33
|
+
const select = container.querySelector('select')
|
|
34
|
+
expect(select?.hasAttribute('multiple')).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('renders with multiple attribute when multiple prop is true', () => {
|
|
38
|
+
const { container } = render(<SlimSelectReact multiple />)
|
|
39
|
+
const select = container.querySelector('select')
|
|
40
|
+
expect(select?.hasAttribute('multiple')).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('Value and onChange binding', () => {
|
|
45
|
+
test('accepts initial value for single select', async () => {
|
|
46
|
+
const TestComponent = () => {
|
|
47
|
+
const [value, setValue] = useState('2')
|
|
48
|
+
const ref = useRef<SlimSelectRef>(null)
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<SlimSelectReact
|
|
52
|
+
ref={ref}
|
|
53
|
+
value={value}
|
|
54
|
+
onChange={(val) => setValue(val as string)}
|
|
55
|
+
data={[
|
|
56
|
+
{ value: '1', text: 'Option 1' },
|
|
57
|
+
{ value: '2', text: 'Option 2' },
|
|
58
|
+
{ value: '3', text: 'Option 3' }
|
|
59
|
+
]}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { container } = render(<TestComponent />)
|
|
65
|
+
|
|
66
|
+
await waitFor(
|
|
67
|
+
() => {
|
|
68
|
+
const select = container.querySelector('select')
|
|
69
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
70
|
+
if (slimInstance) {
|
|
71
|
+
const selected = slimInstance.getSelected()
|
|
72
|
+
expect(selected[0]).toBe('2')
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{ timeout: 500 }
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('accepts initial value for multiple select', async () => {
|
|
80
|
+
const TestComponent = () => {
|
|
81
|
+
const [value, setValue] = useState(['2', '3'])
|
|
82
|
+
return (
|
|
83
|
+
<SlimSelectReact
|
|
84
|
+
value={value}
|
|
85
|
+
onChange={(val) => setValue(val as string[])}
|
|
86
|
+
multiple
|
|
87
|
+
data={[
|
|
88
|
+
{ value: '1', text: 'Option 1' },
|
|
89
|
+
{ value: '2', text: 'Option 2' },
|
|
90
|
+
{ value: '3', text: 'Option 3' }
|
|
91
|
+
]}
|
|
92
|
+
/>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { container } = render(<TestComponent />)
|
|
97
|
+
|
|
98
|
+
await waitFor(
|
|
99
|
+
() => {
|
|
100
|
+
const select = container.querySelector('select')
|
|
101
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
102
|
+
if (slimInstance) {
|
|
103
|
+
const selected = slimInstance.getSelected()
|
|
104
|
+
expect(selected).toEqual(['2', '3'])
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{ timeout: 500 }
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('calls onChange when selection changes', async () => {
|
|
112
|
+
const onChangeMock = vi.fn()
|
|
113
|
+
const TestComponent = () => {
|
|
114
|
+
return (
|
|
115
|
+
<SlimSelectReact
|
|
116
|
+
value="1"
|
|
117
|
+
onChange={onChangeMock}
|
|
118
|
+
data={[
|
|
119
|
+
{ value: '1', text: 'Option 1' },
|
|
120
|
+
{ value: '2', text: 'Option 2' }
|
|
121
|
+
]}
|
|
122
|
+
/>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { container } = render(<TestComponent />)
|
|
127
|
+
|
|
128
|
+
await waitFor(
|
|
129
|
+
async () => {
|
|
130
|
+
const select = container.querySelector('select')
|
|
131
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
132
|
+
if (slimInstance) {
|
|
133
|
+
await act(async () => {
|
|
134
|
+
slimInstance.setSelected('2')
|
|
135
|
+
})
|
|
136
|
+
await waitFor(
|
|
137
|
+
() => {
|
|
138
|
+
expect(onChangeMock).toHaveBeenCalledWith('2')
|
|
139
|
+
},
|
|
140
|
+
{ timeout: 500 }
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
{ timeout: 500 }
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('updates selection when value prop changes', async () => {
|
|
149
|
+
const TestComponent = ({ initialValue }: { initialValue: string }) => {
|
|
150
|
+
const [value, setValue] = useState(initialValue)
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
// Simulate parent updating value
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
act(() => {
|
|
156
|
+
setValue('2')
|
|
157
|
+
})
|
|
158
|
+
}, 100)
|
|
159
|
+
return () => clearTimeout(timer)
|
|
160
|
+
}, [])
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<SlimSelectReact
|
|
164
|
+
value={value}
|
|
165
|
+
onChange={(val) => setValue(val as string)}
|
|
166
|
+
data={[
|
|
167
|
+
{ value: '1', text: 'Option 1' },
|
|
168
|
+
{ value: '2', text: 'Option 2' }
|
|
169
|
+
]}
|
|
170
|
+
/>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const { container } = render(<TestComponent initialValue="1" />)
|
|
175
|
+
|
|
176
|
+
await waitFor(
|
|
177
|
+
() => {
|
|
178
|
+
const select = container.querySelector('select')
|
|
179
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
180
|
+
if (slimInstance) {
|
|
181
|
+
const selected = slimInstance.getSelected()
|
|
182
|
+
expect(selected[0]).toBe('2')
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
{ timeout: 500 }
|
|
186
|
+
)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('Data Prop', () => {
|
|
191
|
+
test('initializes with data prop', async () => {
|
|
192
|
+
const testData = [
|
|
193
|
+
{ value: '1', text: 'Option 1' },
|
|
194
|
+
{ value: '2', text: 'Option 2' },
|
|
195
|
+
{ value: '3', text: 'Option 3' }
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
const { container } = render(<SlimSelectReact data={testData} />)
|
|
199
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
200
|
+
|
|
201
|
+
const select = container.querySelector('select')
|
|
202
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
203
|
+
if (slimInstance) {
|
|
204
|
+
const data = slimInstance.getData()
|
|
205
|
+
expect(data).toHaveLength(3)
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('updates data when data prop changes', async () => {
|
|
210
|
+
const TestComponent = () => {
|
|
211
|
+
const [data, setData] = useState([
|
|
212
|
+
{ value: '1', text: 'Option 1' },
|
|
213
|
+
{ value: '2', text: 'Option 2' }
|
|
214
|
+
])
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
const timer = setTimeout(() => {
|
|
218
|
+
act(() => {
|
|
219
|
+
setData([
|
|
220
|
+
{ value: '3', text: 'Option 3' },
|
|
221
|
+
{ value: '4', text: 'Option 4' },
|
|
222
|
+
{ value: '5', text: 'Option 5' }
|
|
223
|
+
])
|
|
224
|
+
})
|
|
225
|
+
}, 100)
|
|
226
|
+
return () => clearTimeout(timer)
|
|
227
|
+
}, [])
|
|
228
|
+
|
|
229
|
+
return <SlimSelectReact data={data} />
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { container } = render(<TestComponent />)
|
|
233
|
+
|
|
234
|
+
await waitFor(
|
|
235
|
+
() => {
|
|
236
|
+
const select = container.querySelector('select')
|
|
237
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
238
|
+
if (slimInstance) {
|
|
239
|
+
const data = slimInstance.getData()
|
|
240
|
+
expect(data).toHaveLength(3)
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
{ timeout: 500 }
|
|
244
|
+
)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
describe('Settings Prop', () => {
|
|
249
|
+
test('applies settings prop', async () => {
|
|
250
|
+
const { container } = render(
|
|
251
|
+
<SlimSelectReact
|
|
252
|
+
data={[{ value: '1', text: 'Option 1' }]}
|
|
253
|
+
settings={{
|
|
254
|
+
showSearch: false,
|
|
255
|
+
placeholderText: 'Custom Placeholder'
|
|
256
|
+
}}
|
|
257
|
+
/>
|
|
258
|
+
)
|
|
259
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
260
|
+
|
|
261
|
+
const select = container.querySelector('select')
|
|
262
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
263
|
+
if (slimInstance) {
|
|
264
|
+
expect(slimInstance.settings.showSearch).toBe(false)
|
|
265
|
+
expect(slimInstance.settings.placeholderText).toBe('Custom Placeholder')
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('Events Prop', () => {
|
|
271
|
+
test('calls afterChange event from events prop', async () => {
|
|
272
|
+
const afterChangeMock = vi.fn()
|
|
273
|
+
|
|
274
|
+
const TestComponent = () => {
|
|
275
|
+
return (
|
|
276
|
+
<SlimSelectReact
|
|
277
|
+
value="1"
|
|
278
|
+
data={[
|
|
279
|
+
{ value: '1', text: 'Option 1' },
|
|
280
|
+
{ value: '2', text: 'Option 2' }
|
|
281
|
+
]}
|
|
282
|
+
events={{
|
|
283
|
+
afterChange: afterChangeMock
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const { container } = render(<TestComponent />)
|
|
290
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
291
|
+
|
|
292
|
+
const select = container.querySelector('select')
|
|
293
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
294
|
+
if (slimInstance) {
|
|
295
|
+
slimInstance.setSelected('2')
|
|
296
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
297
|
+
expect(afterChangeMock).toHaveBeenCalled()
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('calls both custom afterChange and onChange', async () => {
|
|
302
|
+
const afterChangeMock = vi.fn()
|
|
303
|
+
const onChangeMock = vi.fn()
|
|
304
|
+
|
|
305
|
+
const TestComponent = () => {
|
|
306
|
+
return (
|
|
307
|
+
<SlimSelectReact
|
|
308
|
+
value="1"
|
|
309
|
+
onChange={onChangeMock}
|
|
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
|
+
|
|
321
|
+
const { container } = render(<TestComponent />)
|
|
322
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
323
|
+
|
|
324
|
+
const select = container.querySelector('select')
|
|
325
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
326
|
+
if (slimInstance) {
|
|
327
|
+
slimInstance.setSelected('2')
|
|
328
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
329
|
+
expect(afterChangeMock).toHaveBeenCalled()
|
|
330
|
+
expect(onChangeMock).toHaveBeenCalledWith('2')
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
describe('Empty and Invalid Values', () => {
|
|
336
|
+
test('handles empty string as value for single select', async () => {
|
|
337
|
+
const TestComponent = () => {
|
|
338
|
+
const [value, setValue] = useState('')
|
|
339
|
+
return (
|
|
340
|
+
<SlimSelectReact
|
|
341
|
+
value={value}
|
|
342
|
+
onChange={(val) => setValue(val as string)}
|
|
343
|
+
data={[
|
|
344
|
+
{ value: '1', text: 'Option 1' },
|
|
345
|
+
{ value: '2', text: 'Option 2' }
|
|
346
|
+
]}
|
|
347
|
+
/>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const { container } = render(<TestComponent />)
|
|
352
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
353
|
+
|
|
354
|
+
const select = container.querySelector('select')
|
|
355
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
356
|
+
if (slimInstance) {
|
|
357
|
+
// Verify placeholder exists in data
|
|
358
|
+
const data = slimInstance.getData()
|
|
359
|
+
const hasPlaceholder = (data as Option[]).some((opt: any) => opt.placeholder)
|
|
360
|
+
expect(hasPlaceholder).toBe(true)
|
|
361
|
+
|
|
362
|
+
// CRITICAL: Verify what's actually selected - should be empty string (placeholder)
|
|
363
|
+
const selectedValues = slimInstance.getSelected()
|
|
364
|
+
expect(selectedValues).toEqual([''])
|
|
365
|
+
|
|
366
|
+
// Verify no valid option is selected
|
|
367
|
+
const hasValidOptionSelected = selectedValues.some((val) => val !== '' && ['1', '2'].includes(val))
|
|
368
|
+
expect(hasValidOptionSelected).toBe(false)
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('shows placeholder when value does not exist in options for single select', async () => {
|
|
373
|
+
const TestComponent = () => {
|
|
374
|
+
const [value, setValue] = useState('banana')
|
|
375
|
+
return (
|
|
376
|
+
<SlimSelectReact
|
|
377
|
+
value={value}
|
|
378
|
+
onChange={(val) => setValue(val as string)}
|
|
379
|
+
data={[
|
|
380
|
+
{ value: '1', text: 'Option 1' },
|
|
381
|
+
{ value: '2', text: 'Option 2' },
|
|
382
|
+
{ value: '3', text: 'Option 3' }
|
|
383
|
+
]}
|
|
384
|
+
/>
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const { container } = render(<TestComponent />)
|
|
389
|
+
await waitFor(
|
|
390
|
+
() => {
|
|
391
|
+
const select = container.querySelector('select')
|
|
392
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
393
|
+
if (slimInstance) {
|
|
394
|
+
// Verify placeholder exists in data (with empty value, not 'banana')
|
|
395
|
+
const data = slimInstance.getData()
|
|
396
|
+
const hasPlaceholder = (data as Option[]).some((opt: any) => opt.placeholder)
|
|
397
|
+
expect(hasPlaceholder).toBe(true)
|
|
398
|
+
|
|
399
|
+
// CRITICAL: Check what's actually displayed in the SlimSelect UI
|
|
400
|
+
// SlimSelect renders a custom UI - check the .ss-single or .ss-placeholder element
|
|
401
|
+
const slimMain = container.querySelector('.ss-main') as HTMLElement
|
|
402
|
+
expect(slimMain).toBeDefined()
|
|
403
|
+
|
|
404
|
+
// Check what's in the .ss-values container
|
|
405
|
+
const ssValues = slimMain?.querySelector('.ss-values') as HTMLElement
|
|
406
|
+
expect(ssValues).toBeDefined()
|
|
407
|
+
|
|
408
|
+
// If placeholder is selected, it should show placeholder (no .ss-single element)
|
|
409
|
+
// If a real option is selected, it will show .ss-single with the option text
|
|
410
|
+
const singleValue = ssValues?.querySelector('.ss-single') as HTMLElement
|
|
411
|
+
const placeholderEl = ssValues?.querySelector('.ss-placeholder') as HTMLElement
|
|
412
|
+
|
|
413
|
+
// CRITICAL: If placeholder is correctly selected, we should NOT see .ss-single with "Option 1"
|
|
414
|
+
// Instead, we should either see placeholder OR empty (if placeholder text is empty)
|
|
415
|
+
if (singleValue) {
|
|
416
|
+
const displayedText = singleValue.textContent?.trim() || ''
|
|
417
|
+
// This should NOT be showing a real option
|
|
418
|
+
expect(displayedText).not.toBe('Option 1')
|
|
419
|
+
expect(displayedText).not.toBe('Option 2')
|
|
420
|
+
expect(displayedText).not.toBe('Option 3')
|
|
421
|
+
|
|
422
|
+
// If it's showing something, it might be the placeholder (empty text is OK)
|
|
423
|
+
// But we should verify the placeholder option is actually selected in the store
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Check the store to see what's actually selected
|
|
427
|
+
const selectedOptions = (slimInstance as any).store.getSelectedOptions()
|
|
428
|
+
const hasPlaceholderSelected = selectedOptions.some((opt: any) => opt.placeholder && opt.selected)
|
|
429
|
+
const hasValidOptionSelected = selectedOptions.some(
|
|
430
|
+
(opt: any) => opt.selected && !opt.placeholder && ['1', '2', '3'].includes(opt.value)
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
// The placeholder should be selected, not a valid option
|
|
434
|
+
expect(hasPlaceholderSelected).toBe(true)
|
|
435
|
+
expect(hasValidOptionSelected).toBe(false)
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
{ timeout: 500 }
|
|
439
|
+
)
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
test('handles empty array as value for multiple select', async () => {
|
|
443
|
+
const TestComponent = () => {
|
|
444
|
+
const [value, setValue] = useState([] as string[])
|
|
445
|
+
return (
|
|
446
|
+
<SlimSelectReact
|
|
447
|
+
value={value}
|
|
448
|
+
onChange={(val) => setValue(val as string[])}
|
|
449
|
+
multiple
|
|
450
|
+
data={[
|
|
451
|
+
{ value: '1', text: 'Option 1' },
|
|
452
|
+
{ value: '2', text: 'Option 2' }
|
|
453
|
+
]}
|
|
454
|
+
/>
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const { container } = render(<TestComponent />)
|
|
459
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
460
|
+
|
|
461
|
+
// Should not throw errors
|
|
462
|
+
expect(container.querySelector('select')).toBeTruthy()
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('updates onChange for multiple select when invalid values are provided', async () => {
|
|
466
|
+
const onChangeMock = vi.fn()
|
|
467
|
+
|
|
468
|
+
const TestComponent = () => {
|
|
469
|
+
return (
|
|
470
|
+
<SlimSelectReact
|
|
471
|
+
value={['banana', 'apple']}
|
|
472
|
+
onChange={onChangeMock}
|
|
473
|
+
multiple
|
|
474
|
+
data={[
|
|
475
|
+
{ value: '1', text: 'Option 1' },
|
|
476
|
+
{ value: '2', text: 'Option 2' },
|
|
477
|
+
{ value: '3', text: 'Option 3' }
|
|
478
|
+
]}
|
|
479
|
+
/>
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const { container } = render(<TestComponent />)
|
|
484
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
485
|
+
|
|
486
|
+
const select = container.querySelector('select')
|
|
487
|
+
const slimInstance = (select as any)?._slimSelect as SlimSelect
|
|
488
|
+
if (slimInstance) {
|
|
489
|
+
// When user manually selects valid options, onChange should update
|
|
490
|
+
slimInstance.setSelected(['1', '2'])
|
|
491
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
492
|
+
|
|
493
|
+
// Verify onChange has been called with the valid values
|
|
494
|
+
expect(onChangeMock).toHaveBeenCalledWith(['1', '2'])
|
|
495
|
+
}
|
|
496
|
+
})
|
|
497
|
+
})
|
|
498
|
+
})
|
|
@@ -22,12 +22,72 @@ const SlimSelect = forwardRef<SlimSelectRef, SlimSelectProps>(
|
|
|
22
22
|
const selectRef = useRef<HTMLSelectElement>(null)
|
|
23
23
|
const slimSelectRef = useRef<SlimSelectCore | null>(null)
|
|
24
24
|
const isInitialMount = useRef(true)
|
|
25
|
+
const currentValueRef = useRef<string | string[] | undefined>(value)
|
|
25
26
|
|
|
26
27
|
// Expose SlimSelect instance to parent via ref
|
|
27
28
|
useImperativeHandle(ref, () => ({
|
|
28
29
|
slimSelect: slimSelectRef.current
|
|
29
30
|
}))
|
|
30
31
|
|
|
32
|
+
// Helper to get clean value (similar to Vue's getCleanValue)
|
|
33
|
+
const getCleanValue = (val: string | string[] | undefined): string | string[] => {
|
|
34
|
+
// If its multiple and the value is a string, return an array with the string
|
|
35
|
+
if (typeof val === 'string') {
|
|
36
|
+
return multiple ? [val] : val
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// If its not multiple and the value is an array, return the first item
|
|
40
|
+
if (Array.isArray(val)) {
|
|
41
|
+
return multiple ? val : val[0]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return multiple ? [] : ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Helper to sync value to SlimSelect (similar to Vue's syncModelValueToSlimSelect)
|
|
48
|
+
const syncValueToSlimSelect = (val: string | string[] | undefined, runAfterChange: boolean = false) => {
|
|
49
|
+
if (!slimSelectRef.current) return
|
|
50
|
+
|
|
51
|
+
// Only sync if value is explicitly set (not undefined)
|
|
52
|
+
// This prevents adding placeholders when value is not provided
|
|
53
|
+
if (val === undefined) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cleanValue = getCleanValue(val)
|
|
58
|
+
const data = slimSelectRef.current.getData()
|
|
59
|
+
// Extract options from data (flatten optgroups if any)
|
|
60
|
+
const options = data.flatMap((item: Option | Optgroup) => ('label' in item ? item.options : [item])) as Option[]
|
|
61
|
+
|
|
62
|
+
// Check if the value exists in options
|
|
63
|
+
const valueExists = Array.isArray(cleanValue)
|
|
64
|
+
? cleanValue.length > 0 && cleanValue.every((val) => options.some((opt) => opt.value === val))
|
|
65
|
+
: cleanValue !== '' && options.some((opt) => opt.value === cleanValue)
|
|
66
|
+
|
|
67
|
+
// If value doesn't exist in options, add a placeholder option
|
|
68
|
+
if (!valueExists) {
|
|
69
|
+
// If its not multiple, add a placeholder option
|
|
70
|
+
if (!Array.isArray(cleanValue)) {
|
|
71
|
+
// For single select, check if placeholder already exists
|
|
72
|
+
const hasPlaceholder = options.some((opt) => opt.placeholder)
|
|
73
|
+
if (!hasPlaceholder) {
|
|
74
|
+
// Get current data and prepend placeholder option
|
|
75
|
+
const currentData = slimSelectRef.current.getData()
|
|
76
|
+
const placeholderOption: Partial<Option> = {
|
|
77
|
+
value: '',
|
|
78
|
+
text: '',
|
|
79
|
+
placeholder: true
|
|
80
|
+
}
|
|
81
|
+
slimSelectRef.current.setData([placeholderOption].concat(currentData))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Set the selected value (will select placeholder if it was just added)
|
|
87
|
+
// Match Vue component exactly - call setSelected with cleanValue directly
|
|
88
|
+
slimSelectRef.current.setSelected(cleanValue, runAfterChange)
|
|
89
|
+
}
|
|
90
|
+
|
|
31
91
|
// Initialize SlimSelect
|
|
32
92
|
useEffect(() => {
|
|
33
93
|
if (!selectRef.current) return
|
|
@@ -53,10 +113,41 @@ const SlimSelect = forwardRef<SlimSelectRef, SlimSelectProps>(
|
|
|
53
113
|
config.events = {
|
|
54
114
|
...events,
|
|
55
115
|
afterChange: (newVal) => {
|
|
56
|
-
|
|
116
|
+
if (!slimSelectRef.current) return
|
|
57
117
|
|
|
58
|
-
|
|
118
|
+
const newValue = multiple ? newVal.map((option) => option.value) : (newVal[0]?.value ?? '')
|
|
119
|
+
const slimData = slimSelectRef.current.getData()
|
|
120
|
+
const options = slimData.flatMap((item: Option | Optgroup) =>
|
|
121
|
+
'label' in item ? item.options : [item]
|
|
122
|
+
) as Option[]
|
|
123
|
+
|
|
124
|
+
// Check if the current value exists in options
|
|
125
|
+
const currentValue = currentValueRef.current
|
|
126
|
+
const currentValueExists =
|
|
127
|
+
currentValue === undefined
|
|
128
|
+
? false
|
|
129
|
+
: Array.isArray(currentValue)
|
|
130
|
+
? currentValue.length > 0 && currentValue.every((val) => options.some((opt) => opt.value === val))
|
|
131
|
+
: currentValue !== '' && options.some((opt) => opt.value === currentValue)
|
|
132
|
+
|
|
133
|
+
// Check if the new value is valid (exists in options)
|
|
134
|
+
const newValueIsValid = Array.isArray(newValue)
|
|
135
|
+
? newValue.length > 0 && newValue.every((val) => options.some((opt) => opt.value === val))
|
|
136
|
+
: newValue !== '' && options.some((opt) => opt.value === newValue)
|
|
137
|
+
|
|
138
|
+
// Check if value actually changed (properly compare arrays)
|
|
139
|
+
const valueChanged =
|
|
140
|
+
Array.isArray(newValue) && Array.isArray(currentValue)
|
|
141
|
+
? JSON.stringify(newValue.sort()) !== JSON.stringify(currentValue.sort())
|
|
142
|
+
: currentValue !== newValue
|
|
143
|
+
|
|
144
|
+
// Only call onChange if:
|
|
145
|
+
// 1. The value actually changed, AND
|
|
146
|
+
// 2. Either the current value exists in options, OR we're setting to a valid non-empty value
|
|
147
|
+
// This prevents clearing invalid values when we show placeholder
|
|
148
|
+
if (onChange && valueChanged && (currentValueExists || newValueIsValid)) {
|
|
59
149
|
onChange(newValue)
|
|
150
|
+
currentValueRef.current = newValue
|
|
60
151
|
}
|
|
61
152
|
|
|
62
153
|
if (ogAfterChange) {
|
|
@@ -67,9 +158,9 @@ const SlimSelect = forwardRef<SlimSelectRef, SlimSelectProps>(
|
|
|
67
158
|
|
|
68
159
|
slimSelectRef.current = new SlimSelectCore(config)
|
|
69
160
|
|
|
70
|
-
//
|
|
161
|
+
// Sync initial value if provided
|
|
71
162
|
if (value !== undefined) {
|
|
72
|
-
|
|
163
|
+
syncValueToSlimSelect(value, false)
|
|
73
164
|
}
|
|
74
165
|
|
|
75
166
|
return () => {
|
|
@@ -84,11 +175,13 @@ const SlimSelect = forwardRef<SlimSelectRef, SlimSelectProps>(
|
|
|
84
175
|
useEffect(() => {
|
|
85
176
|
if (isInitialMount.current) {
|
|
86
177
|
isInitialMount.current = false
|
|
178
|
+
currentValueRef.current = value
|
|
87
179
|
return
|
|
88
180
|
}
|
|
89
181
|
|
|
90
182
|
if (slimSelectRef.current && value !== undefined) {
|
|
91
|
-
|
|
183
|
+
currentValueRef.current = value
|
|
184
|
+
syncValueToSlimSelect(value, false)
|
|
92
185
|
}
|
|
93
186
|
}, [value])
|
|
94
187
|
|
|
@@ -96,6 +189,10 @@ const SlimSelect = forwardRef<SlimSelectRef, SlimSelectProps>(
|
|
|
96
189
|
useEffect(() => {
|
|
97
190
|
if (slimSelectRef.current && data && !isInitialMount.current) {
|
|
98
191
|
slimSelectRef.current.setData(data)
|
|
192
|
+
// Re-sync value after data changes
|
|
193
|
+
if (value !== undefined) {
|
|
194
|
+
syncValueToSlimSelect(value, false)
|
|
195
|
+
}
|
|
99
196
|
}
|
|
100
197
|
}, [data])
|
|
101
198
|
|