slim-select
Version:
Slim advanced select dropdown
684 lines (536 loc) • 23.7 kB
text/typescript
/**
* @jest-environment jsdom
*/
'use strict'
import { describe, expect, vi, test, beforeEach } from 'vitest'
import Select from './select'
import Store, { Optgroup, Option } from './store'
describe('select module', () => {
let select: Select
beforeEach(() => {
document.body.innerHTML = `<select id="test" multiple>
<optgroup label="test1">
<option id="111" value="1">One</option>
<option id="222" value="2">Two</option>
</optgroup>
<optgroup label="test2">
<option id="333" value="3">Three</option>
<option id="444" value="4">Four</option>
<option id="555" value="5">Five</option>
</optgroup>
<option id="666" value="6">Six</option>
</select>`
const selectElement = document.getElementById('test') as HTMLSelectElement
select = new Select(selectElement)
})
describe('constructor', () => {
test('constructor works', () => {
document.body.innerHTML = '<select id="test"></select>'
const selectElement = document.getElementById('test') as HTMLSelectElement
const select = new Select(selectElement)
expect(select).toBeInstanceOf(Select)
expect(select.select).toBeInstanceOf(HTMLSelectElement)
})
})
describe('enable', () => {
test('enable enables select element', () => {
select.select.disabled = true
select.enable()
expect(select.select.disabled).toBe(false)
})
})
describe('disable', () => {
test('disable disables select element', () => {
select.disable()
expect(select.select.disabled).toBe(true)
})
})
describe('hideUI', () => {
test('correct HTML attributes get set', () => {
select.hideUI()
expect(select.select.tabIndex).toBe(-1)
// Visually hidden but still focusable for form validation
expect(select.select.style.position).toBe('absolute')
expect(select.select.style.width).toBe('1px')
expect(select.select.style.height).toBe('1px')
expect(select.select.style.opacity).toBe('0')
expect(select.select.style.pointerEvents).toBe('none')
expect(select.select.style.margin).toBe('0px')
expect(select.select.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 = select.select.style.clip
if (clipValue) {
expect(clipValue).toContain('rect')
}
// If clip is empty, the other hiding properties (position, width, height, opacity) are sufficient
expect(select.select.getAttribute('aria-hidden')).toBe('true')
})
})
describe('showUI', () => {
test('HTML attributes get reset', () => {
select.select.tabIndex = -1
select.select.style.position = 'absolute'
select.select.style.width = '0'
select.select.style.opacity = '0'
select.select.style.margin = '0'
select.select.style.padding = '0'
select.select.style.borderWidth = '0'
select.select.setAttribute('aria-hidden', 'true')
select.showUI()
expect(select.select.tabIndex).toBeFalsy()
expect(select.select.style.position).toBeFalsy()
expect(select.select.style.width).toBeFalsy()
expect(select.select.style.opacity).toBeFalsy()
expect(select.select.style.margin).toBeFalsy()
expect(select.select.style.padding).toBeFalsy()
// borderWidth gets reset
expect(select.select.getAttribute('aria-hidden')).toBeNull()
})
})
describe('getData', () => {
test('get data from select options', () => {
document.body.innerHTML = `<select id="test">
<option id="value1" value="1">One</option>
<option value="2">Two</option>
</select>`
const selectElement = document.getElementById('test') as HTMLSelectElement
const select = new Select(selectElement)
const data = select.getData() as Option[]
expect(data).toHaveLength(2)
expect(data[0].id).toBe('value1')
expect(data[0].value).toBe('1')
expect(data[0].text).toBe('One')
expect(data[1].value).toBe('2')
expect(data[1].text).toBe('Two')
})
test('get data from select optgroups', () => {
document.body.innerHTML = `<select id="test">
<optgroup label="test1">
<option id="111" value="1">One</option>
<option id="222" value="2">Two</option>
</optgroup>
<optgroup label="test2">
<option id="333" value="3">Three</option>
<option id="444" value="4">Four</option>
<option id="555" value="5">Five</option>
</optgroup>
</select>`
const selectElement = document.getElementById('test') as HTMLSelectElement
const select = new Select(selectElement)
const data = select.getData() as Optgroup[]
expect(data).toHaveLength(2)
expect(data[0].label).toBe('test1')
expect(data[0].options).toHaveLength(2)
expect(data[0].options[0].id).toBe('111')
expect(data[0].options[0].value).toBe('1')
expect(data[0].options[0].text).toBe('One')
expect(data[0].options[1].id).toBe('222')
expect(data[0].options[1].value).toBe('2')
expect(data[0].options[1].text).toBe('Two')
expect(data[1].label).toBe('test2')
expect(data[1].options).toHaveLength(3)
expect(data[1].options[0].id).toBe('333')
expect(data[1].options[0].value).toBe('3')
expect(data[1].options[0].text).toBe('Three')
expect(data[1].options[1].id).toBe('444')
expect(data[1].options[1].value).toBe('4')
expect(data[1].options[1].text).toBe('Four')
expect(data[1].options[2].id).toBe('555')
expect(data[1].options[2].value).toBe('5')
expect(data[1].options[2].text).toBe('Five')
})
})
describe('setSelected', () => {
test('single option gets selected correctly', () => {
// get id of the first option in the first optgroup
const id = select.select.querySelector<HTMLOptionElement>('optgroup option')?.id
expect(id).toBe('111')
select.setSelected([id as string])
expect(select.select.querySelector<HTMLOptionElement>('option[value="1"]')?.selected).toBe(true)
})
test('mix of options get selected correctly', () => {
select.setSelected(['111', '222', '333'])
expect(select.select.querySelector<HTMLOptionElement>('option[value="1"]')?.selected).toBe(true)
expect(select.select.querySelector<HTMLOptionElement>('option[value="2"]')?.selected).toBe(true)
expect(select.select.querySelector<HTMLOptionElement>('option[value="3"]')?.selected).toBe(true)
})
})
describe('setSelectedByValue', () => {
test('single value get selected correctly', () => {
select.setSelectedByValue(['6'])
expect(select.select.querySelector<HTMLOptionElement>('option[value="6"]')?.selected).toBe(true)
})
test('opt group value gets selected correctly', () => {
select.setSelectedByValue(['4'])
expect(select.select.querySelector<HTMLOptionElement>('option[value="4"]')?.selected).toBe(true)
})
test('mix of options get selected correctly', () => {
select.setSelectedByValue(['2', '3', '6'])
expect(select.select.querySelector<HTMLOptionElement>('option[value="2"]')?.selected).toBe(true)
expect(select.select.querySelector<HTMLOptionElement>('option[value="3"]')?.selected).toBe(true)
expect(select.select.querySelector<HTMLOptionElement>('option[value="6"]')?.selected).toBe(true)
})
})
describe('updateSelected', () => {
test('id gets updated correctly', () => {
select.select.dataset.id = 'old_id'
select.updateSelect('new_id')
expect(select.select.dataset.id).toBe('new_id')
})
test('new styles are set correctly', () => {
select.updateSelect(undefined, 'color: red')
expect(select.select.style.color).toBe('red')
})
test("setting styles doesn't override id", () => {
select.select.dataset.id = 'set_id'
select.updateSelect(undefined, 'color: red')
expect(select.select.dataset.id).toBe('set_id')
})
test('classes are set correctly', () => {
select.updateSelect(undefined, undefined, ['class0', 'class1'])
expect(select.select.classList.contains('class0')).toBe(true)
expect(select.select.classList.contains('class1')).toBe(true)
})
})
test('update select from data', () => {
document.body.innerHTML = '<select id="test"></select>'
let selectElement = document.getElementById('test') as HTMLSelectElement
let select = new Select(selectElement)
let store = new Store('single', [
{
id: '1',
value: '1',
text: 'One',
selected: false
},
{
id: '2',
value: '2',
text: 'Two',
selected: false
}
])
let data = store.getData()
select.updateOptions(data)
expect(selectElement.outerHTML).toBe(
'<select id="test"><option id="1" value="1">One</option><option id="2" value="2">Two</option></select>'
)
})
describe('onValueChange', () => {
let select: Select
let selectElement: HTMLSelectElement
beforeEach(() => {
document.body.innerHTML = `<select id="test">
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</select>`
selectElement = document.getElementById('test') as HTMLSelectElement
select = new Select(selectElement)
})
test('listener is triggered when the select value changes', async () => {
// make sure the first element is selected as standard
let data = select.getData() as Option[]
expect(data[0].selected).toBe(true)
const onValueMock = vi.fn()
select.onValueChange = onValueMock
// Change the value
selectElement.value = '2'
selectElement.dispatchEvent(new Event('change'))
await new Promise((r) => setTimeout(r, 50))
expect(onValueMock).toHaveBeenCalled()
// Get selected data
const selected = select.getSelectedValues()
expect(selected[0]).toBe('2')
// Check all data
data = select.getData() as Option[]
expect(data[0].selected).toBe(false)
expect(data[1].selected).toBe(true)
})
test('listener is triggered when inner HTML is replaced with new options', async () => {
const onValueMock = vi.fn()
select.onValueChange = onValueMock
selectElement.innerHTML = `<option value="4">Four</option>
<option value="5" selected>Five</option>
<option value="6">Six</option>`
await new Promise((r) => setTimeout(r, 50))
expect(onValueMock).toHaveBeenCalled()
// Give the mutation observer time to run
const data = select.getData() as Option[]
expect(data[1].value).toBe('5')
expect(data[1].selected).toBe(true)
})
})
describe('onOptionsChange', () => {
test('listener triggers when select options change without changing the select value', async () => {
document.body.innerHTML = `<select id="test">
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</select>`
const selectElement = document.getElementById('test') as HTMLSelectElement
const select = new Select(selectElement)
let data = select.getData() as Option[]
expect(data).toHaveLength(3)
const onOptionsMock = vi.fn()
select.onOptionsChange = onOptionsMock
selectElement.innerHTML = '<option value="1">One</option><option value="2">Two</option>'
await new Promise((r) => setTimeout(r, 50))
expect(onOptionsMock).toHaveBeenCalled()
data = select.getData() as Option[]
expect(data).toHaveLength(2)
})
test('listener triggers when optgroup options change', async () => {
document.body.innerHTML = `<select id="test">
<optgroup id="test_optgroup" label="test1">
<option value="1">One</option>
<option value="2">Two</option>
</optgroup>
<optgroup label="test2">
<option value="3">Three</option>
<option value="4">Four</option>
<option value="5">Five</option>
</optgroup>
</select>`
const selectElement = document.getElementById('test') as HTMLSelectElement
const select = new Select(selectElement)
const optGroups = select.getData() as Optgroup[]
expect(optGroups).toHaveLength(2)
const onOptionsMock = vi.fn()
select.onOptionsChange = onOptionsMock
const selectOptgroup = document.getElementById('test_optgroup') as HTMLOptGroupElement
selectOptgroup.innerHTML = '<option value="8">Eight</option><option value="9">Nine</option>'
await new Promise((r) => setTimeout(r, 50))
expect(onOptionsMock).toHaveBeenCalled()
const options = select.getData() as Option[]
expect(options).toHaveLength(2)
// get selected data
const selected = select.getSelectedValues()
expect(selected).toHaveLength(1)
})
test('listener triggers when select option text changes', async () => {
document.body.innerHTML = `<select id="test">
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</select>`
const selectElement = document.getElementById('test') as HTMLSelectElement
const select = new Select(selectElement)
let data = select.getData() as Option[]
expect(data[0].text).toBe('One')
const onOptionsMock = vi.fn()
select.onOptionsChange = onOptionsMock
let option = selectElement.options[0]
option.text = 'New One'
await new Promise((r) => setTimeout(r, 50))
expect(onOptionsMock).toHaveBeenCalled()
data = select.getData() as Option[]
expect(data[0].text).toBe('New One')
})
test('listener triggers when select optgroup option text changes', async () => {
document.body.innerHTML = `<select id="test">
<optgroup id="test_optgroup" label="test1">
<option id=test_option value="1">One</option>
<option value="2">Two</option>
</optgroup>
<optgroup label="test2">
<option value="3">Three</option>
<option value="4">Four</option>
<option value="5">Five</option>
</optgroup>
</select>`
const selectElement = document.getElementById('test') as HTMLSelectElement
const select = new Select(selectElement)
let dataOptgroup = select.getData() as Optgroup[]
expect(dataOptgroup[0].options[0].text).toBe('One')
const onOptionsMock = vi.fn()
select.onOptionsChange = onOptionsMock
let selectOption = document.getElementById('test_option') as HTMLOptionElement
selectOption.text = 'New One'
await new Promise((r) => setTimeout(r, 50))
expect(onOptionsMock).toHaveBeenCalled()
let dataOption = select.getData() as Optgroup[]
expect(dataOption[0].options[0].text).toBe('New One')
})
})
describe('label handling', () => {
test('setupLabelHandlers finds label with for attribute and adds click handler', async () => {
document.body.innerHTML = `
<label for="test-select">Select Label</label>
<select id="test-select">
<option value="1">One</option>
<option value="2">Two</option>
</select>
`
const selectElement = document.getElementById('test-select') as HTMLSelectElement
const select = new Select(selectElement)
select.hideUI()
const onLabelClickMock = vi.fn()
select.onLabelClick = onLabelClickMock
select.setupLabelHandlers()
const label = document.querySelector('label[for="test-select"]') as HTMLLabelElement
expect(label).toBeTruthy()
// Click the label
label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
// Wait for setTimeout in the label handler
await new Promise((r) => setTimeout(r, 10))
// Should have prevented default and called the callback
expect(onLabelClickMock).toHaveBeenCalled()
})
test('setupLabelHandlers finds wrapped label and adds click handler', async () => {
document.body.innerHTML = `
<label>
Select Label
<select id="test-select">
<option value="1">One</option>
<option value="2">Two</option>
</select>
</label>
`
const selectElement = document.getElementById('test-select') as HTMLSelectElement
const select = new Select(selectElement)
select.hideUI()
const onLabelClickMock = vi.fn()
select.onLabelClick = onLabelClickMock
select.setupLabelHandlers()
const label = selectElement.parentElement as HTMLLabelElement
expect(label.tagName).toBe('LABEL')
// Click the label
label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
// Wait for setTimeout in the label handler
await new Promise((r) => setTimeout(r, 10))
// Should have called the callback
expect(onLabelClickMock).toHaveBeenCalled()
})
test('setupLabelHandlers handles multiple labels', async () => {
document.body.innerHTML = `
<label for="test-select">Label 1</label>
<label for="test-select">Label 2</label>
<select id="test-select">
<option value="1">One</option>
</select>
`
const selectElement = document.getElementById('test-select') as HTMLSelectElement
const select = new Select(selectElement)
select.hideUI()
const onLabelClickMock = vi.fn()
select.onLabelClick = onLabelClickMock
select.setupLabelHandlers()
const labels = document.querySelectorAll('label[for="test-select"]')
expect(labels).toHaveLength(2)
// Click first label
labels[0].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 10))
expect(onLabelClickMock).toHaveBeenCalledTimes(1)
// Click second label
labels[1].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 10))
expect(onLabelClickMock).toHaveBeenCalledTimes(2)
})
test('removeLabelHandlers removes click handlers from labels', () => {
document.body.innerHTML = `
<label for="test-select">Select Label</label>
<select id="test-select">
<option value="1">One</option>
</select>
`
const selectElement = document.getElementById('test-select') as HTMLSelectElement
const select = new Select(selectElement)
select.hideUI()
const onLabelClickMock = vi.fn()
select.onLabelClick = onLabelClickMock
select.setupLabelHandlers()
select.removeLabelHandlers()
const label = document.querySelector('label[for="test-select"]') as HTMLLabelElement
label.dispatchEvent(new MouseEvent('click', { bubbles: true }))
// Should not have been called after removal
expect(onLabelClickMock).not.toHaveBeenCalled()
})
test('hideUI prevents native select from opening on click', () => {
document.body.innerHTML = '<select id="test"><option value="1">One</option></select>'
const selectElement = document.getElementById('test') as HTMLSelectElement
const select = new Select(selectElement)
select.hideUI()
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
const prevented = !selectElement.dispatchEvent(clickEvent)
// The event should be prevented by our handler
expect(clickEvent.defaultPrevented).toBe(true)
})
test('hideUI prevents native select from opening on focus', () => {
document.body.innerHTML = '<select id="test"><option value="1">One</option></select>'
const selectElement = document.getElementById('test') as HTMLSelectElement
const select = new Select(selectElement)
select.hideUI()
const focusEvent = new FocusEvent('focus', { bubbles: true, cancelable: true })
selectElement.dispatchEvent(focusEvent)
// The event should be prevented by our handler
expect(focusEvent.defaultPrevented).toBe(true)
})
test('hideUI prevents native select from opening on mousedown', () => {
document.body.innerHTML = '<select id="test"><option value="1">One</option></select>'
const selectElement = document.getElementById('test') as HTMLSelectElement
const select = new Select(selectElement)
select.hideUI()
const mousedownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
selectElement.dispatchEvent(mousedownEvent)
// The event should be prevented by our handler
expect(mousedownEvent.defaultPrevented).toBe(true)
})
test('showUI removes event handlers that prevent native select', () => {
document.body.innerHTML = '<select id="test"><option value="1">One</option></select>'
const selectElement = document.getElementById('test') as HTMLSelectElement
const select = new Select(selectElement)
select.hideUI()
select.showUI()
// After showUI, events should not be prevented
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
selectElement.dispatchEvent(clickEvent)
// Note: The event may still propagate normally, but defaultPrevented should be false
// We test this by ensuring the select doesn't have our prevent handlers attached
// The actual behavior depends on browser implementation
})
test('destroy removes label handlers', async () => {
document.body.innerHTML = `
<label for="test-select">Select Label</label>
<select id="test-select">
<option value="1">One</option>
</select>
`
const selectElement = document.getElementById('test-select') as HTMLSelectElement
const select = new Select(selectElement)
select.hideUI()
const onLabelClickMock = vi.fn()
select.onLabelClick = onLabelClickMock
select.setupLabelHandlers()
// Verify handler works
const label = document.querySelector('label[for="test-select"]') as HTMLLabelElement
label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 10))
expect(onLabelClickMock).toHaveBeenCalledTimes(1)
// Destroy and verify handler removed
select.destroy()
onLabelClickMock.mockClear()
label.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await new Promise((r) => setTimeout(r, 10))
expect(onLabelClickMock).not.toHaveBeenCalled()
})
test('setupLabelHandlers does nothing if select has no id and no wrapping label', () => {
document.body.innerHTML = `
<div>
<select>
<option value="1">One</option>
</select>
</div>
`
const selectElement = document.querySelector('select') as HTMLSelectElement
const select = new Select(selectElement)
select.hideUI()
const onLabelClickMock = vi.fn()
select.onLabelClick = onLabelClickMock
// Should not throw or error
expect(() => select.setupLabelHandlers()).not.toThrow()
})
})
})