UNPKG

slim-select

Version:

Slim advanced select dropdown

684 lines (536 loc) 23.7 kB
/** * @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() }) }) })