UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

603 lines (478 loc) 22.2 kB
import '@testing-library/jest-dom' import { axe, toHaveNoViolations } from 'jest-axe' import { vi } from 'vitest' expect.extend(toHaveNoViolations) // Import the components import './tabs' import './tabitem' // Import component classes import { PktTabs } from './tabs' const waitForCustomElements = async () => { await Promise.all([ customElements.whenDefined('pkt-tabs'), customElements.whenDefined('pkt-tab-item'), ]) } // Helper function to create tabs markup const createTabs = async (tabsProps = '', tabItemsMarkup = '') => { const container = document.createElement('div') const defaultMarkup = ` <pkt-tab-item index="0" active>Tab 1</pkt-tab-item> <pkt-tab-item index="1">Tab 2</pkt-tab-item> <pkt-tab-item index="2">Tab 3</pkt-tab-item> ` container.innerHTML = ` <pkt-tabs ${tabsProps}> ${tabItemsMarkup || defaultMarkup} </pkt-tabs> ` document.body.appendChild(container) await waitForCustomElements() const tabs = container.querySelector('pkt-tabs') as PktTabs await tabs.updateComplete // Wait for tab items to be updated const tabItems = container.querySelectorAll('pkt-tab-item') await Promise.all( Array.from(tabItems).map( (item) => (item as HTMLElement & { updateComplete: Promise<boolean> }).updateComplete, ), ) return container } // Cleanup after each test afterEach(() => { document.body.innerHTML = '' }) describe('PktTabs', () => { describe('Rendering and basic functionality', () => { test('renders without errors', async () => { const container = await createTabs() const tabs = container.querySelector('pkt-tabs') as PktTabs expect(tabs).toBeInTheDocument() }) test('renders tab items from children', async () => { const container = await createTabs() const tabItems = container.querySelectorAll('pkt-tab-item') expect(tabItems).toHaveLength(3) expect(tabItems[0].textContent?.trim()).toBe('Tab 1') expect(tabItems[1].textContent?.trim()).toBe('Tab 2') expect(tabItems[2].textContent?.trim()).toBe('Tab 3') }) test('applies active class to the active tab', async () => { const container = await createTabs() const tabItems = container.querySelectorAll('pkt-tab-item') const firstTabButton = tabItems[0].querySelector('button, a') const secondTabButton = tabItems[1].querySelector('button, a') expect(firstTabButton).toHaveClass('active') expect(secondTabButton).not.toHaveClass('active') }) test('renders as button when no href is provided', async () => { const container = await createTabs() const firstTabItem = container.querySelector('pkt-tab-item') const button = firstTabItem?.querySelector('button') const link = firstTabItem?.querySelector('a') expect(button).toBeInTheDocument() expect(link).not.toBeInTheDocument() expect(button?.tagName).toBe('BUTTON') }) test('renders as link when href is provided', async () => { const container = await createTabs( '', '<pkt-tab-item index="0" href="/first">First Tab</pkt-tab-item>', ) const firstTabItem = container.querySelector('pkt-tab-item') const link = firstTabItem?.querySelector('a') const button = firstTabItem?.querySelector('button') expect(link).toBeInTheDocument() expect(button).not.toBeInTheDocument() expect(link?.getAttribute('href')).toBe('/first') }) test('applies default arrowNav property correctly', async () => { const container = await createTabs() const tabs = container.querySelector('pkt-tabs') as PktTabs await tabs.updateComplete expect(tabs.arrowNav).toBe(true) expect(tabs.disableArrowNav).toBe(false) }) test('applies custom arrowNav property correctly', async () => { const container = await createTabs('disable-arrow-nav') const tabs = container.querySelector('pkt-tabs') as PktTabs await tabs.updateComplete // When disable-arrow-nav is present, effective arrowNav should be false expect(tabs.disableArrowNav).toBe(true) expect(tabs.arrowNav).toBe(true) // arrowNav prop itself is still true by default }) test('applies disableArrowNav property correctly', async () => { const container = await createTabs('disable-arrow-nav="true"') const tabs = container.querySelector('pkt-tabs') as PktTabs await tabs.updateComplete expect(tabs.disableArrowNav).toBe(true) }) test('applies separator class on tabs root when separator icon is provided', async () => { const container = await createTabs('separator-icon-name="arrow-right"') const tabsRoot = container.querySelector('.pkt-tabs') expect(tabsRoot).toHaveClass('pkt-tabs--with-separator') }) test('uses separator-icon-path when both separator props are provided', async () => { const container = await createTabs( 'separator-icon-name="arrow-right" separator-icon-path="/custom.svg"', ) const separatorImage = container.querySelector('.pkt-tabs__separator img') expect(separatorImage?.getAttribute('src')).toBe('/custom.svg') }) }) describe('Tab item props', () => { test('renders icon when icon prop is provided', async () => { const container = await createTabs( '', '<pkt-tab-item index="0" icon="user">Tab with icon</pkt-tab-item>', ) const icon = container.querySelector('pkt-icon') expect(icon).toBeInTheDocument() expect(icon?.getAttribute('name')).toBe('user') expect(icon).toHaveClass('pkt-icon--small') }) test('renders tag when tag prop is provided', async () => { const container = await createTabs( '', '<pkt-tab-item index="0" tag="New" tag-skin="blue">Tab with tag</pkt-tab-item>', ) const tag = container.querySelector('pkt-tag') expect(tag).toBeInTheDocument() expect(tag?.textContent?.trim()).toBe('New') expect(tag?.getAttribute('skin')).toBe('blue') }) test('applies controls attribute when provided', async () => { const container = await createTabs( '', '<pkt-tab-item index="0" controls="panel-1">Tab 1</pkt-tab-item>', ) const button = container.querySelector('button') expect(button?.getAttribute('aria-controls')).toBe('panel-1') }) }) describe('Click interactions', () => { test('dispatches tab-selected event when tab is clicked', async () => { const container = await createTabs() const tabs = container.querySelector('pkt-tabs') as PktTabs const eventListener = vi.fn() tabs.addEventListener('tab-selected', eventListener) const secondTabItem = container.querySelectorAll('pkt-tab-item')[1] const button = secondTabItem.querySelector('button') button?.click() expect(eventListener).toHaveBeenCalled() expect(eventListener.mock.calls[0][0].detail.index).toBe(1) }) test('dispatches tab-selected event with correct index for each tab', async () => { const container = await createTabs() const tabs = container.querySelector('pkt-tabs') as PktTabs const eventListener = vi.fn() tabs.addEventListener('tab-selected', eventListener) const tabItems = container.querySelectorAll('pkt-tab-item') // Click first tab tabItems[0].querySelector('button')?.click() expect(eventListener).toHaveBeenCalledTimes(1) expect(eventListener.mock.calls[0][0].detail.index).toBe(0) // Click third tab tabItems[2].querySelector('button')?.click() expect(eventListener).toHaveBeenCalledTimes(2) expect(eventListener.mock.calls[1][0].detail.index).toBe(2) }) }) describe('Keyboard navigation', () => { test('handles ArrowRight keyboard navigation', async () => { const container = await createTabs() const tabItems = container.querySelectorAll('pkt-tab-item') const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement const secondButton = tabItems[1].querySelector('button') as HTMLButtonElement firstButton.focus() expect(document.activeElement).toBe(firstButton) // Simulate ArrowRight key const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) firstButton.dispatchEvent(keyEvent) // Wait for focus to change await new Promise((resolve) => setTimeout(resolve, 50)) expect(document.activeElement).toBe(secondButton) }) test('handles ArrowLeft keyboard navigation', async () => { const container = await createTabs() const tabItems = container.querySelectorAll('pkt-tab-item') const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement const secondButton = tabItems[1].querySelector('button') as HTMLButtonElement secondButton.focus() expect(document.activeElement).toBe(secondButton) // Simulate ArrowLeft key const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }) secondButton.dispatchEvent(keyEvent) // Wait for focus to change await new Promise((resolve) => setTimeout(resolve, 50)) expect(document.activeElement).toBe(firstButton) }) test('stays on last tab when navigating past it with ArrowRight', async () => { const container = await createTabs() const tabItems = container.querySelectorAll('pkt-tab-item') const thirdButton = tabItems[2].querySelector('button') as HTMLButtonElement thirdButton.focus() expect(document.activeElement).toBe(thirdButton) // Simulate ArrowRight key const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) thirdButton.dispatchEvent(keyEvent) // Wait await new Promise((resolve) => setTimeout(resolve, 50)) // Should stay on third tab expect(document.activeElement).toBe(thirdButton) }) test('stays on first tab when navigating before it with ArrowLeft', async () => { const container = await createTabs() const tabItems = container.querySelectorAll('pkt-tab-item') const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement firstButton.focus() expect(document.activeElement).toBe(firstButton) // Simulate ArrowLeft key const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }) firstButton.dispatchEvent(keyEvent) // Wait await new Promise((resolve) => setTimeout(resolve, 50)) // Should stay on first tab expect(document.activeElement).toBe(firstButton) }) test('dispatches tab-selected event when Space key is pressed', async () => { const container = await createTabs() const tabs = container.querySelector('pkt-tabs') as PktTabs const eventListener = vi.fn() tabs.addEventListener('tab-selected', eventListener) const secondTabItem = container.querySelectorAll('pkt-tab-item')[1] const button = secondTabItem.querySelector('button') as HTMLButtonElement // Simulate Space key const keyEvent = new KeyboardEvent('keydown', { key: ' ', bubbles: true }) button.dispatchEvent(keyEvent) // Wait await new Promise((resolve) => setTimeout(resolve, 50)) expect(eventListener).toHaveBeenCalled() expect(eventListener.mock.calls[0][0].detail.index).toBe(1) }) test('dispatches tab-selected event when ArrowDown key is pressed', async () => { const container = await createTabs() const tabs = container.querySelector('pkt-tabs') as PktTabs const eventListener = vi.fn() tabs.addEventListener('tab-selected', eventListener) const secondTabItem = container.querySelectorAll('pkt-tab-item')[1] const button = secondTabItem.querySelector('button') as HTMLButtonElement // Simulate ArrowDown key const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) button.dispatchEvent(keyEvent) // Wait await new Promise((resolve) => setTimeout(resolve, 50)) expect(eventListener).toHaveBeenCalled() expect(eventListener.mock.calls[0][0].detail.index).toBe(1) }) test('disables keyboard navigation when arrowNav is false', async () => { const container = await createTabs('disable-arrow-nav') const tabs = container.querySelector('pkt-tabs') as PktTabs await tabs.updateComplete const tabItems = container.querySelectorAll('pkt-tab-item') const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement firstButton.focus() expect(document.activeElement).toBe(firstButton) // Simulate ArrowRight key const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) firstButton.dispatchEvent(keyEvent) // Wait await new Promise((resolve) => setTimeout(resolve, 50)) // Should NOT move to second tab expect(document.activeElement).toBe(firstButton) }) test('disables keyboard navigation when disableArrowNav is true', async () => { const container = await createTabs('disable-arrow-nav="true"') const tabItems = container.querySelectorAll('pkt-tab-item') const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement firstButton.focus() expect(document.activeElement).toBe(firstButton) // Simulate ArrowRight key const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) firstButton.dispatchEvent(keyEvent) // Wait await new Promise((resolve) => setTimeout(resolve, 50)) // Should NOT move to second tab expect(document.activeElement).toBe(firstButton) }) test('disableArrowNav overrides arrowNav when both are set', async () => { const container = await createTabs('arrow-nav="true" disable-arrow-nav="true"') const tabItems = container.querySelectorAll('pkt-tab-item') const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement firstButton.focus() // Simulate ArrowRight key const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) firstButton.dispatchEvent(keyEvent) // Wait await new Promise((resolve) => setTimeout(resolve, 50)) // Should NOT move to second tab (disableArrowNav overrides arrowNav) expect(document.activeElement).toBe(firstButton) }) test('skips disabled tabs during ArrowRight navigation', async () => { const container = await createTabs( '', ` <pkt-tab-item index="0" active>Tab 1</pkt-tab-item> <pkt-tab-item index="1" disabled>Tab 2</pkt-tab-item> <pkt-tab-item index="2">Tab 3</pkt-tab-item> `, ) const tabItems = container.querySelectorAll('pkt-tab-item') const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement const thirdButton = tabItems[2].querySelector('button') as HTMLButtonElement firstButton.focus() const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) firstButton.dispatchEvent(keyEvent) await new Promise((resolve) => setTimeout(resolve, 50)) expect(document.activeElement).toBe(thirdButton) }) }) describe('Disabled tabs', () => { test('does not dispatch tab-selected for disabled button tab', async () => { const container = await createTabs( '', ` <pkt-tab-item index="0" active>Tab 1</pkt-tab-item> <pkt-tab-item index="1" disabled>Tab 2</pkt-tab-item> `, ) const tabs = container.querySelector('pkt-tabs') as PktTabs const eventListener = vi.fn() tabs.addEventListener('tab-selected', eventListener) const disabledButton = container.querySelectorAll('pkt-tab-item')[1].querySelector('button') disabledButton?.click() expect(disabledButton).toHaveAttribute('disabled') expect(eventListener).not.toHaveBeenCalled() }) test('disabled link tab has aria-disabled and does not dispatch event', async () => { const container = await createTabs( '', ` <pkt-tab-item index="0" href="/enabled" active>Enabled</pkt-tab-item> <pkt-tab-item index="1" href="/disabled" disabled>Disabled</pkt-tab-item> `, ) const tabs = container.querySelector('pkt-tabs') as PktTabs const eventListener = vi.fn() tabs.addEventListener('tab-selected', eventListener) const disabledLink = container.querySelectorAll('pkt-tab-item')[1].querySelector('a') as HTMLAnchorElement disabledLink.click() disabledLink.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) expect(disabledLink).toHaveAttribute('aria-disabled', 'true') expect(disabledLink).toHaveAttribute('tabindex', '-1') expect(disabledLink).not.toHaveAttribute('href') expect(eventListener).not.toHaveBeenCalled() }) test('disabled active tab is not exposed as selected', async () => { const container = await createTabs( '', ` <pkt-tab-item index="0" active disabled>Disabled active</pkt-tab-item> <pkt-tab-item index="1">Enabled</pkt-tab-item> `, ) const disabledButton = container.querySelector('pkt-tab-item button') expect(disabledButton).toHaveAttribute('aria-selected', 'false') }) }) describe('Accessibility', () => { test('should not have any accessibility violations', async () => { const container = await createTabs() const results = await axe(container) expect(results).toHaveNoViolations() }) test('sets correct ARIA attributes on buttons when arrowNav is true', async () => { const container = await createTabs() const tabItems = container.querySelectorAll('pkt-tab-item') const firstButton = tabItems[0].querySelector('button') const secondButton = tabItems[1].querySelector('button') expect(firstButton).toHaveAttribute('role', 'tab') expect(firstButton).toHaveAttribute('aria-selected', 'true') expect(secondButton).toHaveAttribute('role', 'tab') expect(secondButton).toHaveAttribute('aria-selected', 'false') }) test('does not set tab role or aria-selected on links when arrowNav is false', async () => { const container = await createTabs( 'disable-arrow-nav', ` <pkt-tab-item index="0" href="/" active>Home</pkt-tab-item> <pkt-tab-item index="1" href="/about">About</pkt-tab-item> `, ) await new Promise((resolve) => setTimeout(resolve, 50)) const tabItems = container.querySelectorAll('pkt-tab-item') const homeLink = tabItems[0].querySelector('a') const aboutLink = tabItems[1].querySelector('a') expect(homeLink).not.toHaveAttribute('role') expect(homeLink).not.toHaveAttribute('aria-selected') expect(aboutLink).not.toHaveAttribute('role') expect(aboutLink).not.toHaveAttribute('aria-selected') }) test('works with href links when arrowNav is false (no WCAG violations)', async () => { const container = await createTabs( 'disable-arrow-nav', ` <pkt-tab-item index="0" href="/" active>Home</pkt-tab-item> <pkt-tab-item index="1" href="/about">About</pkt-tab-item> `, ) await new Promise((resolve) => setTimeout(resolve, 50)) const results = await axe(container) expect(results).toHaveNoViolations() }) test('sets role="tablist" when arrowNav is true', async () => { const container = await createTabs() const tabs = container.querySelector('pkt-tabs') as PktTabs const tabList = tabs.querySelector('.pkt-tabs__list') expect(tabList).toHaveAttribute('role', 'tablist') }) test('sets role="navigation" when arrowNav is false', async () => { const container = await createTabs('disable-arrow-nav') const tabs = container.querySelector('pkt-tabs') as PktTabs await tabs.updateComplete const tabList = tabs.querySelector('.pkt-tabs__list') expect(tabList).toHaveAttribute('role', 'navigation') }) test('keeps tab semantics when separators are enabled', async () => { const container = await createTabs('separator-icon-name="arrow-right"') const tabList = container.querySelector('.pkt-tabs__list') const buttons = container.querySelectorAll('pkt-tab-item button') expect(tabList).toHaveAttribute('role', 'tablist') buttons.forEach((button) => { expect(button).toHaveAttribute('role', 'tab') }) }) test('renders decorative separators in DOM', async () => { const container = await createTabs('separator-icon-name="arrow-right"') expect(container.querySelectorAll('.pkt-tabs__separator')).toHaveLength(2) }) }) describe('Multiple tab items', () => { test('works with many tab items', async () => { const container = await createTabs( '', ` <pkt-tab-item index="0" active>Tab 1</pkt-tab-item> <pkt-tab-item index="1">Tab 2</pkt-tab-item> <pkt-tab-item index="2">Tab 3</pkt-tab-item> <pkt-tab-item index="3">Tab 4</pkt-tab-item> <pkt-tab-item index="4">Tab 5</pkt-tab-item> `, ) const tabItems = container.querySelectorAll('pkt-tab-item') expect(tabItems).toHaveLength(5) // Test navigation from first to last const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement firstButton.focus() // Navigate through all tabs for (let i = 0; i < 4; i++) { const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) ;(document.activeElement as HTMLElement).dispatchEvent(keyEvent) await new Promise((resolve) => setTimeout(resolve, 50)) } const lastButton = tabItems[4].querySelector('button') as HTMLButtonElement expect(document.activeElement).toBe(lastButton) }) }) })