@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
437 lines (340 loc) • 16.6 kB
text/typescript
import '@testing-library/jest-dom'
import { fireEvent } from '@testing-library/dom'
import './combobox'
import { PktCombobox } from './combobox'
import type { IPktComboboxOption } from './combobox'
const waitForCustomElements = async () => {
await customElements.whenDefined('pkt-combobox')
}
const createCombobox = async (comboboxProps = '') => {
const container = document.createElement('div')
container.innerHTML = `
<pkt-combobox ${comboboxProps}></pkt-combobox>
`
document.body.appendChild(container)
await waitForCustomElements()
return container
}
const defaultOptions: IPktComboboxOption[] = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
{ value: 'date', label: 'Date' },
]
afterEach(() => {
document.body.innerHTML = ''
})
describe('PktCombobox', () => {
describe('Keyboard navigation', () => {
test('opens dropdown with Enter on arrow button', async () => {
const container = await createCombobox('id="test" name="test" label="Test"')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const arrowButton = combobox.querySelector('.pkt-combobox__input')
fireEvent.keyDown(arrowButton!, { key: 'Enter' })
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(true)
})
test('opens dropdown with Space on arrow button', async () => {
const container = await createCombobox('id="test" name="test" label="Test"')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const arrowButton = combobox.querySelector('.pkt-combobox__input')
fireEvent.keyDown(arrowButton!, { key: ' ' })
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(true)
})
test('opens dropdown with ArrowDown on arrow button', async () => {
const container = await createCombobox('id="test" name="test" label="Test"')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const arrowButton = combobox.querySelector('.pkt-combobox__input')
fireEvent.keyDown(arrowButton!, { key: 'ArrowDown' })
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(true)
})
test('toggles dropdown closed with Enter on arrow button', async () => {
const container = await createCombobox('id="test" name="test" label="Test"')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const arrowButton = combobox.querySelector('.pkt-combobox__input')
// Open
fireEvent.keyDown(arrowButton!, { key: 'Enter' })
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(true)
// Close
fireEvent.keyDown(arrowButton!, { key: 'Enter' })
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(false)
})
test('does not toggle on non-toggle keys', async () => {
const container = await createCombobox('id="test" name="test" label="Test"')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const arrowButton = combobox.querySelector('.pkt-combobox__input')
fireEvent.keyDown(arrowButton!, { key: 'Escape' })
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(false)
})
test('submits value with Enter in text input', async () => {
const container = await createCombobox('id="test" name="test" label="Test" allow-user-input')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
combobox.options = [...defaultOptions]
await combobox.updateComplete
const input = combobox.querySelector('input[type="text"]') as HTMLInputElement
fireEvent.focus(input)
await combobox.updateComplete
input.value = 'apple'
fireEvent.keyDown(input, { key: 'Enter' })
await combobox.updateComplete
expect(combobox['_value']).toContain('apple')
})
test('closes dropdown with Escape in text input', async () => {
const container = await createCombobox('id="test" name="test" label="Test" allow-user-input')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
combobox.options = [...defaultOptions]
await combobox.updateComplete
const input = combobox.querySelector('input[type="text"]') as HTMLInputElement
fireEvent.focus(input)
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(true)
fireEvent.keyDown(input, { key: 'Escape' })
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(false)
})
test('does not open dropdown when disabled', async () => {
const container = await createCombobox('id="test" name="test" label="Test" disabled')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const arrowButton = combobox.querySelector('.pkt-combobox__input')
fireEvent.keyDown(arrowButton!, { key: 'Enter' })
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(false)
})
})
describe('Focus handling', () => {
test('opens dropdown on input focus', async () => {
const container = await createCombobox('id="test" name="test" label="Test" allow-user-input')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
combobox.options = [...defaultOptions]
await combobox.updateComplete
const input = combobox.querySelector('input[type="text"]') as HTMLInputElement
fireEvent.focus(input)
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(true)
expect(combobox['_inputFocus']).toBe(true)
})
test('populates input with current value on focus in single-select', async () => {
const container = await createCombobox(
'id="test" name="test" label="Test" allow-user-input value="apple"',
)
const combobox = container.querySelector('pkt-combobox') as PktCombobox
combobox.options = [...defaultOptions]
await combobox.updateComplete
const input = combobox.querySelector('input[type="text"]') as HTMLInputElement
fireEvent.focus(input)
await combobox.updateComplete
expect(input.value).toBe('Apple')
})
test('handles blur correctly', async () => {
const container = await createCombobox('id="test" name="test" label="Test" allow-user-input')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const input = combobox.querySelector('input[type="text"]') as HTMLInputElement
fireEvent.focus(input)
await combobox.updateComplete
fireEvent.blur(input)
await combobox.updateComplete
expect(combobox['_inputFocus']).toBe(false)
})
test('opens dropdown on input container click', async () => {
const container = await createCombobox('id="test" name="test" label="Test"')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const inputDiv = combobox.querySelector('.pkt-combobox__input')
fireEvent.click(inputDiv!)
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(true)
})
test('does not open when disabled and input container is clicked', async () => {
const container = await createCombobox('id="test" name="test" label="Test" disabled')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const inputDiv = combobox.querySelector('.pkt-combobox__input')
fireEvent.click(inputDiv!)
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(false)
})
})
describe('Focus-out behavior', () => {
test('closes dropdown when clicking outside combobox', async () => {
const container = await createCombobox('id="test" name="test" label="Test"')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const arrowButton = combobox.querySelector('.pkt-combobox__input')
fireEvent.click(arrowButton!)
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(true)
fireEvent.click(document.body)
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(false)
})
test('selects matching option on focus-out when allowUserInput is off', async () => {
const container = await createCombobox('id="test" name="test" label="Test" typeahead')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
combobox.options = [...defaultOptions]
await combobox.updateComplete
const input = combobox.querySelector('input[type="text"]') as HTMLInputElement
fireEvent.focus(input)
await combobox.updateComplete
input.value = 'Apple'
combobox['_isOptionsOpen'] = true
await combobox.updateComplete
;(combobox as any).closeAndProcessInput()
await combobox.updateComplete
expect(combobox['_value']).toContain('apple')
})
test('adds custom value on focus-out when allowUserInput is on', async () => {
const container = await createCombobox('id="test" name="test" label="Test" allow-user-input')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
combobox.options = [...defaultOptions]
await combobox.updateComplete
const input = combobox.querySelector('input[type="text"]') as HTMLInputElement
fireEvent.focus(input)
await combobox.updateComplete
input.value = 'NewFruit'
combobox['_isOptionsOpen'] = true
await combobox.updateComplete
;(combobox as any).closeAndProcessInput()
await combobox.updateComplete
expect(combobox['_value']).toContain('NewFruit')
})
})
describe('Search and filtering', () => {
test('filters options when typing in typeahead mode', async () => {
const container = await createCombobox('id="test" name="test" label="Test" typeahead')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
combobox.options = [...defaultOptions]
await combobox.updateComplete
const input = combobox.querySelector('input[type="text"]') as HTMLInputElement
fireEvent.focus(input)
await combobox.updateComplete
// Type to filter
input.value = 'app'
fireEvent.input(input, { target: { value: 'app' } })
await combobox.updateComplete
const listbox = combobox.querySelector('pkt-listbox') as any
await listbox?.updateComplete
// Internal _options should be filtered
const filteredCount = combobox['_options'].length
expect(filteredCount).toBeLessThan(defaultOptions.length)
})
test('shows no-match message when search has no results and allowUserInput is off', async () => {
const container = await createCombobox('id="test" name="test" label="Test" typeahead')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
combobox.options = [...defaultOptions]
await combobox.updateComplete
const input = combobox.querySelector('input[type="text"]') as HTMLInputElement
fireEvent.focus(input)
await combobox.updateComplete
input.value = 'zzzzz'
fireEvent.input(input, { target: { value: 'zzzzz' } })
await combobox.updateComplete
const listbox = combobox.querySelector('pkt-listbox') as any
await listbox?.updateComplete
const visibleOptions = combobox.querySelectorAll('.pkt-listbox__option')
expect(visibleOptions.length).toBe(0)
})
test('shows add-value banner when search has no exact match and allowUserInput is on', async () => {
const container = await createCombobox('id="test" name="test" label="Test" allow-user-input')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
combobox.options = [...defaultOptions]
await combobox.updateComplete
const input = combobox.querySelector('input[type="text"]') as HTMLInputElement
fireEvent.focus(input)
await combobox.updateComplete
input.value = 'NewFruit'
fireEvent.input(input, { target: { value: 'NewFruit' } })
await combobox.updateComplete
const listbox = combobox.querySelector('pkt-listbox') as any
await listbox?.updateComplete
const addBanner = combobox.querySelector('.pkt-listbox__banner--new-option')
expect(addBanner).toBeInTheDocument()
})
test('dispatches search event on internal search state change', async () => {
const container = await createCombobox('id="test" name="test" label="Test" include-search')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
let searchEventDetail: string | null = null
combobox.addEventListener('search', (e: Event) => {
searchEventDetail = (e as CustomEvent).detail
})
combobox['_search'] = 'test query'
await combobox.updateComplete
expect(searchEventDetail).toBe('test query')
})
test('resets search when option is selected via toggleValue', async () => {
const container = await createCombobox('id="test" name="test" label="Test" typeahead')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
combobox.options = [...defaultOptions]
await combobox.updateComplete
// Set some search state
combobox['_search'] = 'app'
await combobox.updateComplete
// Select an option
;(combobox as any).toggleValue('apple')
await combobox.updateComplete
expect(combobox['_search']).toBe('')
})
})
describe('Listbox search (includeSearch)', () => {
test('passes includeSearch to listbox', async () => {
const container = await createCombobox('id="test" name="test" label="Test" include-search')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const listbox = combobox.querySelector('pkt-listbox')
expect(listbox?.hasAttribute('include-search')).toBe(true)
})
test('passes searchPlaceholder to listbox', async () => {
const container = await createCombobox(
'id="test" name="test" label="Test" include-search search-placeholder="Søk her..."',
)
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const listbox = combobox.querySelector('pkt-listbox') as any
expect(listbox?.searchPlaceholder).toBe('Søk her...')
})
test('updates search state on listbox search-change event', async () => {
const container = await createCombobox('id="test" name="test" label="Test" include-search')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
combobox.options = [...defaultOptions]
await combobox.updateComplete
// Open dropdown first
const arrowButton = combobox.querySelector('.pkt-combobox__input')
fireEvent.click(arrowButton!)
await combobox.updateComplete
// Simulate search input in the listbox search field
const listbox = combobox.querySelector('pkt-listbox') as any
await listbox?.updateComplete
const searchInput = combobox.querySelector('[role="searchbox"]') as HTMLInputElement
if (searchInput) {
fireEvent.input(searchInput, { target: { value: 'app' } })
await combobox.updateComplete
}
expect(combobox['_search']).toBe('app')
})
})
describe('Disconnected callback cleanup', () => {
test('cleans up body click handler on disconnect', async () => {
const container = await createCombobox('id="test" name="test" label="Test"')
const combobox = container.querySelector('pkt-combobox') as PktCombobox
await combobox.updateComplete
const arrowButton = combobox.querySelector('.pkt-combobox__input')
fireEvent.click(arrowButton!)
await combobox.updateComplete
expect(combobox['_isOptionsOpen']).toBe(true)
combobox.remove()
expect(() => fireEvent.click(document.body)).not.toThrow()
})
})
})