slim-select
Version:
Slim advanced select dropdown
1,540 lines (1,240 loc) • 54.9 kB
text/typescript
'use strict'
import { describe, expect, test, vi, beforeEach } from 'vitest'
import Render, { Callbacks } from './render'
import Settings from './settings'
import Store, { Option } from './store'
import CssClasses from './classes'
describe('render module', () => {
let render: Render
let openMock: ReturnType<typeof vi.fn>
let closeMock: ReturnType<typeof vi.fn>
let addSelectedMock: ReturnType<typeof vi.fn>
let setSelectedMock: ReturnType<typeof vi.fn>
let addOptionMock: ReturnType<typeof vi.fn>
let searchMock: ReturnType<typeof vi.fn>
let afterChangeMock: ReturnType<typeof vi.fn>
let beforeChangeMock: ReturnType<typeof vi.fn>
beforeEach(() => {
const store = new Store('single', [
{
text: 'test0',
value: 'test0'
},
{
text: 'test1',
value: 'test1',
html: '<span>test1</span>'
},
{
text: 'test2',
selected: true
}
])
// default settings
const settings = new Settings()
const classes = new CssClasses()
openMock = vi.fn(() => {})
closeMock = vi.fn(() => {})
addSelectedMock = vi.fn(() => {})
setSelectedMock = vi.fn(() => {})
addOptionMock = vi.fn(() => {})
searchMock = vi.fn(() => {})
afterChangeMock = vi.fn(() => {})
beforeChangeMock = vi.fn(() => true)
// default callbacks
const callbacks: Callbacks = {
open: openMock as () => void,
close: closeMock as () => void,
setSelected: setSelectedMock as (value: string | string[], runAfterChange: boolean) => void,
addOption: addOptionMock as (option: Option) => void,
search: searchMock as (search: string) => void,
afterChange: afterChangeMock as (newVal: Option[]) => void,
beforeChange: beforeChangeMock as (newVal: Option[], oldVal: Option[]) => boolean | void
}
render = new Render(settings, classes, store, callbacks)
})
describe('constructor', () => {
test('default constructor works', () => {
// create a new store with 2 options
const store = new Store('single', [
{ text: 'test1', value: 'test1' },
{ text: 'test2', value: 'test2' }
])
// default settings
const settings = new Settings()
const classes = new CssClasses()
// default callbacks
const callbacks = {
open: () => {},
close: () => {},
addSelected: () => {},
setSelected: () => {},
addOption: () => {},
search: () => {},
beforeChange: () => {
return true
}
} as Callbacks
const render = new Render(settings, classes, store, callbacks)
expect(render).toBeInstanceOf(Render)
expect(render.main.main).toBeInstanceOf(HTMLDivElement)
expect(render.content.search.input).toBeInstanceOf(HTMLInputElement)
})
})
describe('enable', () => {
test('enable removes disabled class from main and enables search input', () => {
// disable stuff directly
render.main.main.classList.add(render.classes.disabled)
render.content.search.input.disabled = true
render.enable()
expect(render.main.main.classList.contains(render.classes.disabled)).toBe(false)
expect(render.content.search.input.disabled).toBe(false)
})
})
describe('disable', () => {
test('disable adds disabled class to main and disables search input', () => {
render.disable()
expect(render.main.main.classList.contains(render.classes.disabled)).toBe(true)
expect(render.content.search.input.disabled).toBe(true)
})
})
describe('open', () => {
test('open sets the correct attributes and CSS classes', () => {
render.open()
expect(render.main.arrow.path.getAttribute('d')).toBe(render.classes.arrowOpen)
expect(render.main.main.getAttribute('aria-expanded')).toBe('true')
expect(render.content.main.classList.contains(render.classes.contentOpen)).toBe(true)
// Direction class should be set on both main and content (dirAbove or dirBelow)
const mainHasDirection =
render.main.main.classList.contains(render.classes.dirAbove) ||
render.main.main.classList.contains(render.classes.dirBelow)
const contentHasDirection =
render.content.main.classList.contains(render.classes.dirAbove) ||
render.content.main.classList.contains(render.classes.dirBelow)
expect(mainHasDirection).toBe(true)
expect(contentHasDirection).toBe(true)
})
})
describe('close', () => {
test('close sets the correct attributes and CSS classes', () => {
render.open()
render.close()
expect(render.main.arrow.path.getAttribute('d')).toBe(render.classes.arrowClose)
expect(render.main.main.getAttribute('aria-expanded')).toBe('false')
expect(render.content.main.classList.contains(render.classes.contentOpen)).toBe(false)
// Direction class should persist after close
const hasDirection =
render.content.main.classList.contains(render.classes.dirAbove) ||
render.content.main.classList.contains(render.classes.dirBelow)
expect(hasDirection).toBe(true)
})
})
describe('updateClassStyles', () => {
test('existing classes and styles are cleared', () => {
// manually set classes and styles for testing
render.main.main.className = 'test'
render.main.main.style.color = 'red'
render.content.main.className = 'test'
render.content.main.style.color = 'red'
render.updateClassStyles()
expect(render.main.main.classList.contains('test')).toBe(false)
expect(render.main.main.style.color).toBeFalsy()
expect(render.content.main.classList.contains('test')).toBe(false)
expect(render.content.main.style.color).toBeFalsy()
})
test('inline styles are applied to main elements', () => {
render.settings.style = 'color: red'
render.updateClassStyles()
expect(render.main.main.style.color).toBe('red')
expect(render.content.main.style.color).toBe('red')
})
test('classes are applied to main elements', () => {
render.settings.class = ['test0', 'test1', 'test2']
render.updateClassStyles()
expect(render.main.main.classList.contains('test0')).toBe(true)
expect(render.main.main.classList.contains('test1')).toBe(true)
expect(render.main.main.classList.contains('test2')).toBe(true)
expect(render.content.main.classList.contains('test0')).toBe(true)
expect(render.content.main.classList.contains('test1')).toBe(true)
expect(render.content.main.classList.contains('test2')).toBe(true)
})
test('if content position is relative, class is added on content', () => {
render.settings.contentPosition = 'relative'
render.updateClassStyles()
expect(render.content.main.classList.contains('ss-relative')).toBe(true)
})
})
describe('updateAriaAttributes', () => {
test('sets correct aria attributes', () => {
render.updateAriaAttributes()
expect(render.main.main.role).toBe('combobox')
expect(render.main.main.getAttribute('aria-haspopup')).toBe('listbox')
expect(render.main.main.getAttribute('aria-controls')).toBe(render.content.list.id)
expect(render.main.main.getAttribute('aria-expanded')).toBe('false')
expect(render.content.list.getAttribute('role')).toBe('listbox')
expect(render.content.list.getAttribute('aria-label')).toContain('listbox')
})
})
describe('mainDiv', () => {
test('correct HTML element gets created', () => {
const main = render.main.main
expect(main.dataset.id).toBe(render.settings.id)
expect(main.getAttribute('aria-label')).toBe(render.settings.ariaLabel)
expect(main.tabIndex).toBe(0)
expect(main.children).toHaveLength(3)
expect(main.children.item(0)?.className).toBe(render.classes.values)
expect(main.children.item(1)?.classList.contains(render.classes.deselect)).toBe(true)
expect(main.children.item(1)?.classList.contains(render.classes.hide)).toBe(true)
expect(main.children.item(1)?.children).toHaveLength(1)
expect(main.children.item(1)?.children).toHaveLength(1)
expect(main.children.item(1)?.children.item(0)).toBeInstanceOf(SVGElement)
expect(main.children.item(2)?.classList.contains(render.classes.arrow)).toBe(true)
expect(main.children.item(2)?.classList.contains(render.classes.hide)).toBe(false)
expect(main.children.item(2)?.children.item(0)).toBeInstanceOf(SVGElement)
})
test('arrow is hidden when alwaysOpen is set', () => {
render.settings.alwaysOpen = true
const main = render.mainDiv().main
expect(main.children.item(2)?.classList.contains(render.classes.arrow)).toBe(true)
expect(main.children.item(2)?.classList.contains(render.classes.hide)).toBe(true)
expect(main.children.item(2)?.children.item(0)).toBeInstanceOf(SVGElement)
})
test('arrow key events on main element move highlight', () => {
const highlightMock = vi.fn(() => {})
render.highlight = highlightMock
render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
expect(openMock).toHaveBeenCalled()
expect(highlightMock).toHaveBeenCalledTimes(1)
expect(highlightMock.mock.calls[0]).toStrictEqual(['up'])
render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
expect(openMock).toHaveBeenCalledTimes(2)
expect(highlightMock).toHaveBeenCalledTimes(2)
expect(highlightMock.mock.calls[1]).toStrictEqual(['down'])
})
test('tab and escape key event on main element triggers close callback', () => {
render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }))
expect(closeMock).toHaveBeenCalled()
render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(closeMock).toHaveBeenCalledTimes(2)
})
test('enter and space key event on main element triggers open callback', () => {
render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(openMock).toHaveBeenCalled()
render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }))
expect(openMock).toHaveBeenCalledTimes(2)
})
test('click on main event does nothing if element is disabled', () => {
render.settings.disabled = true
render.main.main.dispatchEvent(new MouseEvent('click'))
expect(openMock).not.toHaveBeenCalled()
expect(closeMock).not.toHaveBeenCalled()
})
test('click on main event triggers open if element is closed', () => {
render.main.main.dispatchEvent(new MouseEvent('click'))
expect(openMock).toHaveBeenCalled()
expect(closeMock).not.toHaveBeenCalled()
})
test('click on main event triggers close if element is opened', () => {
render.settings.isOpen = true
render.main.main.dispatchEvent(new MouseEvent('click'))
expect(openMock).not.toHaveBeenCalled()
expect(closeMock).toHaveBeenCalled()
})
test('click on deselect does nothing if element is disabled', () => {
render.settings.disabled = true
const deselectElement: HTMLDivElement = render.main.main.querySelector(
'.' + render.classes.deselect
) as HTMLDivElement
deselectElement.dispatchEvent(new MouseEvent('click'))
expect(afterChangeMock).not.toHaveBeenCalled()
})
test('click on deselect on single select runs callbacks', () => {
const deselectElement: HTMLDivElement = render.main.main.querySelector(
'.' + render.classes.deselect
) as HTMLDivElement
deselectElement.dispatchEvent(new MouseEvent('click'))
expect(setSelectedMock).toHaveBeenCalled()
expect(closeMock).toHaveBeenCalled()
expect(afterChangeMock).toHaveBeenCalled()
})
test('click on deselect on multiple select runs callbacks', () => {
render.settings.isMultiple = true
const deselectAllMock = vi.fn()
render.updateDeselectAll = deselectAllMock
const deselectElement: HTMLDivElement = render.main.main.querySelector(
'.' + render.classes.deselect
) as HTMLDivElement
deselectElement.dispatchEvent(new MouseEvent('click'))
expect(deselectAllMock).toHaveBeenCalled()
expect(setSelectedMock).toHaveBeenCalled()
expect(closeMock).toHaveBeenCalled()
expect(afterChangeMock).toHaveBeenCalled()
})
})
describe('mainFocus', () => {
let focusMock: (options?: FocusOptions | undefined) => void
beforeEach(() => {
focusMock = vi.fn(() => {}) as (options?: FocusOptions | undefined) => void
render.main.main.focus = focusMock
})
test('mainFocus does nothing if the event is click', () => {
render.mainFocus('click')
expect(focusMock).not.toHaveBeenCalled()
})
test('mainFocus triggers focus if the event is not click', () => {
render.mainFocus('keydown')
expect(focusMock).toHaveBeenCalled()
render.mainFocus('keyup')
expect(focusMock).toHaveBeenCalledTimes(2)
render.mainFocus('mouse')
expect(focusMock).toHaveBeenCalledTimes(3)
})
})
describe('placeholder', () => {
test('placeholder uses fallback text if no option is found', () => {
render.settings.placeholderText = 'placeholder text'
const placeholder = render.placeholder()
expect(placeholder.innerHTML).toBe(render.settings.placeholderText)
})
test('placeholder uses option html if option is found', () => {
render.store.setData([
{
text: 'opt text',
html: '<h1>Option HTML</h1>',
placeholder: true
}
])
const placeholder = render.placeholder()
expect(placeholder.innerHTML).toBe('<h1>Option HTML</h1>')
})
test('placeholder uses option text if option is found and no HTML is set', () => {
render.store.setData([
{
text: 'opt text',
placeholder: true
}
])
const placeholder = render.placeholder()
expect(placeholder.innerHTML).toBe('opt text')
})
})
describe('renderValues', () => {
test('single select renders only one value', () => {
render.renderValues()
expect(render.main.values.children).toHaveLength(1)
})
test('single select renders HTML option', () => {
render.store.setData([
{
text: 'opt0'
},
{
text: 'opt1',
html: '<span>opt1</span>',
selected: true
}
])
render.renderValues()
expect(render.main.values.children).toHaveLength(1)
expect(render.main.values.children.item(0)?.innerHTML).toBe('<span>opt1</span>')
})
test('multiple select renders all selected values', () => {
render.settings.isMultiple = true
render.store = new Store('multiple', [
{
text: 'opt0',
value: 'opt0',
selected: true
},
{
text: 'opt1',
value: 'opt1',
html: '<span>opt1</span>',
selected: true
},
{
text: 'opt2'
}
])
render.renderValues()
expect(render.main.values.children).toHaveLength(2)
expect(render.main.values.children.item(0)).toBeInstanceOf(HTMLDivElement)
expect((render.main.values.children.item(0) as HTMLDivElement).textContent).toBe('opt0')
expect(render.main.values.children.item(1)).toBeInstanceOf(HTMLDivElement)
expect((render.main.values.children.item(1) as HTMLDivElement).textContent).toBe('opt1')
})
test('multiple select renders counter element when maxValuesShown is set', () => {
render.settings.isMultiple = true
render.settings.maxValuesShown = 2
render.store = new Store('multiple', [
{
text: 'opt0',
value: 'opt0',
selected: true
},
{
text: 'opt1',
value: 'opt1',
html: '<span>opt1</span>',
selected: true
},
{
text: 'opt2',
value: 'opt2',
selected: true
},
{
text: 'opt4'
}
])
render.renderValues()
expect(render.main.values.children).toHaveLength(1)
expect(render.main.values.children.item(0)?.innerHTML).toBe('3 selected')
})
test('remove old options from values', () => {
render.renderValues()
expect(render.main.values.children).toHaveLength(1)
expect(render.main.values.innerText).toBeFalsy()
render.store.setSelectedBy('value', ['test1'])
render.renderValues()
expect(render.main.values.children).toHaveLength(1)
expect(render.main.values.children.item(0)?.innerHTML).toBe('<span>test1</span>')
})
})
describe('moveContent', () => {
let contentAboveMock: () => void
let contentBelowMock: () => void
beforeEach(() => {
contentAboveMock = vi.fn() as () => void
contentBelowMock = vi.fn() as () => void
render.moveContentAbove = contentAboveMock
render.moveContentBelow = contentBelowMock
})
test('content is moved below when position is relative', () => {
render.settings.contentPosition = 'relative'
render.moveContent()
expect(contentAboveMock).not.toHaveBeenCalled()
expect(contentBelowMock).toHaveBeenCalled()
})
test('content is moved below when open position is down', () => {
render.settings.openPosition = 'down'
render.moveContent()
expect(contentAboveMock).not.toHaveBeenCalled()
expect(contentBelowMock).toHaveBeenCalled()
})
test('content is moved above when open position is up', () => {
render.settings.openPosition = 'up'
render.moveContent()
expect(contentAboveMock).toHaveBeenCalled()
expect(contentBelowMock).not.toHaveBeenCalled()
})
test('content is moved above when putContent is up', () => {
render.putContent = vi.fn(() => 'up') as any
render.moveContent()
expect(contentAboveMock).toHaveBeenCalled()
expect(contentBelowMock).not.toHaveBeenCalled()
})
test('content is moved below when putContent is down', () => {
render.putContent = vi.fn(() => 'down') as any
render.moveContent()
expect(contentAboveMock).not.toHaveBeenCalled()
expect(contentBelowMock).toHaveBeenCalled()
})
})
describe('searchDiv', () => {
test('search is hidden when showSearch setting is false', () => {
render.settings.showSearch = false
const search = render.searchDiv()
expect(search.main.classList.contains(render.classes.hide)).toBe(true)
})
test('input is debounced', async () => {
const search = render.searchDiv()
search.input.dispatchEvent(new InputEvent('input', { data: 'asdf' }))
// wait for debounce to trigger
await new Promise((r) => setTimeout(r, 101))
expect(searchMock).toHaveBeenCalled()
})
test('arrow keys move highlight', () => {
const search = render.searchDiv()
const highlightMock = vi.fn(() => {})
render.highlight = highlightMock
search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
expect(highlightMock).toHaveBeenCalledTimes(1)
expect(highlightMock.mock.calls[0]).toStrictEqual(['up'])
search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
expect(highlightMock).toHaveBeenCalledTimes(2)
expect(highlightMock.mock.calls[1]).toStrictEqual(['down'])
})
test('tab triggers close callback', () => {
const search = render.searchDiv()
search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }))
expect(closeMock).toHaveBeenCalled()
})
test('escape triggers close callback', () => {
// separate test in case we want to also test the event someday
const search = render.searchDiv()
search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(closeMock).toHaveBeenCalled()
})
test("enter and space don't call addable witout ctrl key", () => {
const search = render.searchDiv()
const addableMock = vi.fn((s: string) => ({
text: s,
value: s.toLowerCase()
}))
render.callbacks.addable = addableMock
search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(addableMock).not.toHaveBeenCalled()
search.input.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }))
expect(addableMock).not.toHaveBeenCalled()
})
test('enter and space call event and does nothing without input value', () => {
const addableMock = vi.fn((s: string) => ({
text: s,
value: s.toLowerCase()
}))
render.callbacks.addable = addableMock
// recreate search because we have added the addable callback
render.content.search = render.searchDiv()
render.content.search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(addableMock).not.toHaveBeenCalled()
})
test('enter and space call addable when defined', () => {
const addableMock = vi.fn((s: string) => ({
text: s,
value: s.toLowerCase()
}))
render.callbacks.addable = addableMock
// recreate search because we have added the addable callback
render.content.search = render.searchDiv()
render.content.search.input.value = 'Search'
render.content.search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(addableMock).toHaveBeenCalledTimes(1)
expect(addableMock.mock.calls[0]).toStrictEqual(['Search'])
})
test('enter selects highlighted option before calling addable', () => {
const addableMock = vi.fn((s: string) => ({
text: s,
value: s.toLowerCase()
}))
render.callbacks.addable = addableMock
// recreate search because we have added the addable callback
render.content.search = render.searchDiv()
// Render options
render.renderOptions(render.store.getDataOptions())
// Set search value
render.content.search.input.value = '1'
// Highlight first option (simulating arrow down)
render.highlight('down')
// Verify an option is highlighted
const highlighted = render.content.list.querySelector('.' + render.classes.highlighted)
expect(highlighted).toBeTruthy()
// Press Enter - should select highlighted option, NOT call addable
render.content.search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
// Addable should NOT have been called because an option was highlighted
expect(addableMock).not.toHaveBeenCalled()
})
test('enter calls addable when no option is highlighted', () => {
const addableMock = vi.fn((s: string) => ({
text: s,
value: s.toLowerCase()
}))
render.callbacks.addable = addableMock
// recreate search because we have added the addable callback
render.content.search = render.searchDiv()
// Render options
render.renderOptions(render.store.getDataOptions())
// Set search value
render.content.search.input.value = 'NewItem'
// Do NOT highlight any option (user just types and presses Enter)
// Press Enter - should call addable since no option is highlighted
render.content.search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
// Addable SHOULD have been called
expect(addableMock).toHaveBeenCalledTimes(1)
expect(addableMock.mock.calls[0]).toStrictEqual(['NewItem'])
})
})
describe('searchFocus', () => {
test('search is focused', () => {
expect(document.activeElement).not.toBe(render.content.search.input)
render.searchFocus()
expect(document.activeElement).toBe(render.content.search.input)
})
})
describe('getOptions', () => {
test('returns all options when called without parameters', () => {
// render all 3 default options and get them back
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions()
expect(opts).toHaveLength(3)
})
test('filters correctly when filtering out placeholders', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0',
placeholder: true
},
{
text: 'opt1'
},
{
text: 'opt2'
}
])
)
const opts = render.getOptions(true, false, true)
expect(opts).toHaveLength(2)
})
test('filters correctly when filtering out disabled options', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0',
disabled: true
},
{
text: 'opt1'
},
{
text: 'opt2'
}
])
)
const opts = render.getOptions(false, true)
expect(opts).toHaveLength(2)
})
test('filters correctly when filtering out hidden options', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0',
placeholder: true
},
{
text: 'opt1'
},
{
text: 'opt2'
}
])
)
const opts = render.getOptions(false, false, true)
expect(opts).toHaveLength(2)
})
test('filters correctly when filtering out hidden and disabled options', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0',
disabled: true
},
{
text: 'opt1',
placeholder: true
},
{
text: 'opt2'
}
])
)
const opts = render.getOptions(false, true, true)
expect(opts).toHaveLength(1)
})
})
describe('highlight', () => {
test('simply do nothing without breaking when options are empty', () => {
render.renderOptions([])
expect(() => render.highlight('up')).not.toThrow()
})
test('highlight single option that is not already highlighted', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0'
}
])
)
render.highlight('up')
expect(render.getOptions()[0].classList.contains(render.classes.highlighted)).toBe(true)
})
test('select first option with down when no option is highlighted or selected', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0'
},
{
text: 'opt1'
},
{
text: 'opt2'
}
])
)
render.highlight('down')
expect(render.getOptions()[0].classList.contains(render.classes.highlighted)).toBe(true)
})
test('select last option with up when no option is highlighted or selected', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0'
},
{
text: 'opt1'
},
{
text: 'opt2'
}
])
)
render.highlight('up')
expect(render.getOptions()[2].classList.contains(render.classes.highlighted)).toBe(true)
})
test('highlight next option on down after highlighted option', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0',
class: render.classes.highlighted
},
{
text: 'opt1'
},
{
text: 'opt2'
}
])
)
render.highlight('down')
expect(render.getOptions()[1].classList.contains(render.classes.highlighted)).toBe(true)
})
test('highlight previous option on up before highlighted option', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0'
},
{
text: 'opt1'
},
{
text: 'opt2',
class: render.classes.highlighted
}
])
)
render.highlight('up')
expect(render.getOptions()[1].classList.contains(render.classes.highlighted)).toBe(true)
})
test('highlight next option on down after selected option when no options is highlighted', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0',
selected: true
},
{
text: 'opt1'
},
{
text: 'opt2'
}
])
)
render.highlight('down')
expect(render.getOptions()[1].classList.contains(render.classes.highlighted)).toBe(true)
})
test('skip to last option when using up at the first option', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0',
selected: true
},
{
text: 'opt1'
},
{
text: 'opt2'
}
])
)
render.highlight('up')
expect(render.getOptions()[2].classList.contains(render.classes.highlighted)).toBe(true)
})
test('highlight next option within opt group on down', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0',
selected: true
},
{
label: 'opt group',
options: [
{
text: 'opt1'
}
]
},
{
text: 'opt2'
}
])
)
render.highlight('down')
expect(render.getOptions()[1].classList.contains(render.classes.highlighted)).toBe(true)
})
test('highlight previous option within opt group on up', () => {
render.renderOptions(
render.store.partialToFullData([
{
text: 'opt0'
},
{
label: 'opt group',
options: [
{
text: 'opt1',
selected: true
}
]
},
{
text: 'opt2'
}
])
)
render.highlight('up')
expect(render.getOptions()[0].classList.contains(render.classes.highlighted)).toBe(true)
})
})
describe('listDiv', () => {
test('list div has correct class', () => {
const list = render.listDiv()
expect(list.classList.contains(render.classes.list)).toBe(true)
})
})
describe('renderError', () => {
test('error message is rendered correctly', () => {
expect(render.content.list.children).toHaveLength(0)
render.renderError('test error')
expect(render.content.list.children).toHaveLength(1)
expect(render.content.list.children.item(0)).toBeInstanceOf(HTMLDivElement)
expect(render.content.list.children.item(0)?.className).toBe(render.classes.error)
expect(render.content.list.children.item(0)?.textContent).toBe('test error')
})
test('list is reset on new error', () => {
expect(render.content.list.children).toHaveLength(0)
render.renderError('test error')
expect(render.content.list.children).toHaveLength(1)
render.renderError('error 2')
expect(render.content.list.children).toHaveLength(1)
expect(render.content.list.children.item(0)?.textContent).toBe('error 2')
})
})
describe('renderSearching', () => {
test('search text is rendered correctly', () => {
expect(render.content.list.children).toHaveLength(0)
render.settings.searchingText = 'search'
render.renderSearching()
expect(render.content.list.children).toHaveLength(1)
expect(render.content.list.children.item(0)).toBeInstanceOf(HTMLDivElement)
expect(render.content.list.children.item(0)?.className).toBe(render.classes.searching)
expect(render.content.list.children.item(0)?.textContent).toBe('search')
})
test('list is reset on new search text', () => {
expect(render.content.list.children).toHaveLength(0)
render.settings.searchingText = 'search'
render.renderSearching()
expect(render.content.list.children).toHaveLength(1)
render.settings.searchingText = 'search 2'
render.renderSearching()
expect(render.content.list.children).toHaveLength(1)
expect(render.content.list.children.item(0)?.textContent).toBe('search 2')
})
})
describe('option', () => {
test('add inline styles correctly', () => {
const option = render.option(
new Option({
text: 'opt',
style: 'color: red'
})
)
expect(option.style.color).toBe('red')
})
test('add hidden class on option with display false', () => {
const option = render.option(
new Option({
text: 'opt',
display: false
})
)
expect(option.classList.contains(render.classes.hide)).toBe(true)
})
test('add hidden class on selected option when hideSelected setting is true', () => {
render.settings.hideSelected = true
const option = render.option(
new Option({
text: 'opt',
selected: true
})
)
expect(option.classList.contains(render.classes.hide)).toBe(true)
})
test('title attribute is set when showOptionTooltips setting is true', () => {
render.settings.showOptionTooltips = true
const option = render.option(
new Option({
text: 'opt'
})
)
expect(option.getAttribute('title')).toBe('opt')
})
test('text is highlighted correctly with option text', () => {
render.settings.searchHighlight = true
render.content.search.input.value = 'opt'
const option = render.option(
new Option({
text: 'opt 1'
})
)
expect(option.querySelector('mark')).toBeTruthy()
expect(option.querySelector('mark')?.textContent).toBe('opt')
})
test('text is highlighted correctly with option HTML', () => {
render.settings.searchHighlight = true
render.content.search.input.value = 'opt'
const option = render.option(
new Option({
text: 'opt 1',
html: '<h1>opt 1</h1>'
})
)
expect(option.querySelector('mark')).toBeTruthy()
expect(option.querySelector('mark')?.textContent).toBe('opt')
})
test('text highlighting with special regex characters does not break HTML', () => {
render.settings.searchHighlight = true
render.content.search.input.value = '<'
const option = render.option(
new Option({
text: 'option <test>'
})
)
// Should not break the HTML structure
expect(option.querySelector('.ss-option')).toBeFalsy() // No nested ss-option divs
// The < character should be highlighted if found in text
const mark = option.querySelector('mark')
if (mark) {
expect(mark.textContent).toBe('<')
}
})
test('text highlighting escapes special regex characters', () => {
render.settings.searchHighlight = true
// Test various special regex characters
const specialChars = ['<', '>', '.', '*', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\']
specialChars.forEach((char) => {
render.content.search.input.value = char
const option = render.option(
new Option({
text: `option ${char} test`
})
)
// Should not throw an error and should contain the character
expect(option.textContent).toContain(char)
})
})
test('text highlighting with HTML content highlights only text nodes', () => {
render.settings.searchHighlight = true
render.content.search.input.value = 'test'
const option = render.option(
new Option({
text: 'test option',
html: '<div class="wrapper">test option</div>'
})
)
// Should not break HTML structure - the key fix for issue #570
expect(option.querySelector('.wrapper')).toBeTruthy() // HTML structure preserved
expect(option.querySelector('mark')).toBeTruthy() // Text is highlighted
expect(option.querySelector('mark')?.textContent).toBe('test')
})
test('click does nothing when option is disabled', () => {
const option = render.option(
new Option({
text: 'opt 1',
disabled: true
})
)
option.dispatchEvent(new MouseEvent('click'))
expect(addOptionMock).not.toHaveBeenCalled()
expect(setSelectedMock).not.toHaveBeenCalled()
})
test('click does nothing when max count of selected options is reached', () => {
render.settings.isMultiple = true
render.settings.maxSelected = 1
const option = render.option(
new Option({
text: 'opt 1'
})
)
option.dispatchEvent(new MouseEvent('click'))
expect(addOptionMock).not.toHaveBeenCalled()
expect(setSelectedMock).not.toHaveBeenCalled()
})
test('click does nothing when min count of selected options is reached', () => {
render.settings.isMultiple = true
render.settings.allowDeselect = true
render.settings.minSelected = 1
const option = render.option(
new Option({
text: 'opt 1',
selected: true
})
)
option.dispatchEvent(new MouseEvent('click'))
expect(addOptionMock).not.toHaveBeenCalled()
expect(setSelectedMock).not.toHaveBeenCalled()
})
test('click does nothing when trying to deselect a mandatory option', () => {
const option = render.option(
new Option({
text: 'mandatory option',
selected: true,
mandatory: true
})
)
option.dispatchEvent(new MouseEvent('click'))
expect(addOptionMock).not.toHaveBeenCalled()
expect(setSelectedMock).not.toHaveBeenCalled()
})
test('click removes option', () => {
const option = render.option(
new Option({
text: 'new opt 1'
})
)
option.dispatchEvent(new MouseEvent('click'))
expect(addOptionMock).toHaveBeenCalled()
// check that we add the right option
expect(addOptionMock.mock.calls[0][0].text).toBe('new opt 1')
expect(setSelectedMock).toHaveBeenCalled()
})
})
describe('native multi-select behavior', () => {
let render: Render
let afterChangeMock: ReturnType<typeof vi.fn>
let closeMock: ReturnType<typeof vi.fn>
beforeEach(() => {
// create a new store with 5 options for comprehensive testing
const store = new Store('multiple', [
{ text: 'test1', value: 'test1' },
{ text: 'test2', value: 'test2' },
{ text: 'test3', value: 'test3' },
{ text: 'test4', value: 'test4' },
{ text: 'test5', value: 'test5' }
])
const settings = new Settings()
const classes = new CssClasses()
afterChangeMock = vi.fn(() => {})
closeMock = vi.fn(() => {})
const callbacks = {
open: () => {},
close: closeMock,
addSelected: () => {},
setSelected: (value) => {
store.setSelectedBy('id', typeof value === 'string' ? [value] : value)
},
addOption: () => {},
search: () => {},
afterChange: afterChangeMock
} as Callbacks
render = new Render(settings, classes, store, callbacks)
render.settings.isMultiple = true
render.settings.closeOnSelect = true
})
describe('Regular Click (no modifiers)', () => {
test('toggles option (add/remove) without affecting others', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Click first option - adds it
opts[0].dispatchEvent(new MouseEvent('click'))
expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
// Click third option - adds it (first option still selected)
opts[2].dispatchEvent(new MouseEvent('click'))
expect(afterChangeMock).toHaveBeenCalledWith([
expect.objectContaining({ value: 'test1' }),
expect.objectContaining({ value: 'test3' })
])
// Click first option again - removes it
opts[0].dispatchEvent(new MouseEvent('click'))
expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test3' })])
})
test('closes dropdown on regular click when closeOnSelect is true', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
opts[0].dispatchEvent(new MouseEvent('click'))
expect(closeMock).toHaveBeenCalled()
})
test('regular click on selected option deselects it and closes dropdown', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Select two options
opts[0].dispatchEvent(new MouseEvent('click'))
opts[1].dispatchEvent(new MouseEvent('click'))
expect(afterChangeMock).toHaveBeenLastCalledWith([
expect.objectContaining({ value: 'test1' }),
expect.objectContaining({ value: 'test2' })
])
closeMock.mockClear()
// Click on already selected option - should deselect it and close
opts[0].dispatchEvent(new MouseEvent('click'))
expect(afterChangeMock).toHaveBeenLastCalledWith([expect.objectContaining({ value: 'test2' })])
expect(closeMock).toHaveBeenCalledTimes(1)
})
test('regression: regular click adds/removes and closes, Cmd/Ctrl keeps open', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Regular click - should close
opts[0].dispatchEvent(new MouseEvent('click'))
expect(closeMock).toHaveBeenCalledTimes(1)
closeMock.mockClear()
// Cmd+Click - should NOT close
opts[1].dispatchEvent(new MouseEvent('click', { metaKey: true }))
expect(closeMock).not.toHaveBeenCalled()
// Ctrl+Click - should NOT close
opts[2].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
expect(closeMock).not.toHaveBeenCalled()
// Regular click again - should close
opts[3].dispatchEvent(new MouseEvent('click'))
expect(closeMock).toHaveBeenCalledTimes(1)
})
})
describe('Cmd/Ctrl+Click (toggle selection)', () => {
test('Cmd+Click adds option without deselecting others (Mac)', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Cmd+Click first option
opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
// Cmd+Click third option - both should be selected
opts[2].dispatchEvent(new MouseEvent('click', { metaKey: true }))
expect(afterChangeMock).toHaveBeenCalledWith([
expect.objectContaining({ value: 'test1' }),
expect.objectContaining({ value: 'test3' })
])
})
test('Ctrl+Click adds option without deselecting others (Windows/Linux)', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Ctrl+Click first option
opts[0].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
// Ctrl+Click third option - both should be selected
opts[2].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
expect(afterChangeMock).toHaveBeenCalledWith([
expect.objectContaining({ value: 'test1' }),
expect.objectContaining({ value: 'test3' })
])
})
test('Cmd+Click on selected option deselects it (Mac)', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Select multiple options with Cmd
opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
opts[1].dispatchEvent(new MouseEvent('click', { metaKey: true }))
opts[2].dispatchEvent(new MouseEvent('click', { metaKey: true }))
// Cmd+Click on middle option to deselect it
opts[1].dispatchEvent(new MouseEvent('click', { metaKey: true }))
expect(afterChangeMock).toHaveBeenCalledWith([
expect.objectContaining({ value: 'test1' }),
expect.objectContaining({ value: 'test3' })
])
})
test('Ctrl+Click on selected option deselects it (Windows/Linux)', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Select multiple options with Ctrl
opts[0].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
opts[1].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
opts[2].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
// Ctrl+Click on middle option to deselect it
opts[1].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
expect(afterChangeMock).toHaveBeenCalledWith([
expect.objectContaining({ value: 'test1' }),
expect.objectContaining({ value: 'test3' })
])
})
test('Cmd+Click toggles selection on and off', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Click to select
opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
expect(afterChangeMock).toHaveBeenLastCalledWith([expect.objectContaining({ value: 'test1' })])
// Click again to deselect
opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
expect(afterChangeMock).toHaveBeenLastCalledWith([])
// Click once more to select again
opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
expect(afterChangeMock).toHaveBeenLastCalledWith([expect.objectContaining({ value: 'test1' })])
})
test('Cmd/Ctrl+Click works even when allowDeselect is false', () => {
render.settings.allowDeselect = false
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Select with Cmd/Ctrl
opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
opts[1].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
// Should be able to deselect with Cmd/Ctrl even though allowDeselect is false
opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
expect(afterChangeMock).toHaveBeenLastCalledWith([expect.objectContaining({ value: 'test2' })])
})
test('does NOT close dropdown on Cmd+Click', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
opts[0].dispatchEvent(new MouseEvent('click', { metaKey: true }))
expect(closeMock).not.toHaveBeenCalled()
})
test('does NOT close dropdown on Ctrl+Click', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
opts[0].dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
expect(closeMock).not.toHaveBeenCalled()
})
})
describe('Shift+Click (range selection)', () => {
test('selects range from last clicked to current', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Click first option
opts[0].dispatchEvent(new MouseEvent('click'))
expect(afterChangeMock).toHaveBeenCalledWith([expect.objectContaining({ value: 'test1' })])
// Shift+Click third option - should select test1, test2, test3
opts[2].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
expect(afterChangeMock).toHaveBeenCalledWith([
expect.objectContaining({ value: 'test1' }),
expect.objectContaining({ value: 'test2' }),
expect.objectContaining({ value: 'test3' })
])
})
test('selects range in reverse direction', () => {
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Click fourth option
opts[3].dispatchEvent(new MouseEvent('click'))
// Shift+Click second option - should select test2, test3, test4
opts[1].dispatchEvent(new MouseEvent('click', { shiftKey: true }))
expect(afterChangeMock).toHaveBeenCalledWith([
expect.objectContaining({ value: 'test4' }),
expect.objectContaining({ value: 'test2' }),
expect.objectContaining({ value: 'test3' })
])
})
test('respects maxSelected limit', () => {
render.settings.maxSelected = 2
render.renderOptions(render.store.getDataOptions())
const opts = render.getOptions(false, false, true)
// Clic