import '@testing-library/jest-dom' import { fireEvent, render } from '@testing-library/react' import { axe, toHaveNoViolations } from 'jest-axe' import type { IPktComboboxOption } from 'shared-types/combobox' import { PktCombobox } from './Combobox' import type { IPktCombobox } from './types' expect.extend(toHaveNoViolations) const comboboxId = 'test-combobox' const label = 'Test Combobox' const getDefaultOptions = (): IPktComboboxOption[] => [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, ] const createComboboxTest = (props: Partial = {}) => { const defaultProps: IPktCombobox = { label, id: comboboxId, ...props, } return render() } describe('PktCombobox', () => { describe('Accessibility (axe)', () => { test('basic combobox has no accessibility violations', async () => { const { container } = createComboboxTest() const results = await axe(container) expect(results).toHaveNoViolations() }) test('combobox with options has no accessibility violations', async () => { const { container } = createComboboxTest({ options: getDefaultOptions(), }) const results = await axe(container) expect(results).toHaveNoViolations() }) test('combobox with text input has no accessibility violations', async () => { const { container } = createComboboxTest({ allowUserInput: true, options: getDefaultOptions(), }) const results = await axe(container) expect(results).toHaveNoViolations() }) test('combobox with typeahead has no accessibility violations', async () => { const { container } = createComboboxTest({ typeahead: true, options: getDefaultOptions(), }) const results = await axe(container) expect(results).toHaveNoViolations() }) test('multiple combobox has no accessibility violations', async () => { const { container } = createComboboxTest({ multiple: true, options: getDefaultOptions(), }) // Exclude nested-interactive: the decorative checkbox inside li[role="option"] // is aria-hidden and non-focusable, but axe flags it anyway. // Selection state is conveyed by aria-selected on the option element. const results = await axe(container, { rules: { 'nested-interactive': { enabled: false } }, }) expect(results).toHaveNoViolations() }) test('disabled combobox has no accessibility violations', async () => { const { container } = createComboboxTest({ disabled: true }) const results = await axe(container) expect(results).toHaveNoViolations() }) test('combobox with error state has no accessibility violations', async () => { const { container } = createComboboxTest({ hasError: true, errorMessage: 'Required field', }) const results = await axe(container) expect(results).toHaveNoViolations() }) test('combobox with selected value has no accessibility violations', async () => { const { container } = createComboboxTest({ defaultValue: 'apple', options: getDefaultOptions(), }) const results = await axe(container) expect(results).toHaveNoViolations() }) test('combobox with multiple selected values has no accessibility violations', async () => { const { container } = createComboboxTest({ multiple: true, defaultValue: ['apple', 'banana'], options: getDefaultOptions(), }) // Exclude nested-interactive: decorative checkboxes inside options (see above) const results = await axe(container, { rules: { 'nested-interactive': { enabled: false } }, }) expect(results).toHaveNoViolations() }) }) describe('ARIA attributes', () => { test('select-only combobox has correct ARIA attributes', () => { const { container } = createComboboxTest() const comboboxInput = container.querySelector('.pkt-combobox__input') expect(comboboxInput?.getAttribute('role')).toBe('combobox') expect(comboboxInput?.getAttribute('aria-controls')).toBe(`${comboboxId}-listbox`) expect(comboboxInput?.getAttribute('aria-haspopup')).toBe('listbox') expect(comboboxInput?.getAttribute('aria-expanded')).toBe('false') expect(comboboxInput?.getAttribute('aria-labelledby')).toBe(`${comboboxId}-combobox-label`) }) test('combobox aria-expanded updates when dropdown opens', () => { const { container } = createComboboxTest() const comboboxInput = container.querySelector('.pkt-combobox__input') expect(comboboxInput?.getAttribute('aria-expanded')).toBe('false') fireEvent.click(comboboxInput!) expect(comboboxInput?.getAttribute('aria-expanded')).toBe('true') }) test('text input has correct ARIA attributes for allowUserInput', () => { const { container } = createComboboxTest({ allowUserInput: true }) const textInput = container.querySelector('input[type="text"][role="combobox"]') expect(textInput?.getAttribute('role')).toBe('combobox') expect(textInput?.getAttribute('aria-controls')).toBe(`${comboboxId}-listbox`) expect(textInput?.getAttribute('aria-label')).toBe(label) expect(textInput?.getAttribute('aria-autocomplete')).toBe('list') }) test('text input has correct ARIA attributes for typeahead', () => { const { container } = createComboboxTest({ typeahead: true }) const textInput = container.querySelector('input[type="text"]') expect(textInput?.getAttribute('aria-autocomplete')).toBe('both') }) test('text input sets aria-activedescendant when value is selected', () => { const { container } = createComboboxTest({ allowUserInput: true, defaultValue: 'apple', options: getDefaultOptions(), }) const textInput = container.querySelector('input[type="text"]') expect(textInput?.getAttribute('aria-activedescendant')).toBeTruthy() }) test('text input aria-expanded reflects dropdown state', () => { const { container } = createComboboxTest({ allowUserInput: true }) const textInput = container.querySelector('input[type="text"]') expect(textInput?.getAttribute('aria-expanded')).toBe('false') fireEvent.focus(textInput!) expect(textInput?.getAttribute('aria-expanded')).toBe('true') }) test('listbox has correct id for aria-controls reference', () => { const { container } = createComboboxTest() const listbox = container.querySelector('.pkt-listbox') expect(listbox?.getAttribute('id')).toBe(`${comboboxId}-listbox`) }) test('listbox has role=listbox', () => { const { container } = createComboboxTest({ options: getDefaultOptions() }) const listbox = container.querySelector('.pkt-listbox') expect(listbox?.getAttribute('role')).toBe('listbox') }) test('listbox has aria-label with component label', () => { const { container } = createComboboxTest({ options: getDefaultOptions() }) const listbox = container.querySelector('.pkt-listbox') expect(listbox?.getAttribute('aria-label')).toBe(`Liste: ${label}`) }) test('options have role=option with aria-selected', () => { const { container } = createComboboxTest({ defaultValue: 'apple', options: getDefaultOptions(), }) const options = container.querySelectorAll('.pkt-listbox__option') expect(options[0].getAttribute('role')).toBe('option') expect(options[0].getAttribute('aria-selected')).toBe('true') expect(options[1].getAttribute('aria-selected')).toBe('false') }) }) describe('Keyboard accessibility', () => { test('select-only combobox is focusable when not disabled', () => { const { container } = createComboboxTest() const comboboxInput = container.querySelector('.pkt-combobox__input') as HTMLElement expect(comboboxInput.getAttribute('tabindex')).toBe('0') }) test('select-only combobox is not focusable when disabled', () => { const { container } = createComboboxTest({ disabled: true }) const comboboxInput = container.querySelector('.pkt-combobox__input') as HTMLElement expect(comboboxInput.getAttribute('tabindex')).toBe('-1') }) test('text input is part of tab order', () => { const { container } = createComboboxTest({ allowUserInput: true }) const textInput = container.querySelector('input[type="text"]') as HTMLElement expect(textInput).toBeInTheDocument() // Text inputs are naturally tabbable (no tabindex needed) expect(textInput.hasAttribute('tabindex')).toBe(false) }) test('text input is disabled when component is disabled', () => { const { container } = createComboboxTest({ allowUserInput: true, disabled: true, }) const textInput = container.querySelector('input[type="text"]') as HTMLInputElement expect(textInput).toBeDisabled() }) }) describe('Label association', () => { test('input wrapper label targets text input when allowUserInput', () => { const { container } = createComboboxTest({ allowUserInput: true }) const labelEl = container.querySelector('label') expect(labelEl).toBeInTheDocument() expect(labelEl?.getAttribute('for')).toBe(`${comboboxId}-input`) }) test('input wrapper uses fieldset/legend when no text input (select-only)', () => { const { container } = createComboboxTest() const fieldset = container.querySelector('fieldset') expect(fieldset).toBeInTheDocument() const legend = container.querySelector('legend') expect(legend).toBeInTheDocument() }) }) })