UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

581 lines (470 loc) 19.1 kB
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() }) }) })