import '@testing-library/jest-dom' import { 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 ?? '' } const openDropdown = (container: HTMLElement) => { const arrowButton = container.querySelector('.pkt-combobox__input') fireEvent.click(arrowButton!) } const clickOption = (container: HTMLElement, value: string) => { const option = container.querySelector(`[data-value="${value}"][role="option"]`) fireEvent.click(option!) } describe('PktCombobox', () => { describe('Single selection', () => { test('selects a value by clicking option', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ options: getDefaultOptions(), onValueChange: handleValueChange, }) openDropdown(container) clickOption(container, 'apple') expect(handleValueChange).toHaveBeenCalledWith(['apple']) expect(getFormInputValue(container)).toBe('apple') }) test('replaces current selection when selecting a new value', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ defaultValue: 'apple', options: getDefaultOptions(), onValueChange: handleValueChange, }) expect(getFormInputValue(container)).toBe('apple') openDropdown(container) clickOption(container, 'banana') expect(handleValueChange).toHaveBeenCalledWith(['banana']) expect(getFormInputValue(container)).toBe('banana') }) test('closes dropdown after selecting in single mode', () => { const { container } = createComboboxTest({ options: getDefaultOptions(), }) openDropdown(container) const arrowButton = container.querySelector('.pkt-combobox__input') expect(arrowButton?.getAttribute('aria-expanded')).toBe('true') clickOption(container, 'apple') expect(arrowButton?.getAttribute('aria-expanded')).toBe('false') }) test('deselects value when toggling already selected option', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ defaultValue: 'apple', options: getDefaultOptions(), onValueChange: handleValueChange, }) openDropdown(container) clickOption(container, 'apple') expect(handleValueChange).toHaveBeenCalledWith([]) }) test('displays selected value as text in single mode', () => { const { container } = createComboboxTest({ defaultValue: 'apple', options: getDefaultOptions(), }) const valueSpan = container.querySelector('.pkt-combobox__value') expect(valueSpan?.textContent?.trim()).toBe('Apple') }) test('selects option when clicking it in the open dropdown', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ options: getDefaultOptions(), onValueChange: handleValueChange, }) openDropdown(container) const option = container.querySelector('.pkt-listbox__option') expect(option).toBeInTheDocument() fireEvent.click(option!) expect(handleValueChange).toHaveBeenCalledWith(['apple']) }) }) describe('Multiple selection', () => { test('selects multiple values', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ multiple: true, options: getDefaultOptions(), onValueChange: handleValueChange, }) openDropdown(container) clickOption(container, 'apple') expect(handleValueChange).toHaveBeenCalledWith(['apple']) clickOption(container, 'banana') expect(handleValueChange).toHaveBeenCalledWith(['apple', 'banana']) }) test('keeps dropdown open after selection in multiple mode', () => { const { container } = createComboboxTest({ multiple: true, options: getDefaultOptions(), }) openDropdown(container) clickOption(container, 'apple') const arrowButton = container.querySelector('.pkt-combobox__input') expect(arrowButton?.getAttribute('aria-expanded')).toBe('true') }) test('renders selected values as tags in multiple mode', () => { const { container } = createComboboxTest({ multiple: true, defaultValue: ['apple', 'banana'], options: getDefaultOptions(), }) const tags = container.querySelectorAll('.pkt-combobox__input .pkt-tag') expect(tags.length).toBe(2) }) test('removes a selected value by clicking its tag close button', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ multiple: true, defaultValue: ['apple', 'banana'], options: getDefaultOptions(), onValueChange: handleValueChange, }) const closeButtons = container.querySelectorAll('.pkt-tag__close-btn') expect(closeButtons.length).toBe(2) fireEvent.click(closeButtons[0]) expect(handleValueChange).toHaveBeenCalledWith(['banana']) }) test('deselects value when toggling already selected option in multiple mode', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ multiple: true, defaultValue: ['apple', 'banana'], options: getDefaultOptions(), onValueChange: handleValueChange, }) openDropdown(container) clickOption(container, 'apple') expect(handleValueChange).toHaveBeenCalledWith(['banana']) }) test('renders tags outside when tagPlacement is outside', () => { const { container } = createComboboxTest({ multiple: true, tagPlacement: 'outside', defaultValue: ['apple', 'banana'], options: getDefaultOptions(), }) const outsideTags = container.querySelector('.pkt-combobox__tags-outside') expect(outsideTags).toBeInTheDocument() const tags = outsideTags?.querySelectorAll('.pkt-tag') expect(tags?.length).toBe(2) }) test('renders value as tag with tagSkinColor', () => { const optionsWithTags: IPktComboboxOption[] = [ { value: 'red', label: 'Red', tagSkinColor: 'red' }, { value: 'blue', label: 'Blue', tagSkinColor: 'blue' }, ] const { container } = createComboboxTest({ multiple: true, defaultValue: ['red'], options: optionsWithTags, }) const tag = container.querySelector('.pkt-tag') expect(tag).toBeInTheDocument() }) test('renders checkboxes for multi-select options', () => { const { container } = createComboboxTest({ multiple: true, defaultValue: ['apple'], options: getDefaultOptions(), }) const checkboxes = container.querySelectorAll('.pkt-listbox__option input[type="checkbox"]') expect(checkboxes.length).toBe(4) const checkedBoxes = container.querySelectorAll('.pkt-listbox__option input[type="checkbox"]:checked') expect(checkedBoxes.length).toBe(1) }) }) describe('Maxlength enforcement', () => { test('prevents selection beyond maxlength', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ multiple: true, maxlength: 2, defaultValue: ['apple', 'banana'], options: getDefaultOptions(), onValueChange: handleValueChange, }) openDropdown(container) clickOption(container, 'date') // Value should not change - cherry was clicked but max is reached expect(getFormInputValue(container)).toBe('apple,banana') }) test('allows deselection when at maxlength', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ multiple: true, maxlength: 2, defaultValue: ['apple', 'banana'], options: getDefaultOptions(), onValueChange: handleValueChange, }) openDropdown(container) clickOption(container, 'apple') expect(handleValueChange).toHaveBeenCalledWith(['banana']) }) test('shows max reached banner', () => { const { container } = createComboboxTest({ multiple: true, maxlength: 2, defaultValue: ['apple', 'banana'], options: getDefaultOptions(), }) const banner = container.querySelector('.pkt-listbox__banner--maximum-reached') expect(banner).toBeInTheDocument() expect(banner?.textContent).toContain('2 av maks 2') }) test('disables unselected options when max is reached', () => { const { container } = createComboboxTest({ multiple: true, maxlength: 2, defaultValue: ['apple', 'banana'], options: getDefaultOptions(), }) const unselectedOption = container.querySelector('[data-value="date"]') expect(unselectedOption?.getAttribute('data-disabled')).toBe('true') expect(unselectedOption?.getAttribute('tabindex')).toBe('-1') }) }) describe('Disabled options', () => { test('does not select disabled options', () => { const handleValueChange = vi.fn() const optionsWithDisabled: IPktComboboxOption[] = [ { value: 'enabled', label: 'Enabled' }, { value: 'disabled', label: 'Disabled', disabled: true }, ] const { container } = createComboboxTest({ options: optionsWithDisabled, onValueChange: handleValueChange, }) openDropdown(container) clickOption(container, 'disabled') expect(handleValueChange).not.toHaveBeenCalled() expect(getFormInputValue(container)).toBe('') }) }) describe('User input (custom values)', () => { test('adds custom value in single-select mode', () => { 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 = 'CustomFruit' fireEvent.change(textInput, { target: { value: 'CustomFruit' } }) fireEvent.keyDown(textInput, { key: 'Enter' }) expect(handleValueChange).toHaveBeenCalledWith(['CustomFruit']) }) test('adds custom value in multiple-select 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 = 'CustomFruit' fireEvent.change(textInput, { target: { value: 'CustomFruit' } }) fireEvent.keyDown(textInput, { key: 'Enter' }) expect(handleValueChange).toHaveBeenCalledWith(['CustomFruit']) }) test('does not add empty custom value', () => { const { container } = createComboboxTest({ allowUserInput: true, }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) textInput.value = '' fireEvent.keyDown(textInput, { key: 'Enter' }) // No user-added option should appear in the listbox const options = container.querySelectorAll('.pkt-listbox__option') const hasUserAdded = Array.from(options).some((opt) => opt.getAttribute('data-value') === '') expect(hasUserAdded).toBe(false) expect(getFormInputValue(container)).toBe('') }) test('does not add whitespace-only custom value', () => { const { container } = createComboboxTest({ allowUserInput: true, }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) textInput.value = ' ' fireEvent.change(textInput, { target: { value: ' ' } }) fireEvent.keyDown(textInput, { key: 'Enter' }) // No user-added option should appear in the listbox const options = container.querySelectorAll('.pkt-listbox__option') const hasUserAdded = Array.from(options).some((opt) => opt.getAttribute('data-value')?.trim() === '') expect(hasUserAdded).toBe(false) expect(getFormInputValue(container)).toBe('') }) test('preserves user-added options when options prop changes', () => { const { container, rerender } = render( , ) // Add a custom value const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) textInput.value = 'CustomFruit' fireEvent.change(textInput, { target: { value: 'CustomFruit' } }) fireEvent.keyDown(textInput, { key: 'Enter' }) expect(getFormInputValue(container)).toBe('CustomFruit') // Change options prop rerender() // User-added value should persist expect(getFormInputValue(container)).toBe('CustomFruit') // User-added option should still appear in the listbox const options = container.querySelectorAll('.pkt-listbox__option') const hasCustom = Array.from(options).some((opt) => opt.getAttribute('data-value') === 'CustomFruit') expect(hasCustom).toBe(true) }) test('removes user-added option when deselected', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ allowUserInput: true, multiple: true, options: getDefaultOptions(), onValueChange: handleValueChange, }) // Add a custom value const textInput = container.querySelector('input[type="text"]') as HTMLInputElement fireEvent.focus(textInput) textInput.value = 'CustomFruit' fireEvent.change(textInput, { target: { value: 'CustomFruit' } }) fireEvent.keyDown(textInput, { key: 'Enter' }) expect(getFormInputValue(container)).toContain('CustomFruit') // Remove by clicking the tag close button const closeButtons = container.querySelectorAll('.pkt-tag__close-btn') const customCloseBtn = closeButtons[closeButtons.length - 1] fireEvent.click(customCloseBtn!) expect(getFormInputValue(container)).not.toContain('CustomFruit') }) }) describe('displayValueAs modes', () => { test('displays value using label by default', () => { const options: IPktComboboxOption[] = [{ value: 'no', label: 'Norway', prefix: 'NO' }] const { container } = createComboboxTest({ defaultValue: 'no', options, }) const valueEl = container.querySelector('.pkt-combobox__value') expect(valueEl?.textContent?.trim()).toBe('Norway') }) test('displays value using value when displayValueAs is value', () => { const options: IPktComboboxOption[] = [{ value: 'no', label: 'Norway', prefix: 'NO' }] const { container } = createComboboxTest({ defaultValue: 'no', displayValueAs: 'value', options, }) const valueEl = container.querySelector('.pkt-combobox__value') expect(valueEl?.textContent?.trim()).toBe('no') }) test('displays prefix and value when displayValueAs is prefixAndValue', () => { const options: IPktComboboxOption[] = [{ value: 'no', label: 'Norway', prefix: 'NO' }] const { container } = createComboboxTest({ defaultValue: 'no', displayValueAs: 'prefixAndValue', options, }) const valueEl = container.querySelector('.pkt-combobox__value') expect(valueEl?.textContent?.trim()).toBe('NO no') }) }) describe('Value change callbacks', () => { test('calls onValueChange on selection', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ options: getDefaultOptions(), onValueChange: handleValueChange, }) openDropdown(container) clickOption(container, 'apple') expect(handleValueChange).toHaveBeenCalledWith(['apple']) }) test('calls onValueChange with array for multiple mode', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ multiple: true, defaultValue: ['apple'], options: getDefaultOptions(), onValueChange: handleValueChange, }) openDropdown(container) clickOption(container, 'banana') expect(handleValueChange).toHaveBeenCalledWith(['apple', 'banana']) }) test('calls onValueChange with empty array when clearing', () => { const handleValueChange = vi.fn() const { container } = createComboboxTest({ defaultValue: 'apple', options: getDefaultOptions(), onValueChange: handleValueChange, }) openDropdown(container) clickOption(container, 'apple') // deselect expect(handleValueChange).toHaveBeenCalledWith([]) }) }) })