import '@testing-library/jest-dom' import { act, fireEvent, render } from '@testing-library/react' import { vi } from 'vitest' import type { IPktComboboxOption } from 'shared-types/combobox' import { PktCombobox } from './Combobox' import type { IPktCombobox } from './types' const comboboxId = 'test-combobox' const label = 'Test Combobox' const getDefaultOptions = (): IPktComboboxOption[] => [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, ] const createComboboxTest = (props: Partial = {}) => { const defaultProps: IPktCombobox = { label, id: comboboxId, ...props, } return render() } const getFormInputValue = (container: HTMLElement) => { return (container.querySelector('input.pkt-visually-hidden') as HTMLInputElement)?.value ?? '' } describe('PktCombobox', () => { describe('Keyboard navigation', () => { test('opens dropdown with Enter on arrow button', () => { const { container } = createComboboxTest() const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.keyDown(arrowButton!, { key: 'Enter' }) expect(arrowButton?.getAttribute('aria-expanded')).toBe('true') }) test('opens dropdown with Space on arrow button', () => { const { container } = createComboboxTest() const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.keyDown(arrowButton!, { key: ' ' }) expect(arrowButton?.getAttribute('aria-expanded')).toBe('true') }) test('opens dropdown with ArrowDown on arrow button', () => { const { container } = createComboboxTest() const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.keyDown(arrowButton!, { key: 'ArrowDown' }) expect(arrowButton?.getAttribute('aria-expanded')).toBe('true') }) test('toggles dropdown closed with Enter on arrow button', () => { const { container } = createComboboxTest() const arrowButton = container.querySelector('.pkt-combobox__input') // Open fireEvent.keyDown(arrowButton!, { key: 'Enter' }) expect(arrowButton?.getAttribute('aria-expanded')).toBe('true') // Close fireEvent.keyDown(arrowButton!, { key: 'Enter' }) expect(arrowButton?.getAttribute('aria-expanded')).toBe('false') }) test('does not toggle on non-toggle keys', () => { const { container } = createComboboxTest() const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.keyDown(arrowButton!, { key: 'Escape' }) expect(arrowButton?.getAttribute('aria-expanded')).toBe('false') }) test('submits value with Enter in text input', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ allowUserInput: true, options: getDefaultOptions(), onValueChange: handleValueChange, }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) textInput.value = 'apple' fireEvent.change(textInput, { target: { value: 'apple' } }) fireEvent.keyDown(textInput, { key: 'Enter' }) expect(handleValueChange).toHaveBeenCalledWith(['apple']) }) test('closes dropdown with Escape in text input', () => { const { container } = createComboboxTest({ allowUserInput: true, options: getDefaultOptions(), }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) expect(textInput.getAttribute('aria-expanded')).toBe('true') fireEvent.keyDown(textInput, { key: 'Escape' }) expect(textInput.getAttribute('aria-expanded')).toBe('false') }) test('does not open dropdown when disabled', () => { const { container } = createComboboxTest({ disabled: true }) const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.keyDown(arrowButton!, { key: 'Enter' }) expect(arrowButton?.getAttribute('aria-expanded')).toBe('false') }) test('selects option with Enter key on option element', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ options: getDefaultOptions(), onValueChange: handleValueChange, }) const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) const option = container.querySelector('[data-value="apple"][role="option"]') fireEvent.keyDown(option!, { key: 'Enter' }) expect(handleValueChange).toHaveBeenCalledWith(['apple']) }) test('selects option with Space key on option element', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ options: getDefaultOptions(), onValueChange: handleValueChange, }) const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) const option = container.querySelector('[data-value="banana"][role="option"]') fireEvent.keyDown(option!, { key: ' ' }) expect(handleValueChange).toHaveBeenCalledWith(['banana']) }) test('closes dropdown with Escape on option element', () => { const { container } = createComboboxTest({ options: getDefaultOptions(), }) const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) expect(arrowButton?.getAttribute('aria-expanded')).toBe('true') const option = container.querySelector('[data-value="apple"][role="option"]') fireEvent.keyDown(option!, { key: 'Escape' }) expect(arrowButton?.getAttribute('aria-expanded')).toBe('false') }) test('adds value with comma separator in multiple mode', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ allowUserInput: true, multiple: true, options: getDefaultOptions(), onValueChange: handleValueChange, }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) textInput.value = 'apple' fireEvent.change(textInput, { target: { value: 'apple' } }) fireEvent.keyDown(textInput, { key: ',' }) expect(handleValueChange).toHaveBeenCalledWith(['apple']) }) }) describe('Focus handling', () => { test('opens dropdown on input focus', () => { const { container } = createComboboxTest({ allowUserInput: true, options: getDefaultOptions(), }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) expect(textInput.getAttribute('aria-expanded')).toBe('true') }) test('populates input with current value on focus in single-select', () => { const { container } = createComboboxTest({ allowUserInput: true, defaultValue: 'apple', options: getDefaultOptions(), }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) expect(textInput.value).toBe('Apple') }) test('opens dropdown on input container click (hidden input mode)', () => { const { container } = createComboboxTest({ options: getDefaultOptions(), }) const inputDiv = container.querySelector('.pkt-combobox__input') fireEvent.click(inputDiv!) const arrowButton = container.querySelector('.pkt-combobox__input') expect(arrowButton?.getAttribute('aria-expanded')).toBe('true') }) test('toggles dropdown on input container click (hidden input mode)', () => { const { container } = createComboboxTest({ options: getDefaultOptions(), }) const inputDiv = container.querySelector('.pkt-combobox__input') fireEvent.click(inputDiv!) const arrowButton = container.querySelector('.pkt-combobox__input') expect(arrowButton?.getAttribute('aria-expanded')).toBe('true') fireEvent.click(inputDiv!) expect(arrowButton?.getAttribute('aria-expanded')).toBe('false') }) test('does not open when disabled and input container is clicked', () => { const { container } = createComboboxTest({ disabled: true }) const inputDiv = container.querySelector('.pkt-combobox__input') fireEvent.click(inputDiv!) const arrowButton = container.querySelector('.pkt-combobox__input') expect(arrowButton?.getAttribute('aria-expanded')).toBe('false') }) test('focuses text input when placeholder is clicked', () => { const { container } = createComboboxTest({ allowUserInput: true, placeholder: 'Select...', }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement expect(textInput.placeholder).toBe('Select...') fireEvent.click(textInput) expect(document.activeElement).toBe(textInput) }) }) describe('Focus-out behavior', () => { test('closes dropdown when clicking outside combobox', () => { const { container } = createComboboxTest({ options: getDefaultOptions(), }) const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) expect(arrowButton?.getAttribute('aria-expanded')).toBe('true') fireEvent.click(document.body) expect(arrowButton?.getAttribute('aria-expanded')).toBe('false') }) test('adds custom value on focus-out when allowUserInput is on', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ allowUserInput: true, options: getDefaultOptions(), onValueChange: handleValueChange, }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) textInput.value = 'NewFruit' fireEvent.change(textInput, { target: { value: 'NewFruit' } }) // Click outside to trigger close and process input fireEvent.click(document.body) expect(handleValueChange).toHaveBeenCalledWith(['NewFruit']) }) test('selects matching option on focus-out when typeahead', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ typeahead: true, options: getDefaultOptions(), onValueChange: handleValueChange, }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) textInput.value = 'Apple' fireEvent.change(textInput, { target: { value: 'Apple' } }) // Click outside to trigger close and process input fireEvent.click(document.body) expect(handleValueChange).toHaveBeenCalledWith(['apple']) }) }) describe('Search and filtering', () => { test('shows add-value banner when search has no exact match and allowUserInput is on', () => { const { container } = createComboboxTest({ allowUserInput: true, options: getDefaultOptions(), }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) fireEvent.input(textInput, { target: { value: 'NewFruit' } }) const addBanner = container.querySelector('.pkt-listbox__banner--new-option') expect(addBanner).toBeInTheDocument() }) test('renders search input in listbox when includeSearch is true', () => { const { container } = createComboboxTest({ includeSearch: true }) const searchInput = container.querySelector('.pkt-listbox__search input') expect(searchInput).toBeInTheDocument() expect(searchInput?.getAttribute('role')).toBe('searchbox') }) test('sets search placeholder in listbox', () => { const { container } = createComboboxTest({ includeSearch: true, searchPlaceholder: 'Søk her...', }) const searchInput = container.querySelector('.pkt-listbox__search input') as HTMLInputElement expect(searchInput.placeholder).toBe('Søk her...') }) test('filters options via listbox search', () => { const { container } = createComboboxTest({ includeSearch: true, options: getDefaultOptions(), }) // Open dropdown const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) // Type in listbox search const searchInput = container.querySelector('.pkt-listbox__search input') as HTMLInputElement fireEvent.change(searchInput, { target: { value: 'app' } }) // Options should be filtered const visibleOptions = container.querySelectorAll('.pkt-listbox__option') expect(visibleOptions.length).toBeLessThan(4) }) test('search input navigates to first option with ArrowDown', () => { const { container } = createComboboxTest({ includeSearch: true, options: getDefaultOptions(), }) // Open dropdown const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) const searchInput = container.querySelector('.pkt-listbox__search input') as HTMLInputElement // ArrowDown in search should not throw fireEvent.keyDown(searchInput, { key: 'ArrowDown' }) expect(searchInput).toBeInTheDocument() }) test('search input closes dropdown with Escape', () => { const { container } = createComboboxTest({ includeSearch: true, options: getDefaultOptions(), }) const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) expect(arrowButton?.getAttribute('aria-expanded')).toBe('true') const searchInput = container.querySelector('.pkt-listbox__search input') as HTMLInputElement fireEvent.keyDown(searchInput, { key: 'Escape' }) expect(arrowButton?.getAttribute('aria-expanded')).toBe('false') }) }) describe('New option banner interaction', () => { test('adds new option when clicking add-value banner', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ allowUserInput: true, options: getDefaultOptions(), onValueChange: handleValueChange, }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) fireEvent.input(textInput, { target: { value: 'NewFruit' } }) const addBanner = container.querySelector('.pkt-listbox__banner--new-option') expect(addBanner).toBeInTheDocument() fireEvent.click(addBanner!) expect(handleValueChange).toHaveBeenCalledWith(['NewFruit']) }) test('adds new option when pressing Enter on add-value banner', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ allowUserInput: true, options: getDefaultOptions(), onValueChange: handleValueChange, }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) fireEvent.input(textInput, { target: { value: 'NewFruit' } }) const addBanner = container.querySelector('.pkt-listbox__banner--new-option') expect(addBanner).toBeInTheDocument() fireEvent.keyDown(addBanner!, { key: 'Enter' }) expect(handleValueChange).toHaveBeenCalledWith(['NewFruit']) }) }) describe('Children as options', () => { test('handles options from children correctly', () => { const handleValueChange = vi.fn() const { container } = render( , ) const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) const option = container.querySelector('[data-value="opt2"][role="option"]') fireEvent.click(option!) expect(handleValueChange).toHaveBeenCalledWith(['opt2']) }) test('handles children with defaultValue', () => { const { container } = render( , ) const valueSpan = container.querySelector('.pkt-combobox__value') expect(valueSpan?.textContent?.trim()).toBe('Option 1') expect(getFormInputValue(container)).toBe('opt1') }) test('handles children re-render with new options', () => { const { container, rerender } = render( , ) let options = container.querySelectorAll('.pkt-listbox__option') expect(options.length).toBe(2) rerender( , ) options = container.querySelectorAll('.pkt-listbox__option') expect(options.length).toBe(3) }) }) describe('Form reset', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) test('resets single value to defaultValue on form reset', () => { const { container } = render(
, ) // Select a different value const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) const bananaOption = container.querySelector('[data-value="banana"][role="option"]') fireEvent.click(bananaOption!) expect(getFormInputValue(container)).toBe('banana') // Reset the form const form = container.querySelector('form')! act(() => { fireEvent.reset(form) vi.advanceTimersByTime(10) }) expect(getFormInputValue(container)).toBe('apple') }) test('resets multiple values to defaultValue on form reset', () => { const { container } = render(
, ) // Select an additional value const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) const dateOption = container.querySelector('[data-value="date"][role="option"]') fireEvent.click(dateOption!) expect(getFormInputValue(container)).toBe('apple,banana,date') // Reset the form const form = container.querySelector('form')! act(() => { fireEvent.reset(form) vi.advanceTimersByTime(10) }) expect(getFormInputValue(container)).toBe('apple,banana') }) test('resets to empty when no defaultValue on form reset', () => { const { container } = render(
, ) // Select a value const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) const appleOption = container.querySelector('[data-value="apple"][role="option"]') fireEvent.click(appleOption!) expect(getFormInputValue(container)).toBe('apple') // Reset the form const form = container.querySelector('form')! act(() => { fireEvent.reset(form) vi.advanceTimersByTime(10) }) expect(getFormInputValue(container)).toBe('') }) }) })