@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
581 lines (470 loc) • 19.1 kB
text/typescript
import '@testing-library/jest-dom'
import { fireEvent } from '@testing-library/dom'
import { createElementTest, BaseTestConfig } from '../../tests/test-framework'
import { CustomElementFor } from '../../tests/component-registry'
import { type IPktListbox } from './listbox'
import './listbox'
// jsdom does not implement scrollIntoView
HTMLElement.prototype.scrollIntoView = function () {}
// focusAndScrollIntoView uses setTimeout(0) for focus, so we need to flush
const flushFocusTimers = () => new Promise((resolve) => setTimeout(resolve, 10))
export interface ListboxTestConfig extends Partial<IPktListbox>, BaseTestConfig {
label?: string
id?: string
}
// Properties that must be set via JS because their attribute names are kebab-case
const jsOnlyProps = [
'options',
'isOpen',
'includeSearch',
'isMultiSelect',
'allowUserInput',
'maxIsReached',
'customUserInput',
'searchPlaceholder',
'searchValue',
'maxLength',
'userMessage',
] as const
export const createListboxTest = async (config: ListboxTestConfig = {}) => {
// Separate JS-only props from HTML-safe attributes (id, label, disabled)
const htmlConfig: Record<string, unknown> = {}
const jsConfig: Record<string, unknown> = {}
for (const [key, value] of Object.entries(config)) {
if ((jsOnlyProps as readonly string[]).includes(key)) {
jsConfig[key] = value
} else {
htmlConfig[key] = value
}
}
const { container, element } = await createElementTest<
CustomElementFor<'pkt-listbox'>,
BaseTestConfig & Record<string, unknown>
>('pkt-listbox', htmlConfig)
// Set JS-only properties directly
for (const [key, value] of Object.entries(jsConfig)) {
;(element as any)[key] = value
}
await element.updateComplete
return {
container,
listbox: element,
}
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('PktListbox', () => {
describe('Option click handling', () => {
test('dispatches option-toggle event with correct value', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
]
const { listbox } = await createListboxTest({ options })
let toggledValue: string | null = null
listbox.addEventListener('option-toggle', (e: any) => {
toggledValue = e.detail
})
const optionElement = listbox.querySelector('.pkt-listbox__option')
fireEvent.click(optionElement!)
await listbox.updateComplete
expect(toggledValue).toBe('option1')
})
test('does not dispatch event for disabled options', async () => {
const options = [
{ value: 'disabled-option', label: 'Disabled Option', disabled: true },
]
const { listbox } = await createListboxTest({ options })
let toggledValue: string | null = null
listbox.addEventListener('option-toggle', (e: any) => {
toggledValue = e.detail
})
const optionElement = listbox.querySelector('.pkt-listbox__option')
fireEvent.click(optionElement!)
await listbox.updateComplete
expect(toggledValue).toBeNull()
})
test('does not dispatch event for globally disabled listbox', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
]
const { listbox } = await createListboxTest({ disabled: true, options })
let toggledValue: string | null = null
listbox.addEventListener('option-toggle', (e: any) => {
toggledValue = e.detail
})
const optionElement = listbox.querySelector('.pkt-listbox__option')
fireEvent.click(optionElement!)
await listbox.updateComplete
expect(toggledValue).toBeNull()
})
test('allows deselecting when maxIsReached and option is selected', async () => {
const options = [
{ value: 'selected', label: 'Selected', selected: true },
{ value: 'unselected', label: 'Unselected' },
]
const { listbox } = await createListboxTest({
isMultiSelect: true,
maxIsReached: true,
options,
})
let toggledValue: string | null = null
listbox.addEventListener('option-toggle', (e: any) => {
toggledValue = e.detail
})
const optionElements = listbox.querySelectorAll('.pkt-listbox__option')
fireEvent.click(optionElements[0]) // selected
await listbox.updateComplete
expect(toggledValue).toBe('selected')
})
})
describe('Keyboard navigation', () => {
test('navigates down with ArrowDown key', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
]
const { listbox } = await createListboxTest({ options })
const optionElements = listbox.querySelectorAll('.pkt-listbox__option')
;(optionElements[0] as HTMLElement).focus()
fireEvent.keyDown(optionElements[0], { key: 'ArrowDown' })
await flushFocusTimers()
expect(document.activeElement).toBe(optionElements[1])
})
test('navigates up with ArrowUp key', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
]
const { listbox } = await createListboxTest({ options })
const optionElements = listbox.querySelectorAll('.pkt-listbox__option')
;(optionElements[2] as HTMLElement).focus()
fireEvent.keyDown(optionElements[2], { key: 'ArrowUp' })
await flushFocusTimers()
expect(document.activeElement).toBe(optionElements[1])
})
test('navigates to first option with Home key', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
]
const { listbox } = await createListboxTest({ options })
const optionElements = listbox.querySelectorAll('.pkt-listbox__option')
;(optionElements[2] as HTMLElement).focus()
fireEvent.keyDown(optionElements[2], { key: 'Home' })
await flushFocusTimers()
expect(document.activeElement).toBe(optionElements[0])
})
test('navigates to last option with End key', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
]
const { listbox } = await createListboxTest({ options })
const optionElements = listbox.querySelectorAll('.pkt-listbox__option')
;(optionElements[0] as HTMLElement).focus()
fireEvent.keyDown(optionElements[0], { key: 'End' })
await flushFocusTimers()
expect(document.activeElement).toBe(optionElements[2])
})
test('selects option with Enter key', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
]
const { listbox } = await createListboxTest({ options })
let toggledValue: string | null = null
listbox.addEventListener('option-toggle', (e: any) => {
toggledValue = e.detail
})
const optionElement = listbox.querySelector('.pkt-listbox__option') as HTMLElement
optionElement.focus()
fireEvent.keyDown(optionElement, { key: 'Enter' })
await listbox.updateComplete
expect(toggledValue).toBe('option1')
})
test('selects option with Space key', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
]
const { listbox } = await createListboxTest({ options })
let toggledValue: string | null = null
listbox.addEventListener('option-toggle', (e: any) => {
toggledValue = e.detail
})
const optionElement = listbox.querySelector('.pkt-listbox__option') as HTMLElement
optionElement.focus()
fireEvent.keyDown(optionElement, { key: ' ' })
await listbox.updateComplete
expect(toggledValue).toBe('option1')
})
test('closes options with Escape key', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
]
const { listbox } = await createListboxTest({ isOpen: true, options })
let closedFired = false
listbox.addEventListener('close-options', () => {
closedFired = true
})
const optionElement = listbox.querySelector('.pkt-listbox__option') as HTMLElement
optionElement.focus()
fireEvent.keyDown(optionElement, { key: 'Escape' })
await listbox.updateComplete
expect(closedFired).toBe(true)
})
test('dispatches select-all event with Ctrl+A', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
]
const { listbox } = await createListboxTest({
isMultiSelect: true,
options,
})
let selectAllFired = false
listbox.addEventListener('select-all', () => {
selectAllFired = true
})
const optionElement = listbox.querySelector('.pkt-listbox__option') as HTMLElement
optionElement.focus()
fireEvent.keyDown(optionElement, { key: 'a', ctrlKey: true })
await listbox.updateComplete
expect(selectAllFired).toBe(true)
})
})
describe('Search functionality', () => {
test('renders search input when includeSearch is true', async () => {
const { listbox } = await createListboxTest({ includeSearch: true })
const searchInput = listbox.querySelector('[role="searchbox"]')
expect(searchInput).toBeInTheDocument()
})
test('does not render search input when includeSearch is false', async () => {
const { listbox } = await createListboxTest({ includeSearch: false })
const searchInput = listbox.querySelector('[role="searchbox"]')
expect(searchInput).not.toBeInTheDocument()
})
test('filters options by search value', async () => {
const options = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
]
const { listbox } = await createListboxTest({
includeSearch: true,
options,
})
listbox.searchValue = 'app'
listbox.filterOptions()
await listbox.updateComplete
const visibleOptions = listbox.querySelectorAll('.pkt-listbox__option')
expect(visibleOptions).toHaveLength(1)
expect(visibleOptions[0].textContent?.trim()).toContain('Apple')
})
test('shows all options when search is cleared', async () => {
const options = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
]
const { listbox } = await createListboxTest({
includeSearch: true,
options,
})
// Filter
listbox.searchValue = 'app'
listbox.filterOptions()
await listbox.updateComplete
expect(listbox.querySelectorAll('.pkt-listbox__option')).toHaveLength(1)
// Clear
listbox.searchValue = ''
listbox.filterOptions()
await listbox.updateComplete
expect(listbox.querySelectorAll('.pkt-listbox__option')).toHaveLength(2)
})
test('dispatches search event when typing in search input', async () => {
const { listbox } = await createListboxTest({ includeSearch: true })
let searchDetail: string | null = null
listbox.addEventListener('search', (e: any) => {
searchDetail = e.detail
})
const searchInput = listbox.querySelector('[role="searchbox"]') as HTMLInputElement
fireEvent.input(searchInput, { target: { value: 'test' } })
await listbox.updateComplete
expect(searchDetail).toBe('test')
})
test('applies search placeholder', async () => {
const { listbox } = await createListboxTest({
includeSearch: true,
searchPlaceholder: 'Search here...',
})
const searchInput = listbox.querySelector('[role="searchbox"]') as HTMLInputElement
expect(searchInput?.placeholder).toBe('Search here...')
})
})
describe('Multi-select features', () => {
test('renders checkboxes in multi-select mode', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
]
const { listbox } = await createListboxTest({
isMultiSelect: true,
options,
})
const checkboxes = listbox.querySelectorAll('input[type="checkbox"]')
expect(checkboxes).toHaveLength(2)
})
test('renders check icon for selected option in single-select mode', async () => {
const options = [
{ value: 'option1', label: 'Option 1', selected: true },
{ value: 'option2', label: 'Option 2' },
]
const { listbox } = await createListboxTest({ options })
const checkIcon = listbox.querySelector('pkt-icon[name="check-big"]')
expect(checkIcon).toBeInTheDocument()
})
test('shows maximum reached banner', async () => {
const options = [
{ value: 'option1', label: 'Option 1', selected: true },
{ value: 'option2', label: 'Option 2', selected: true },
]
const { listbox } = await createListboxTest({
isMultiSelect: true,
maxLength: 3,
options,
})
const banner = listbox.querySelector('.pkt-listbox__banner--maximum-reached')
expect(banner).toBeInTheDocument()
expect(banner?.textContent).toContain('2 av maks 3')
})
test('disables unselected checkboxes when maxIsReached', async () => {
const options = [
{ value: 'selected', label: 'Selected', selected: true },
{ value: 'unselected', label: 'Unselected' },
]
const { listbox } = await createListboxTest({
isMultiSelect: true,
maxIsReached: true,
options,
})
const checkboxes = listbox.querySelectorAll('input[type="checkbox"]')
expect(checkboxes[0]).not.toBeDisabled() // selected can still deselect
expect(checkboxes[1]).toBeDisabled() // unselected is disabled
})
})
describe('User input banner', () => {
test('shows new option banner when customUserInput is set', async () => {
const { listbox } = await createListboxTest({
allowUserInput: true,
customUserInput: 'New Value',
})
const newOptionBanner = listbox.querySelector('.pkt-listbox__banner--new-option')
expect(newOptionBanner).toBeInTheDocument()
expect(newOptionBanner?.getAttribute('data-value')).toBe('New Value')
})
test('does not show new option banner when allowUserInput is false', async () => {
const { listbox } = await createListboxTest({
allowUserInput: false,
customUserInput: 'New Value',
})
const newOptionBanner = listbox.querySelector('.pkt-listbox__banner--new-option')
expect(newOptionBanner).not.toBeInTheDocument()
})
test('dispatches option-toggle when clicking new option banner', async () => {
const { listbox } = await createListboxTest({
allowUserInput: true,
customUserInput: 'New Value',
})
let toggledValue: string | null = null
listbox.addEventListener('option-toggle', (e: any) => {
toggledValue = e.detail
})
const newOptionBanner = listbox.querySelector('.pkt-listbox__banner--new-option')
fireEvent.click(newOptionBanner!)
await listbox.updateComplete
expect(toggledValue).toBe('New Value')
})
})
describe('User message display', () => {
test('shows user message when set', async () => {
const { listbox } = await createListboxTest({
userMessage: 'Ingen treff i søket',
})
const messageEl = listbox.querySelector('.pkt-listbox__banner--user-message')
expect(messageEl).toBeInTheDocument()
expect(messageEl?.textContent).toContain('Ingen treff i søket')
})
test('does not show user message when null', async () => {
const { listbox } = await createListboxTest({
userMessage: null,
})
const messageEl = listbox.querySelector('.pkt-listbox__banner--user-message')
expect(messageEl).not.toBeInTheDocument()
})
})
describe('Option rendering', () => {
test('renders option prefix when present', async () => {
const options = [
{ value: 'no', label: 'Norway', prefix: 'NO' },
]
const { listbox } = await createListboxTest({ options })
const prefix = listbox.querySelector('.pkt-listbox__option-prefix')
expect(prefix).toBeInTheDocument()
expect(prefix?.textContent).toBe('NO')
})
test('renders option description when present', async () => {
const options = [
{ value: 'option1', label: 'Option 1', description: 'A description' },
]
const { listbox } = await createListboxTest({ options })
const description = listbox.querySelector('.pkt-listbox__option-description')
expect(description).toBeInTheDocument()
expect(description?.textContent).toBe('A description')
})
test('uses value as label when label is not provided', async () => {
const options = [
{ value: 'my-value' },
]
const { listbox } = await createListboxTest({ options })
const label = listbox.querySelector('.pkt-listbox__option-label')
expect(label?.textContent?.trim()).toBe('my-value')
})
test('sets correct data attributes on options', async () => {
const options = [
{ value: 'option1', label: 'Option 1', selected: true },
]
const { listbox } = await createListboxTest({ options })
const optionEl = listbox.querySelector('.pkt-listbox__option')
expect(optionEl?.getAttribute('data-value')).toBe('option1')
expect(optionEl?.getAttribute('data-selected')).toBe('true')
expect(optionEl?.getAttribute('aria-selected')).toBe('true')
expect(optionEl?.getAttribute('role')).toBe('option')
})
test('renders selected class on selected option in single mode', async () => {
const options = [
{ value: 'option1', label: 'Option 1', selected: true },
{ value: 'option2', label: 'Option 2' },
]
const { listbox } = await createListboxTest({ options })
const selectedOption = listbox.querySelector('.pkt-listbox__option--selected')
expect(selectedOption).toBeInTheDocument()
})
test('renders checkbox class on options in multi-select mode', async () => {
const options = [
{ value: 'option1', label: 'Option 1' },
]
const { listbox } = await createListboxTest({
isMultiSelect: true,
options,
})
const checkboxOption = listbox.querySelector('.pkt-listbox__option--checkBox')
expect(checkboxOption).toBeInTheDocument()
})
})
})