@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
603 lines (478 loc) • 22.2 kB
text/typescript
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)
})
})
})