slim-select
Version:
Slim advanced select dropdown
1,325 lines (1,075 loc) • 43.8 kB
text/typescript
/**
* @jest-environment jsdom
*/
'use strict'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import SlimSelect from '@/slim-select'
describe('SlimSelect Module', () => {
let slim: SlimSelect
beforeEach(() => {
document.body.innerHTML = '<select id="test"></select>'
slim = new SlimSelect({
select: '#test'
})
})
afterEach(() => {
if (slim) {
slim.destroy()
}
})
describe('constructor', () => {
test('missing select element throws error', () => {
const errorMock = vi.fn()
const slim = new SlimSelect({ select: '#invalid', events: { error: errorMock } })
expect(slim.select).toBeUndefined()
expect(slim.store).toBeUndefined()
expect(slim.render).toBeUndefined()
expect(errorMock).toHaveBeenCalled()
expect(errorMock.mock.calls[0][0].message).toBe('Could not find select element')
})
test('invalid element throws error', () => {
document.body.innerHTML = '<div id="invalid"></div>'
const errorMock = vi.fn()
const slim = new SlimSelect({ select: '#invalid', events: { error: errorMock } })
expect(slim.select).toBeUndefined()
expect(slim.store).toBeUndefined()
expect(slim.render).toBeUndefined()
expect(errorMock).toHaveBeenCalled()
expect(errorMock.mock.calls[0][0].message).toBe('Element isnt of type select')
})
test('valid minimal constructor with query string', () => {
document.body.innerHTML = '<select id="test"></select>'
const slimSelect = new SlimSelect({
select: '#test'
})
expect(slimSelect).toBeInstanceOf(SlimSelect)
})
test('valid minimal constructor with HTML element', () => {
document.body.innerHTML = '<select id="test"></select>'
const slimSelect = new SlimSelect({
select: document.getElementById('test') as Element
})
expect(slimSelect).toBeInstanceOf(SlimSelect)
})
test('disabled gets applied correctly', () => {
document.body.innerHTML = '<select id="test"></select>'
const slim = new SlimSelect({
select: document.getElementById('test') as Element,
settings: {
disabled: true
}
})
expect(slim.settings.disabled).toBe(true)
})
})
test('enable', () => {
slim.settings.disabled = true
const selectEnableMock = vi.fn()
const renderEnableMock = vi.fn()
slim.select.enable = selectEnableMock
slim.render.enable = renderEnableMock
slim.enable()
expect(slim.settings.disabled).toBe(false)
expect(selectEnableMock).toHaveBeenCalled()
expect(renderEnableMock).toHaveBeenCalled()
})
test('disable', () => {
slim.settings.disabled = false
const selectDisableMock = vi.fn()
const renderDisableMock = vi.fn()
slim.select.disable = selectDisableMock
slim.render.disable = renderDisableMock
slim.disable()
expect(slim.settings.disabled).toBe(true)
expect(selectDisableMock).toHaveBeenCalled()
expect(renderDisableMock).toHaveBeenCalled()
})
test('multiple - do not render deselect all with no selected options', () => {
document.body.innerHTML = `<select id="test" multiple>
<option data-placeholder="true"></option>
<option id="1" value="1">One</option>
<option id="2" value="2">Two</option>
<option id="3" value="3">Two</option>
</select>`
const options = [
{
id: '1',
value: '1',
text: 'One'
},
{
id: '2',
value: '2',
text: 'Two'
},
{
id: '3',
value: '3',
text: 'Three'
}
]
const config = {
select: '#test',
settings: {
allowDeselect: true
},
data: options
}
let slimSelect = new SlimSelect(config)
expect(slimSelect.store.getSelectType()).toEqual('multiple')
expect(slimSelect.getSelected()).toHaveLength(0)
expect(slimSelect.render.main.deselect.main.classList).toContain(slimSelect.render.classes.hide)
})
test('multiple - render deselect all option with selected options', () => {
document.body.innerHTML = `<select id="test" multiple>
<option data-placeholder="true"></option>
<option id="1" value="1">One</option>
<option id="2" value="2">Two</option>
<option id="3" value="3">Two</option>
</select>`
const options = [
{
id: '1',
value: '1',
text: 'One',
selected: true
},
{
id: '2',
value: '2',
text: 'Two',
selected: true
},
{
id: '3',
value: '3',
text: 'Three'
}
]
const config = {
select: '#test',
settings: {
allowDeselect: true
},
data: options
}
let slimSelect = new SlimSelect(config)
expect(slimSelect.store.getSelectType()).toEqual('multiple')
expect(slimSelect.getSelected()).toHaveLength(2)
expect(slimSelect.render.main.deselect.main.classList).not.toContain(slimSelect.render.classes.hide)
})
describe('required attribute support', () => {
test('select with required attribute remains focusable for validation', () => {
document.body.innerHTML = '<select id="test" required></select>'
const slim = new SlimSelect({
select: '#test'
})
const selectEl = document.getElementById('test') as HTMLSelectElement
// Select should be hidden but still focusable (1px for validation popup)
expect(selectEl.style.position).toBe('absolute')
expect(selectEl.style.width).toBe('1px')
expect(selectEl.style.height).toBe('1px')
expect(selectEl.style.opacity).toBe('0')
expect(selectEl.style.margin).toBe('0px')
expect(selectEl.style.padding).toBe('0px')
// clip property is deprecated and may not be accessible in all test environments
// The code sets it, but some browsers/test environments may ignore or clear it
// Check if clip is set OR if the other hiding properties are sufficient
const clipValue = selectEl.style.clip
if (clipValue) {
expect(clipValue).toContain('rect')
}
// If clip is empty, the other hiding properties (position, width, height, opacity) are sufficient
// Required attribute should still be present
expect(selectEl.hasAttribute('required')).toBe(true)
// Select should be able to receive focus programmatically
selectEl.focus()
expect(document.activeElement).toBe(selectEl)
})
test('form validation works with required select', () => {
// Create a form with required select
document.body.innerHTML = `
<form id="test-form">
<select id="test" name="test-select" required>
<option value="">Select an option</option>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>
<button type="submit">Submit</button>
</form>
`
const slim = new SlimSelect({
select: '#test'
})
const form = document.getElementById('test-form') as HTMLFormElement
const selectEl = document.getElementById('test') as HTMLSelectElement
// Form should be invalid when nothing is selected
expect(selectEl.required).toBe(true)
expect(selectEl.value).toBe('')
expect(form.checkValidity()).toBe(false)
// Select an option
slim.setSelected(['1'])
// Form should now be valid
expect(selectEl.value).toBe('1')
expect(form.checkValidity()).toBe(true)
})
})
describe('keepOrder setting', () => {
test('keepOrder: false returns values in DOM order (default)', () => {
document.body.innerHTML = `
<select id="test" multiple>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="cherry">Cherry</option>
</select>
`
const slim = new SlimSelect({
select: '#test',
settings: {
keepOrder: false // DOM order (default)
}
})
// Select in reverse order: Cherry -> Apple -> Banana
slim.setSelected(['cherry', 'apple', 'banana'])
// Should return in DOM order (how they appear in HTML)
expect(slim.getSelected()).toEqual(['apple', 'banana', 'cherry'])
})
test('keepOrder: true returns values in selection order', () => {
document.body.innerHTML = `
<select id="test" multiple>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="cherry">Cherry</option>
</select>
`
const slim = new SlimSelect({
select: '#test',
settings: {
keepOrder: true // Selection order
}
})
// Select in specific order: Cherry -> Apple -> Banana
slim.setSelected(['cherry', 'apple', 'banana'])
// Should return in the order they were selected (click order)
expect(slim.getSelected()).toEqual(['cherry', 'apple', 'banana'])
})
test('keepOrder: true preserves click order in getSelected', () => {
document.body.innerHTML = `
<select id="test" multiple>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
<option value="4">Option 4</option>
<option value="5">Option 5</option>
</select>
`
const slim = new SlimSelect({
select: '#test',
settings: {
keepOrder: true
}
})
// Simulate user clicking options in random order
slim.setSelected(['5', '2', '4', '1'])
// Should maintain that exact order
expect(slim.getSelected()).toEqual(['5', '2', '4', '1'])
})
})
describe('maxValuesShown with deselection', () => {
test('can still deselect options via dropdown when maxValuesShown is exceeded', () => {
document.body.innerHTML = `
<select id="test" multiple>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
<option value="4">Option 4</option>
<option value="5">Option 5</option>
</select>
`
const slim = new SlimSelect({
select: '#test',
settings: {
maxValuesShown: 2 // Show "X selected" when more than 2
}
})
// Select 3 options (exceeds maxValuesShown)
slim.setSelected(['1', '2', '3'])
expect(slim.getSelected()).toEqual(['1', '2', '3'])
// Verify counter is shown (not individual values)
const counter = document.querySelector('.ss-max')
expect(counter?.textContent).toBe('3 selected')
// Open dropdown
slim.open()
// Find and click on a selected option to deselect it
const options = document.querySelectorAll('[role="option"]')
const selectedOption = Array.from(options).find(
(opt) => opt.getAttribute('aria-selected') === 'true' && opt.textContent?.includes('Option 1')
) as HTMLElement
expect(selectedOption).toBeTruthy()
// Click the selected option - should deselect it
selectedOption.dispatchEvent(new MouseEvent('click', { bubbles: true }))
// Should now only have 2 selected
expect(slim.getSelected()).toEqual(['2', '3'])
})
test('deselecting via dropdown updates maxValuesShown display', () => {
document.body.innerHTML = `
<select id="test" multiple>
<option value="1">Value 1</option>
<option value="2">Value 2</option>
<option value="3">Value 3</option>
</select>
`
const slim = new SlimSelect({
select: '#test',
settings: {
maxValuesShown: 2
}
})
// Select 3 options (shows "3 selected")
slim.setSelected(['1', '2', '3'])
// Open and deselect one
slim.open()
const options = document.querySelectorAll('[role="option"]')
const firstSelected = Array.from(options).find(
(opt) => opt.getAttribute('aria-selected') === 'true'
) as HTMLElement
firstSelected.dispatchEvent(new MouseEvent('click', { bubbles: true }))
// Now only 2 selected - should show individual values again (not counter)
const individualValues = document.querySelectorAll('.ss-value')
expect(individualValues.length).toBeGreaterThan(0)
const counter = document.querySelector('.ss-max')
expect(counter).toBeNull()
})
})
describe('Search State Regression Tests', () => {
let slim: SlimSelect
let searchMock: (searchValue: string, currentData: any[]) => any[]
beforeEach(() => {
document.body.innerHTML = '<select id="searchTest"></select>'
searchMock = vi.fn().mockImplementation((searchValue: string, currentData: any[]) => {
// Mock search results based on search value
if (searchValue.length >= 2) {
return [
{ value: 'null', text: 'Null' },
{ value: 'eins', text: 'Eins' },
{ value: 'zwei', text: 'Zwei' },
{ value: 'drei', text: 'Drei' },
{ value: 'vier', text: 'Vier' },
{ value: 'funf', text: 'Fünf' },
{ value: 'sechs', text: 'Sechs' },
{ value: 'sieben', text: 'Sieben' },
{ value: 'acht', text: 'Acht' },
{ value: 'neun', text: 'Neun' },
{ value: 'zehn', text: 'Zehn' }
]
}
return []
}) as typeof searchMock
slim = new SlimSelect({
select: '#searchTest',
data: [
{ value: 'a', text: 'A' },
{ value: 'b', text: 'B' }
],
events: {
search: searchMock
}
})
})
afterEach(() => {
// Clean up to avoid timeout errors
if (slim) {
slim.destroy()
}
})
test('should maintain search results when reopening dropdown', () => {
// 1. Open dropdown - should show static options [A, B]
slim.open()
let options = document.querySelectorAll('.ss-option')
expect(options).toHaveLength(2)
expect(options[0].textContent).toBe('A')
expect(options[1].textContent).toBe('B')
slim.close()
// 2. Search - should update store data with search results
slim.search('te')
options = document.querySelectorAll('.ss-option')
// Should have search results (11) - static options are replaced
expect(options.length).toBeGreaterThanOrEqual(11)
// 3. Close and reopen - search results persist in store
slim.close()
slim.open()
options = document.querySelectorAll('.ss-option')
// Store data should still contain search results
expect(options.length).toBeGreaterThanOrEqual(11)
})
test('should clear search input when closing dropdown', async () => {
// Open dropdown first
slim.open()
// Search for something
slim.search('te')
expect(slim.render.content.search.input.value).toBe('te')
// Close dropdown - this clears the search input
slim.close()
// Wait for close to complete (timeoutDelay is 200ms by default)
await new Promise((resolve) => setTimeout(resolve, 300))
// Search input should be cleared
expect(slim.render.content.search.input.value).toBe('')
})
test('should keep search input when keepSearch is true', async () => {
// Create new instance with keepSearch enabled
const select = document.createElement('select')
select.innerHTML = `
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
`
document.body.appendChild(select)
const slimWithKeepSearch = new SlimSelect({
select: select,
settings: {
keepSearch: true
}
})
// Open dropdown
slimWithKeepSearch.open()
// Search for something
slimWithKeepSearch.search('test')
expect(slimWithKeepSearch.render.content.search.input.value).toBe('test')
// Close dropdown
slimWithKeepSearch.close()
// Search input should NOT be cleared when keepSearch is true
expect(slimWithKeepSearch.render.content.search.input.value).toBe('test')
// Cleanup
slimWithKeepSearch.destroy()
document.body.removeChild(select)
})
test('should persist search results in store across close/open cycles', () => {
// Search for something
slim.search('te')
// Search results are now in the store
let options = document.querySelectorAll('.ss-option')
expect(options.length).toBeGreaterThanOrEqual(11)
// Close and reopen - store data persists
slim.close()
slim.open()
options = document.querySelectorAll('.ss-option')
expect(options.length).toBeGreaterThanOrEqual(11) // Store data maintained
slim.close()
slim.open()
options = document.querySelectorAll('.ss-option')
expect(options.length).toBeGreaterThanOrEqual(11) // Still maintained after multiple cycles
})
test('should handle selection from search results correctly', () => {
// Search for options
slim.search('te')
// Select first search result (should be from search results)
const options = document.querySelectorAll('.ss-option')
options[0].dispatchEvent(new MouseEvent('click'))
// Check that an option is selected
const selected = slim.getSelected()
expect(selected.length).toBe(1)
// Close and reopen
slim.close()
slim.open()
// Should still have search results in store
const reopenedOptions = document.querySelectorAll('.ss-option')
expect(reopenedOptions.length).toBeGreaterThanOrEqual(11)
// Selected value should be preserved
expect(slim.getSelected()).toEqual(selected)
})
test('should handle multiple selections from search results', () => {
// Configure for multi-select
slim = new SlimSelect({
select: '#searchTest',
data: [
{ value: 'a', text: 'A' },
{ value: 'b', text: 'B' }
],
settings: {
isMultiple: true
},
events: {
search: searchMock
}
})
// Search for options
slim.search('te')
// Select first two search results (Null, Eins) using Cmd+Click
const options = document.querySelectorAll('.ss-option')
options[0].dispatchEvent(new MouseEvent('click', { metaKey: true })) // Cmd+Click to keep dropdown open
options[1].dispatchEvent(new MouseEvent('click', { metaKey: true })) // Cmd+Click to keep dropdown open
// Note: Search results are temporary and not added to the store
// So the selection won't persist in the store, but the search state should be maintained
// Close and reopen
slim.close()
slim.open()
// Should still show search results (search state maintained)
const reopenedOptions = document.querySelectorAll('.ss-option')
// Multi-select may have additional options like deselect all, so we check for at least 11
expect(reopenedOptions.length).toBeGreaterThanOrEqual(11)
// Search results are maintained but selections from search results don't persist in store
// This is expected behavior - search results are temporary
})
test('should handle new search after reopening', () => {
// Initial search
slim.search('te')
let options = document.querySelectorAll('.ss-option')
expect(options.length).toBeGreaterThanOrEqual(11)
// Close and reopen
slim.close()
slim.open()
// Should still have previous search results in store
options = document.querySelectorAll('.ss-option')
expect(options.length).toBeGreaterThanOrEqual(11)
// Perform new search - this updates the store with new results
slim.search('ei')
options = document.querySelectorAll('.ss-option')
// Should show new search results
expect(options.length).toBeGreaterThanOrEqual(11)
})
test('should handle search with no results correctly', () => {
// Search for something that returns no results
slim.search('xyz')
// Should show no results message (searchText)
const noResults = document.querySelector('.ss-search')
expect(noResults).toBeTruthy()
// Close and reopen
slim.close()
slim.open()
// Should still show no results (search state maintained)
const stillNoResults = document.querySelector('.ss-search')
expect(stillNoResults).toBeTruthy()
})
test('should handle async search results correctly', async () => {
// Mock async search
const asyncSearchMock = vi.fn().mockResolvedValue([
{ value: 'async1', text: 'Async Result 1' },
{ value: 'async2', text: 'Async Result 2' }
])
slim = new SlimSelect({
select: '#searchTest',
data: [
{ value: 'a', text: 'A' },
{ value: 'b', text: 'B' }
],
events: {
search: asyncSearchMock
}
})
// Trigger async search
slim.search('async')
// Wait for async results
await new Promise((resolve) => setTimeout(resolve, 100))
// Check results are displayed (should include the async results)
let options = document.querySelectorAll('.ss-option')
expect(options.length).toBeGreaterThanOrEqual(2)
// Find the async results in the options
const asyncOptions = Array.from(options).filter((option) => option.textContent?.includes('Async Result'))
expect(asyncOptions).toHaveLength(2)
expect(asyncOptions[0].textContent).toBe('Async Result 1')
expect(asyncOptions[1].textContent).toBe('Async Result 2')
// Close and reopen
slim.close()
slim.open()
// Should still show async search results
options = document.querySelectorAll('.ss-option')
const reopenedAsyncOptions = Array.from(options).filter((option) => option.textContent?.includes('Async Result'))
expect(reopenedAsyncOptions).toHaveLength(2)
expect(reopenedAsyncOptions[0].textContent).toBe('Async Result 1')
})
test('should update store data with search results', () => {
// Test that search updates the store
slim.search('te')
expect(searchMock).toHaveBeenCalledWith('te', expect.any(Array))
let options = document.querySelectorAll('.ss-option')
expect(options.length).toBeGreaterThanOrEqual(11)
// Search results are now in the store
const storeData = slim.getData()
expect(storeData.length).toBeGreaterThanOrEqual(11)
// Clear search - this now returns all current data in store
slim.search('')
options = document.querySelectorAll('.ss-option')
// Empty search now shows all current data in store (search results)
expect(options.length).toBeGreaterThanOrEqual(11)
})
})
describe('label click support', () => {
test('clicking label with for attribute opens SlimSelect', async () => {
document.body.innerHTML = `
<label for="label-test-select">Select a Country</label>
<select id="label-test-select">
<option value="usa">United States</option>
<option value="uk">United Kingdom</option>
<option value="canada">Canada</option>
</select>
`
const slim = new SlimSelect({
select: '#label-test-select'
})
// Initially closed
expect(slim.settings.isOpen).toBe(false)
// Click the label
const label = document.querySelector('label[for="label-test-select"]') as HTMLLabelElement
label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
// Wait for setTimeout in label handler
await new Promise((r) => setTimeout(r, 10))
// SlimSelect should be open
expect(slim.settings.isOpen).toBe(true)
slim.destroy()
})
test('clicking wrapped label opens SlimSelect', async () => {
document.body.innerHTML = `
<label>
Select a Country
<select id="wrapped-label-select">
<option value="usa">United States</option>
<option value="uk">United Kingdom</option>
</select>
</label>
`
const slim = new SlimSelect({
select: '#wrapped-label-select'
})
// Initially closed
expect(slim.settings.isOpen).toBe(false)
// Click the label
const label = document.querySelector('label') as HTMLLabelElement
label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
// Wait for setTimeout in label handler
await new Promise((r) => setTimeout(r, 10))
// SlimSelect should be open
expect(slim.settings.isOpen).toBe(true)
slim.destroy()
})
test('clicking label does not open SlimSelect when disabled', async () => {
document.body.innerHTML = `
<label for="disabled-label-select">Select a Country</label>
<select id="disabled-label-select">
<option value="usa">United States</option>
</select>
`
const slim = new SlimSelect({
select: '#disabled-label-select',
settings: {
disabled: true
}
})
// Initially closed
expect(slim.settings.isOpen).toBe(false)
// Click the label
const label = document.querySelector('label[for="disabled-label-select"]') as HTMLLabelElement
label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
// Wait for setTimeout in label handler
await new Promise((r) => setTimeout(r, 10))
// SlimSelect should remain closed when disabled
expect(slim.settings.isOpen).toBe(false)
slim.destroy()
})
test('SlimSelect automatically assigns id to select if missing for label association', () => {
document.body.innerHTML = `
<label for="auto-id-select">Select a Country</label>
<select>
<option value="usa">United States</option>
</select>
`
// Note: This won't actually work because the label's 'for' points to 'auto-id-select'
// but we're selecting the select without an id. This test verifies that SlimSelect
// assigns an id. The actual id will be generated, so the label won't match.
// Let's test the id assignment instead.
document.body.innerHTML = '<select><option value="usa">United States</option></select>'
const selectElement = document.querySelector('select') as HTMLSelectElement
const slim = new SlimSelect({
select: selectElement
})
// SlimSelect should have assigned an id to the select
expect(selectElement.id).toBeTruthy()
expect(selectElement.id).toBe(slim.settings.id)
slim.destroy()
})
test('label handlers are cleaned up on destroy', async () => {
document.body.innerHTML = `
<label for="cleanup-test-select">Select a Country</label>
<select id="cleanup-test-select">
<option value="usa">United States</option>
</select>
`
const slim = new SlimSelect({
select: '#cleanup-test-select'
})
// Verify label handler works
const label = document.querySelector('label[for="cleanup-test-select"]') as HTMLLabelElement
label.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await new Promise((r) => setTimeout(r, 10))
expect(slim.settings.isOpen).toBe(true)
// Close and destroy
slim.close()
slim.destroy()
// After destroy, label click should not open SlimSelect
// (though SlimSelect is destroyed, so we can't check isOpen)
// But we verify no errors are thrown
expect(() => {
label.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}).not.toThrow()
})
test('clicking main div closes SlimSelect when wrapped in label', async () => {
document.body.innerHTML = `
<label>
Select a Country
<select id="wrapped-label-close-test">
<option value="usa">United States</option>
<option value="uk">United Kingdom</option>
</select>
</label>
`
const slim = new SlimSelect({
select: '#wrapped-label-close-test',
settings: {
allowDeselect: true
}
})
// Initially closed
expect(slim.settings.isOpen).toBe(false)
// Click the label to open
const label = document.querySelector('label') as HTMLLabelElement
label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 300)) // after animation
expect(slim.settings.isOpen).toBe(true)
// Now click the main div to close
const mainDiv = document.querySelector(`.ss-main[data-id="${slim.settings.id}"]`) as HTMLElement
expect(mainDiv).toBeTruthy()
mainDiv.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 300)) // after animation
// SlimSelect should be closed
expect(slim.settings.isOpen).toBe(false)
slim.destroy()
})
test('deselect button works when wrapped in label', async () => {
document.body.innerHTML = `
<label>
Select a Country
<select id="wrapped-label-deselect-test">
<option value="usa">United States</option>
<option value="uk">United Kingdom</option>
</select>
</label>
`
const slim = new SlimSelect({
select: '#wrapped-label-deselect-test',
settings: {
allowDeselect: true
}
})
// Select an option
slim.setSelected('usa')
await new Promise((r) => setTimeout(r, 10))
expect(slim.getSelected()).toEqual(['usa'])
// Click the deselect button
const deselectButton = document.querySelector(
`.ss-main[data-id="${slim.settings.id}"] .ss-deselect`
) as HTMLElement
expect(deselectButton).toBeTruthy()
deselectButton.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 10))
// Should be deselected (returns array with empty string for single select)
expect(slim.getSelected()).toEqual([''])
slim.destroy()
})
test('clicking main div toggles when wrapped in label', async () => {
document.body.innerHTML = `
<label>
Select a Country
<select id="wrapped-label-toggle-test">
<option value="usa">United States</option>
<option value="uk">United Kingdom</option>
</select>
</label>
`
const slim = new SlimSelect({
select: '#wrapped-label-toggle-test'
})
// Initially closed
expect(slim.settings.isOpen).toBe(false)
// Click main div to open
const mainDiv = document.querySelector(`.ss-main[data-id="${slim.settings.id}"]`) as HTMLElement
mainDiv.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 10))
expect(slim.settings.isOpen).toBe(true)
// Click main div again to close
mainDiv.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 10))
expect(slim.settings.isOpen).toBe(false)
// Click main div again to open
mainDiv.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 10))
expect(slim.settings.isOpen).toBe(true)
slim.destroy()
})
test('clicking label on second select closes first select when both have labels', async () => {
document.body.innerHTML = `
<label>
First Select
<select id="first-select">
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>
</label>
<label>
Second Select
<select id="second-select">
<option value="a">Option A</option>
<option value="b">Option B</option>
</select>
</label>
`
const slim1 = new SlimSelect({
select: '#first-select'
})
const slim2 = new SlimSelect({
select: '#second-select'
})
// Both should be closed initially
expect(slim1.settings.isOpen).toBe(false)
expect(slim2.settings.isOpen).toBe(false)
// Click first label to open first select
const label1 = document.querySelector('label:first-of-type') as HTMLLabelElement
label1.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 300))
expect(slim1.settings.isOpen).toBe(true)
expect(slim2.settings.isOpen).toBe(false)
// Click second label - should close first and open second
const label2 = document.querySelector('label:last-of-type') as HTMLLabelElement
label2.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 300))
expect(slim1.settings.isOpen).toBe(false)
expect(slim2.settings.isOpen).toBe(true)
// Click first label again - should close second and open first
label1.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 300))
expect(slim1.settings.isOpen).toBe(true)
expect(slim2.settings.isOpen).toBe(false)
slim1.destroy()
slim2.destroy()
})
})
describe('option changes race condition scenarios', () => {
test('options should persist when added to select and value is set immediately', async () => {
// Reproduce the issue: empty select, init SlimSelect, add options, set value
document.body.innerHTML = '<select id="test-select"></select>'
const selectElement = document.getElementById('test-select') as HTMLSelectElement
const slim = new SlimSelect({
select: selectElement
})
// Add option directly to the select element (not through SlimSelect API)
const newOption = document.createElement('option')
newOption.value = '1'
newOption.textContent = 'one'
selectElement.appendChild(newOption)
// Set the value immediately
selectElement.value = '1'
// Wait for mutation observer and any async operations
await new Promise((r) => setTimeout(r, 100))
// Verify the option still exists
expect(selectElement.options.length).toBe(1)
expect(selectElement.value).toBe('1')
expect(selectElement.options[0].value).toBe('1')
expect(selectElement.options[0].textContent).toBe('one')
// Verify SlimSelect also has the option
const data = slim.getData()
expect(data.length).toBe(1)
expect((data[0] as any).value).toBe('1')
expect((data[0] as any).text).toBe('one')
slim.destroy()
})
test('rapid option and value changes should preserve final state', async () => {
// Test multiple rapid mutations to ensure queue mechanism works correctly
document.body.innerHTML = '<select id="test-select"></select>'
const selectElement = document.getElementById('test-select') as HTMLSelectElement
const slim = new SlimSelect({
select: selectElement
})
// Add first option
const option1 = document.createElement('option')
option1.value = '1'
option1.textContent = 'one'
selectElement.appendChild(option1)
selectElement.value = '1'
// Immediately add second option
const option2 = document.createElement('option')
option2.value = '2'
option2.textContent = 'two'
selectElement.appendChild(option2)
selectElement.value = '2'
// Immediately add third option
const option3 = document.createElement('option')
option3.value = '3'
option3.textContent = 'three'
selectElement.appendChild(option3)
selectElement.value = '3'
// Wait for all mutations to process
await new Promise((r) => setTimeout(r, 150))
// Verify all options exist and final value is correct
expect(selectElement.options.length).toBe(3)
expect(selectElement.value).toBe('3')
expect(selectElement.options[0].value).toBe('1')
expect(selectElement.options[0].textContent).toBe('one')
expect(selectElement.options[1].value).toBe('2')
expect(selectElement.options[1].textContent).toBe('two')
expect(selectElement.options[2].value).toBe('3')
expect(selectElement.options[2].textContent).toBe('three')
// Verify SlimSelect also has all options
const data = slim.getData()
expect(data.length).toBe(3)
expect((data[0] as any).value).toBe('1')
expect((data[0] as any).text).toBe('one')
expect((data[1] as any).value).toBe('2')
expect((data[1] as any).text).toBe('two')
expect((data[2] as any).value).toBe('3')
expect((data[2] as any).text).toBe('three')
// Verify selected value
const selected = slim.getSelected()
expect(selected).toEqual(['3'])
slim.destroy()
})
test('should handle removing all options and adding new ones', async () => {
// Test scenario: start with options, remove all, then add new ones
// This tests that the queue mechanism handles the removal and addition correctly
document.body.innerHTML = `
<select id="test-select">
<option value="old1">Old One</option>
<option value="old2">Old Two</option>
<option value="old3">Old Three</option>
</select>
`
const selectElement = document.getElementById('test-select') as HTMLSelectElement
const slim = new SlimSelect({
select: selectElement
})
// Verify initial state
expect(selectElement.options.length).toBe(3)
expect(slim.getData().length).toBe(3)
// Remove all options (this will trigger mutation observer with empty data, which we skip)
selectElement.innerHTML = ''
// Wait for mutation observer to process the removal
await new Promise((r) => setTimeout(r, 100))
// Verify options are removed from DOM
expect(selectElement.options.length).toBe(0)
// Add completely new set of options one by one
const newOption1 = document.createElement('option')
newOption1.value = 'new1'
newOption1.textContent = 'New One'
selectElement.appendChild(newOption1)
const newOption2 = document.createElement('option')
newOption2.value = 'new2'
newOption2.textContent = 'New Two'
selectElement.appendChild(newOption2)
const newOption3 = document.createElement('option')
newOption3.value = 'new3'
newOption3.textContent = 'New Three'
selectElement.appendChild(newOption3)
// Wait for all mutations to process
await new Promise((r) => setTimeout(r, 200))
// The key test: verify new options persist in DOM (not deleted by race condition)
expect(selectElement.options.length).toBe(3)
expect(selectElement.options[0].value).toBe('new1')
expect(selectElement.options[0].textContent).toBe('New One')
expect(selectElement.options[1].value).toBe('new2')
expect(selectElement.options[1].textContent).toBe('New Two')
expect(selectElement.options[2].value).toBe('new3')
expect(selectElement.options[2].textContent).toBe('New Three')
slim.destroy()
})
test('should handle rapidly removing all options', async () => {
// Test scenario: start with options, rapidly remove them all
document.body.innerHTML = `
<select id="test-select">
<option value="opt1">Option 1</option>
<option value="opt2">Option 2</option>
<option value="opt3">Option 3</option>
<option value="opt4">Option 4</option>
</select>
`
const selectElement = document.getElementById('test-select') as HTMLSelectElement
const slim = new SlimSelect({
select: selectElement
})
// Set an initial value
selectElement.value = 'opt2'
// Verify initial state
expect(selectElement.options.length).toBe(4)
expect(selectElement.value).toBe('opt2')
expect(slim.getData().length).toBe(4)
// Rapidly remove all options
selectElement.removeChild(selectElement.options[0])
selectElement.removeChild(selectElement.options[0])
selectElement.removeChild(selectElement.options[0])
selectElement.removeChild(selectElement.options[0])
// Wait for all mutations to process
await new Promise((r) => setTimeout(r, 200))
// Verify all options are removed
expect(selectElement.options.length).toBe(0)
expect(selectElement.value).toBe('')
// Verify SlimSelect also has no options
const data = slim.getData()
expect(data.length).toBe(0)
// Verify selected value is empty
const selected = slim.getSelected()
expect(selected).toEqual([])
slim.destroy()
})
test('should handle option change after first render', async () => {
// Test scenario: start with 2 options
document.body.innerHTML = `
<select id="test-select">
<option value="opt1">Option 1</option>
<option value="opt2">Option 2</option>
</select>
`
const selectElement = document.getElementById('test-select') as HTMLSelectElement
const slim = new SlimSelect({
select: selectElement
})
// Set an initial value
selectElement.value = 'opt2'
// Verify initial state
expect(selectElement.options.length).toBe(2)
expect(selectElement.value).toBe('opt2')
expect(slim.getData().length).toBe(2)
// Wait for all mutations to process
await new Promise((r) => setTimeout(r, 200))
// Rebuild options
document.getElementById('test-select')!.innerHTML = `
<option value="opt1">Option 1 updated</option>
<option value="opt2" selected>Option 2 updated</option>
`
// Wait for all mutations to process
await new Promise((r) => setTimeout(r, 200))
// Verify options are still there
expect(selectElement.options.length).toBe(2)
expect(selectElement.options[0].textContent).toBe('Option 1 updated')
expect(selectElement.options[1].textContent).toBe('Option 2 updated')
expect(selectElement.value).toBe('opt2')
// Verify SlimSelect also has the options
const data = slim.getData()
expect(data.length).toBe(2)
// Verify selected value is empty
const selected = slim.getSelected()
expect(selected).toEqual(['opt2'])
slim.destroy()
})
})
describe('cssClasses with space-separated strings', () => {
test('space-separated cssClasses are applied as individual classes', () => {
document.body.innerHTML = '<select id="test"><option>Test</option></select>'
const slim = new SlimSelect({
select: '#test',
cssClasses: {
main: 'class1 class2'
}
})
expect(slim.render.main.main.classList.contains('ss-main')).toBe(true)
expect(slim.render.main.main.classList.contains('class1')).toBe(true)
expect(slim.render.main.main.classList.contains('class2')).toBe(true)
slim.destroy()
})
})
})