@oslokommune/punkt-elements 13.8.0 → 13.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/index.d.ts +72 -0
- package/dist/pkt-index.cjs +3 -3
- package/dist/pkt-index.js +22 -19
- package/dist/pkt-tabs.cjs +1 -0
- package/dist/pkt-tabs.js +7 -0
- package/dist/tabitem-D5zyipN1.cjs +65 -0
- package/dist/tabitem-NV2fzs_-.js +332 -0
- package/dist/tabs.d.ts +1 -0
- package/package.json +4 -3
- package/src/components/index.ts +3 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabitem.ts +117 -0
- package/src/components/tabs/tabs-context.ts +25 -0
- package/src/components/tabs/tabs.test.ts +495 -0
- package/src/components/tabs/tabs.ts +103 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oslokommune/punkt-elements",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.9.1",
|
|
4
4
|
"description": "Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo",
|
|
5
5
|
"homepage": "https://punkt.oslo.kommune.no",
|
|
6
6
|
"author": "Team Designsystem, Oslo Origo",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@date-fns/tz": "^1.2.0",
|
|
31
31
|
"@lit-labs/router": "^0.1.3",
|
|
32
|
+
"@lit/context": "^1.1.6",
|
|
32
33
|
"@types/node": "^20.17.30",
|
|
33
34
|
"date-fns": "^4.1.0",
|
|
34
35
|
"dialog-polyfill": "^0.5.6",
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
"@babel/preset-env": "^7.28.3",
|
|
44
45
|
"@babel/preset-typescript": "^7.25.9",
|
|
45
46
|
"@oslokommune/punkt-assets": "^13.6.3",
|
|
46
|
-
"@oslokommune/punkt-css": "^13.
|
|
47
|
+
"@oslokommune/punkt-css": "^13.9.1",
|
|
47
48
|
"@testing-library/jest-dom": "^6.6.3",
|
|
48
49
|
"@typescript-eslint/eslint-plugin": "^8.46.0",
|
|
49
50
|
"@typescript-eslint/parser": "^8.46.0",
|
|
@@ -80,5 +81,5 @@
|
|
|
80
81
|
"url": "https://github.com/oslokommune/punkt/issues"
|
|
81
82
|
},
|
|
82
83
|
"license": "MIT",
|
|
83
|
-
"gitHead": "
|
|
84
|
+
"gitHead": "44dcfeeeecbe7f3d5404f3d77f2de46689ccb80e"
|
|
84
85
|
}
|
package/src/components/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export { PktRadioButton } from '@/components/radiobutton'
|
|
|
25
25
|
// TODO: Avklar om RadioButton kan eksporteres som *kun* PktRadiobutton
|
|
26
26
|
export { PktRadioButton as PktRadiobutton } from '@/components/radiobutton'
|
|
27
27
|
export { PktTag } from '@/components/tag'
|
|
28
|
+
export { PktTabs, PktTabItem } from '@/components/tabs'
|
|
28
29
|
export { PktTextarea } from '@/components/textarea'
|
|
29
30
|
export { PktTextinput } from '@/components/textinput'
|
|
30
31
|
export { PktSelect } from '@/components/select'
|
|
@@ -62,6 +63,8 @@ export type { IPktHeading, TPktHeadingSize, TPktHeadingLevel } from '@/component
|
|
|
62
63
|
|
|
63
64
|
export type { TTagSkin, TTagType } from '@/components/tag'
|
|
64
65
|
|
|
66
|
+
export type { IPktTabs, IPktTabItem, TSkin as TTabItemSkin } from '@/components/tabs'
|
|
67
|
+
|
|
65
68
|
export type { TSelectOption } from '@/components/select'
|
|
66
69
|
|
|
67
70
|
export type { IPktBackLink } from '@/components/backlink'
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import PktTabs from './tabs'
|
|
2
|
+
import type { IPktTabs } from './tabs'
|
|
3
|
+
import PktTabItem from './tabitem'
|
|
4
|
+
import type { IPktTabItem, TSkin } from './tabitem'
|
|
5
|
+
|
|
6
|
+
export { PktTabs, PktTabItem }
|
|
7
|
+
export type { IPktTabs, IPktTabItem, TSkin }
|
|
8
|
+
export default PktTabs
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { PktElement } from '@/base-elements/element'
|
|
2
|
+
import { PktSlotController } from '@/controllers/pkt-slot-controller'
|
|
3
|
+
import { html } from 'lit'
|
|
4
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
5
|
+
import { consume } from '@lit/context'
|
|
6
|
+
import { createRef, Ref, ref } from 'lit/directives/ref.js'
|
|
7
|
+
import { ifDefined } from 'lit/directives/if-defined.js'
|
|
8
|
+
import { tabsContext, type TabsContext } from './tabs-context'
|
|
9
|
+
|
|
10
|
+
export type TSkin = 'blue' | 'green' | 'red' | 'beige' | 'yellow' | 'grey' | 'gray' | 'blue-light'
|
|
11
|
+
|
|
12
|
+
export interface IPktTabItem {
|
|
13
|
+
active?: boolean
|
|
14
|
+
href?: string
|
|
15
|
+
icon?: string
|
|
16
|
+
controls?: string
|
|
17
|
+
tag?: string
|
|
18
|
+
tagSkin?: TSkin
|
|
19
|
+
index?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@customElement('pkt-tab-item')
|
|
23
|
+
export class PktTabItem extends PktElement<IPktTabItem> implements IPktTabItem {
|
|
24
|
+
@property({ type: Boolean, reflect: true }) active: boolean = false
|
|
25
|
+
@property({ type: String, reflect: true }) href: string = ''
|
|
26
|
+
@property({ type: String, reflect: true }) icon: string = ''
|
|
27
|
+
@property({ type: String, reflect: true }) controls: string = ''
|
|
28
|
+
@property({ type: String, reflect: true }) tag: string = ''
|
|
29
|
+
@property({ type: String, reflect: true, attribute: 'tag-skin' }) tagSkin: TSkin = 'blue'
|
|
30
|
+
@property({ type: Number, reflect: true }) index: number = 0
|
|
31
|
+
|
|
32
|
+
// Consume context from parent pkt-tabs
|
|
33
|
+
@consume({ context: tabsContext, subscribe: true })
|
|
34
|
+
@property({ attribute: false })
|
|
35
|
+
context?: TabsContext
|
|
36
|
+
|
|
37
|
+
elementRef: Ref<HTMLAnchorElement | HTMLButtonElement> = createRef()
|
|
38
|
+
defaultSlot: Ref<HTMLElement> = createRef()
|
|
39
|
+
slotController!: PktSlotController
|
|
40
|
+
|
|
41
|
+
constructor() {
|
|
42
|
+
super()
|
|
43
|
+
this.slotController = new PktSlotController(this, this.defaultSlot)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
connectedCallback() {
|
|
47
|
+
super.connectedCallback()
|
|
48
|
+
// Wait for element to be fully initialized
|
|
49
|
+
this.updateComplete.then(() => {
|
|
50
|
+
if (this.elementRef.value && this.context) {
|
|
51
|
+
this.context.registerTab(this.elementRef.value, this.index)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private handleClick() {
|
|
57
|
+
if (this.context) {
|
|
58
|
+
this.context.handleClick(this.index)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private handleKeyUp(event: KeyboardEvent) {
|
|
63
|
+
if (this.context) {
|
|
64
|
+
this.context.handleKeyUp(event, this.index)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
render() {
|
|
69
|
+
const useArrowNav = this.context?.useArrowNav ?? true
|
|
70
|
+
const commonClasses = this.active ? 'active' : ''
|
|
71
|
+
const role = useArrowNav ? 'tab' : undefined
|
|
72
|
+
const ariaSelected = useArrowNav ? this.active : undefined
|
|
73
|
+
const tabIndex = this.active || !useArrowNav ? undefined : -1
|
|
74
|
+
|
|
75
|
+
const content = html`
|
|
76
|
+
${this.icon ? html`<pkt-icon name=${this.icon} class="pkt-icon--small"></pkt-icon>` : ''}
|
|
77
|
+
<span ${ref(this.defaultSlot)}></span>
|
|
78
|
+
${this.tag ? html`<pkt-tag skin=${this.tagSkin} size="small">${this.tag}</pkt-tag>` : ''}
|
|
79
|
+
`
|
|
80
|
+
|
|
81
|
+
if (this.href) {
|
|
82
|
+
return html`
|
|
83
|
+
<a
|
|
84
|
+
${ref(this.elementRef)}
|
|
85
|
+
href=${this.href}
|
|
86
|
+
class="pkt-tabs__link ${commonClasses}"
|
|
87
|
+
role=${ifDefined(role)}
|
|
88
|
+
aria-selected=${ifDefined(ariaSelected)}
|
|
89
|
+
aria-controls=${ifDefined(this.controls || undefined)}
|
|
90
|
+
tabindex=${ifDefined(tabIndex)}
|
|
91
|
+
@click=${this.handleClick}
|
|
92
|
+
@keyup=${this.handleKeyUp}
|
|
93
|
+
>
|
|
94
|
+
${content}
|
|
95
|
+
</a>
|
|
96
|
+
`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return html`
|
|
100
|
+
<button
|
|
101
|
+
${ref(this.elementRef)}
|
|
102
|
+
type="button"
|
|
103
|
+
class="pkt-tabs__button pkt-link-button ${commonClasses}"
|
|
104
|
+
role=${ifDefined(role)}
|
|
105
|
+
aria-selected=${ifDefined(ariaSelected)}
|
|
106
|
+
aria-controls=${ifDefined(this.controls || undefined)}
|
|
107
|
+
tabindex=${ifDefined(tabIndex)}
|
|
108
|
+
@click=${this.handleClick}
|
|
109
|
+
@keyup=${this.handleKeyUp}
|
|
110
|
+
>
|
|
111
|
+
${content}
|
|
112
|
+
</button>
|
|
113
|
+
`
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default PktTabItem
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createContext } from '@lit/context'
|
|
2
|
+
|
|
3
|
+
export interface TabsContext {
|
|
4
|
+
/**
|
|
5
|
+
* Whether arrow navigation is enabled (computed from arrowNav && !disableArrowNav)
|
|
6
|
+
*/
|
|
7
|
+
useArrowNav: boolean
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Register a tab item with the parent tabs container
|
|
11
|
+
*/
|
|
12
|
+
registerTab: (element: HTMLElement, index: number) => void
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Handle click event from a tab item
|
|
16
|
+
*/
|
|
17
|
+
handleClick: (index: number) => void
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Handle keyboard event from a tab item
|
|
21
|
+
*/
|
|
22
|
+
handleKeyUp: (event: KeyboardEvent, index: number) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const tabsContext = createContext<TabsContext>(Symbol('pkt-tabs-context'))
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe'
|
|
3
|
+
import { vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations)
|
|
6
|
+
|
|
7
|
+
// Import the components
|
|
8
|
+
import './tabs'
|
|
9
|
+
import './tabitem'
|
|
10
|
+
|
|
11
|
+
// Import component classes
|
|
12
|
+
import { PktTabs } from './tabs'
|
|
13
|
+
|
|
14
|
+
const waitForCustomElements = async () => {
|
|
15
|
+
await Promise.all([
|
|
16
|
+
customElements.whenDefined('pkt-tabs'),
|
|
17
|
+
customElements.whenDefined('pkt-tab-item'),
|
|
18
|
+
])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Helper function to create tabs markup
|
|
22
|
+
const createTabs = async (tabsProps = '', tabItemsMarkup = '') => {
|
|
23
|
+
const container = document.createElement('div')
|
|
24
|
+
const defaultMarkup = `
|
|
25
|
+
<pkt-tab-item index="0" active>Tab 1</pkt-tab-item>
|
|
26
|
+
<pkt-tab-item index="1">Tab 2</pkt-tab-item>
|
|
27
|
+
<pkt-tab-item index="2">Tab 3</pkt-tab-item>
|
|
28
|
+
`
|
|
29
|
+
container.innerHTML = `
|
|
30
|
+
<pkt-tabs ${tabsProps}>
|
|
31
|
+
${tabItemsMarkup || defaultMarkup}
|
|
32
|
+
</pkt-tabs>
|
|
33
|
+
`
|
|
34
|
+
document.body.appendChild(container)
|
|
35
|
+
await waitForCustomElements()
|
|
36
|
+
|
|
37
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
38
|
+
await tabs.updateComplete
|
|
39
|
+
|
|
40
|
+
// Wait for tab items to be updated
|
|
41
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
42
|
+
await Promise.all(
|
|
43
|
+
Array.from(tabItems).map(
|
|
44
|
+
(item) => (item as HTMLElement & { updateComplete: Promise<boolean> }).updateComplete,
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return container
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Cleanup after each test
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
document.body.innerHTML = ''
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('PktTabs', () => {
|
|
57
|
+
describe('Rendering and basic functionality', () => {
|
|
58
|
+
test('renders without errors', async () => {
|
|
59
|
+
const container = await createTabs()
|
|
60
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
61
|
+
expect(tabs).toBeInTheDocument()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('renders tab items from children', async () => {
|
|
65
|
+
const container = await createTabs()
|
|
66
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
67
|
+
expect(tabItems).toHaveLength(3)
|
|
68
|
+
expect(tabItems[0].textContent?.trim()).toBe('Tab 1')
|
|
69
|
+
expect(tabItems[1].textContent?.trim()).toBe('Tab 2')
|
|
70
|
+
expect(tabItems[2].textContent?.trim()).toBe('Tab 3')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('applies active class to the active tab', async () => {
|
|
74
|
+
const container = await createTabs()
|
|
75
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
76
|
+
|
|
77
|
+
const firstTabButton = tabItems[0].querySelector('button, a')
|
|
78
|
+
const secondTabButton = tabItems[1].querySelector('button, a')
|
|
79
|
+
|
|
80
|
+
expect(firstTabButton).toHaveClass('active')
|
|
81
|
+
expect(secondTabButton).not.toHaveClass('active')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('renders as button when no href is provided', async () => {
|
|
85
|
+
const container = await createTabs()
|
|
86
|
+
const firstTabItem = container.querySelector('pkt-tab-item')
|
|
87
|
+
const button = firstTabItem?.querySelector('button')
|
|
88
|
+
const link = firstTabItem?.querySelector('a')
|
|
89
|
+
|
|
90
|
+
expect(button).toBeInTheDocument()
|
|
91
|
+
expect(link).not.toBeInTheDocument()
|
|
92
|
+
expect(button?.tagName).toBe('BUTTON')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('renders as link when href is provided', async () => {
|
|
96
|
+
const container = await createTabs(
|
|
97
|
+
'',
|
|
98
|
+
'<pkt-tab-item index="0" href="/first">First Tab</pkt-tab-item>',
|
|
99
|
+
)
|
|
100
|
+
const firstTabItem = container.querySelector('pkt-tab-item')
|
|
101
|
+
const link = firstTabItem?.querySelector('a')
|
|
102
|
+
const button = firstTabItem?.querySelector('button')
|
|
103
|
+
|
|
104
|
+
expect(link).toBeInTheDocument()
|
|
105
|
+
expect(button).not.toBeInTheDocument()
|
|
106
|
+
expect(link?.getAttribute('href')).toBe('/first')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('applies default arrowNav property correctly', async () => {
|
|
110
|
+
const container = await createTabs()
|
|
111
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
112
|
+
await tabs.updateComplete
|
|
113
|
+
|
|
114
|
+
expect(tabs.arrowNav).toBe(true)
|
|
115
|
+
expect(tabs.disableArrowNav).toBe(false)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('applies custom arrowNav property correctly', async () => {
|
|
119
|
+
const container = await createTabs('disable-arrow-nav')
|
|
120
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
121
|
+
await tabs.updateComplete
|
|
122
|
+
|
|
123
|
+
// When disable-arrow-nav is present, effective arrowNav should be false
|
|
124
|
+
expect(tabs.disableArrowNav).toBe(true)
|
|
125
|
+
expect(tabs.arrowNav).toBe(true) // arrowNav prop itself is still true by default
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('applies disableArrowNav property correctly', async () => {
|
|
129
|
+
const container = await createTabs('disable-arrow-nav="true"')
|
|
130
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
131
|
+
await tabs.updateComplete
|
|
132
|
+
|
|
133
|
+
expect(tabs.disableArrowNav).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('Tab item props', () => {
|
|
138
|
+
test('renders icon when icon prop is provided', async () => {
|
|
139
|
+
const container = await createTabs(
|
|
140
|
+
'',
|
|
141
|
+
'<pkt-tab-item index="0" icon="user">Tab with icon</pkt-tab-item>',
|
|
142
|
+
)
|
|
143
|
+
const icon = container.querySelector('pkt-icon')
|
|
144
|
+
expect(icon).toBeInTheDocument()
|
|
145
|
+
expect(icon?.getAttribute('name')).toBe('user')
|
|
146
|
+
expect(icon).toHaveClass('pkt-icon--small')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('renders tag when tag prop is provided', async () => {
|
|
150
|
+
const container = await createTabs(
|
|
151
|
+
'',
|
|
152
|
+
'<pkt-tab-item index="0" tag="New" tag-skin="blue">Tab with tag</pkt-tab-item>',
|
|
153
|
+
)
|
|
154
|
+
const tag = container.querySelector('pkt-tag')
|
|
155
|
+
expect(tag).toBeInTheDocument()
|
|
156
|
+
expect(tag?.textContent?.trim()).toBe('New')
|
|
157
|
+
expect(tag?.getAttribute('skin')).toBe('blue')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('applies controls attribute when provided', async () => {
|
|
161
|
+
const container = await createTabs(
|
|
162
|
+
'',
|
|
163
|
+
'<pkt-tab-item index="0" controls="panel-1">Tab 1</pkt-tab-item>',
|
|
164
|
+
)
|
|
165
|
+
const button = container.querySelector('button')
|
|
166
|
+
expect(button?.getAttribute('aria-controls')).toBe('panel-1')
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('Click interactions', () => {
|
|
171
|
+
test('dispatches tab-selected event when tab is clicked', async () => {
|
|
172
|
+
const container = await createTabs()
|
|
173
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
174
|
+
|
|
175
|
+
const eventListener = vi.fn()
|
|
176
|
+
tabs.addEventListener('tab-selected', eventListener)
|
|
177
|
+
|
|
178
|
+
const secondTabItem = container.querySelectorAll('pkt-tab-item')[1]
|
|
179
|
+
const button = secondTabItem.querySelector('button')
|
|
180
|
+
button?.click()
|
|
181
|
+
|
|
182
|
+
expect(eventListener).toHaveBeenCalled()
|
|
183
|
+
expect(eventListener.mock.calls[0][0].detail.index).toBe(1)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('dispatches tab-selected event with correct index for each tab', async () => {
|
|
187
|
+
const container = await createTabs()
|
|
188
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
189
|
+
|
|
190
|
+
const eventListener = vi.fn()
|
|
191
|
+
tabs.addEventListener('tab-selected', eventListener)
|
|
192
|
+
|
|
193
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
194
|
+
|
|
195
|
+
// Click first tab
|
|
196
|
+
tabItems[0].querySelector('button')?.click()
|
|
197
|
+
expect(eventListener).toHaveBeenCalledTimes(1)
|
|
198
|
+
expect(eventListener.mock.calls[0][0].detail.index).toBe(0)
|
|
199
|
+
|
|
200
|
+
// Click third tab
|
|
201
|
+
tabItems[2].querySelector('button')?.click()
|
|
202
|
+
expect(eventListener).toHaveBeenCalledTimes(2)
|
|
203
|
+
expect(eventListener.mock.calls[1][0].detail.index).toBe(2)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('Keyboard navigation', () => {
|
|
208
|
+
test('handles ArrowRight keyboard navigation', async () => {
|
|
209
|
+
const container = await createTabs()
|
|
210
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
211
|
+
|
|
212
|
+
const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement
|
|
213
|
+
const secondButton = tabItems[1].querySelector('button') as HTMLButtonElement
|
|
214
|
+
|
|
215
|
+
firstButton.focus()
|
|
216
|
+
expect(document.activeElement).toBe(firstButton)
|
|
217
|
+
|
|
218
|
+
// Simulate ArrowRight key
|
|
219
|
+
const keyEvent = new KeyboardEvent('keyup', { code: 'ArrowRight', bubbles: true })
|
|
220
|
+
firstButton.dispatchEvent(keyEvent)
|
|
221
|
+
|
|
222
|
+
// Wait for focus to change
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
224
|
+
|
|
225
|
+
expect(document.activeElement).toBe(secondButton)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('handles ArrowLeft keyboard navigation', async () => {
|
|
229
|
+
const container = await createTabs()
|
|
230
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
231
|
+
|
|
232
|
+
const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement
|
|
233
|
+
const secondButton = tabItems[1].querySelector('button') as HTMLButtonElement
|
|
234
|
+
|
|
235
|
+
secondButton.focus()
|
|
236
|
+
expect(document.activeElement).toBe(secondButton)
|
|
237
|
+
|
|
238
|
+
// Simulate ArrowLeft key
|
|
239
|
+
const keyEvent = new KeyboardEvent('keyup', { code: 'ArrowLeft', bubbles: true })
|
|
240
|
+
secondButton.dispatchEvent(keyEvent)
|
|
241
|
+
|
|
242
|
+
// Wait for focus to change
|
|
243
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
244
|
+
|
|
245
|
+
expect(document.activeElement).toBe(firstButton)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('stays on last tab when navigating past it with ArrowRight', async () => {
|
|
249
|
+
const container = await createTabs()
|
|
250
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
251
|
+
|
|
252
|
+
const thirdButton = tabItems[2].querySelector('button') as HTMLButtonElement
|
|
253
|
+
|
|
254
|
+
thirdButton.focus()
|
|
255
|
+
expect(document.activeElement).toBe(thirdButton)
|
|
256
|
+
|
|
257
|
+
// Simulate ArrowRight key
|
|
258
|
+
const keyEvent = new KeyboardEvent('keyup', { code: 'ArrowRight', bubbles: true })
|
|
259
|
+
thirdButton.dispatchEvent(keyEvent)
|
|
260
|
+
|
|
261
|
+
// Wait
|
|
262
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
263
|
+
|
|
264
|
+
// Should stay on third tab
|
|
265
|
+
expect(document.activeElement).toBe(thirdButton)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('stays on first tab when navigating before it with ArrowLeft', async () => {
|
|
269
|
+
const container = await createTabs()
|
|
270
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
271
|
+
|
|
272
|
+
const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement
|
|
273
|
+
|
|
274
|
+
firstButton.focus()
|
|
275
|
+
expect(document.activeElement).toBe(firstButton)
|
|
276
|
+
|
|
277
|
+
// Simulate ArrowLeft key
|
|
278
|
+
const keyEvent = new KeyboardEvent('keyup', { code: 'ArrowLeft', bubbles: true })
|
|
279
|
+
firstButton.dispatchEvent(keyEvent)
|
|
280
|
+
|
|
281
|
+
// Wait
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
283
|
+
|
|
284
|
+
// Should stay on first tab
|
|
285
|
+
expect(document.activeElement).toBe(firstButton)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('dispatches tab-selected event when Space key is pressed', async () => {
|
|
289
|
+
const container = await createTabs()
|
|
290
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
291
|
+
|
|
292
|
+
const eventListener = vi.fn()
|
|
293
|
+
tabs.addEventListener('tab-selected', eventListener)
|
|
294
|
+
|
|
295
|
+
const secondTabItem = container.querySelectorAll('pkt-tab-item')[1]
|
|
296
|
+
const button = secondTabItem.querySelector('button') as HTMLButtonElement
|
|
297
|
+
|
|
298
|
+
// Simulate Space key
|
|
299
|
+
const keyEvent = new KeyboardEvent('keyup', { code: 'Space', bubbles: true })
|
|
300
|
+
button.dispatchEvent(keyEvent)
|
|
301
|
+
|
|
302
|
+
// Wait
|
|
303
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
304
|
+
|
|
305
|
+
expect(eventListener).toHaveBeenCalled()
|
|
306
|
+
expect(eventListener.mock.calls[0][0].detail.index).toBe(1)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('dispatches tab-selected event when ArrowDown key is pressed', async () => {
|
|
310
|
+
const container = await createTabs()
|
|
311
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
312
|
+
|
|
313
|
+
const eventListener = vi.fn()
|
|
314
|
+
tabs.addEventListener('tab-selected', eventListener)
|
|
315
|
+
|
|
316
|
+
const secondTabItem = container.querySelectorAll('pkt-tab-item')[1]
|
|
317
|
+
const button = secondTabItem.querySelector('button') as HTMLButtonElement
|
|
318
|
+
|
|
319
|
+
// Simulate ArrowDown key
|
|
320
|
+
const keyEvent = new KeyboardEvent('keyup', { code: 'ArrowDown', bubbles: true })
|
|
321
|
+
button.dispatchEvent(keyEvent)
|
|
322
|
+
|
|
323
|
+
// Wait
|
|
324
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
325
|
+
|
|
326
|
+
expect(eventListener).toHaveBeenCalled()
|
|
327
|
+
expect(eventListener.mock.calls[0][0].detail.index).toBe(1)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('disables keyboard navigation when arrowNav is false', async () => {
|
|
331
|
+
const container = await createTabs('disable-arrow-nav')
|
|
332
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
333
|
+
await tabs.updateComplete
|
|
334
|
+
|
|
335
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
336
|
+
|
|
337
|
+
const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement
|
|
338
|
+
|
|
339
|
+
firstButton.focus()
|
|
340
|
+
expect(document.activeElement).toBe(firstButton)
|
|
341
|
+
|
|
342
|
+
// Simulate ArrowRight key
|
|
343
|
+
const keyEvent = new KeyboardEvent('keyup', { code: 'ArrowRight', bubbles: true })
|
|
344
|
+
firstButton.dispatchEvent(keyEvent)
|
|
345
|
+
|
|
346
|
+
// Wait
|
|
347
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
348
|
+
|
|
349
|
+
// Should NOT move to second tab
|
|
350
|
+
expect(document.activeElement).toBe(firstButton)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('disables keyboard navigation when disableArrowNav is true', async () => {
|
|
354
|
+
const container = await createTabs('disable-arrow-nav="true"')
|
|
355
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
356
|
+
|
|
357
|
+
const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement
|
|
358
|
+
|
|
359
|
+
firstButton.focus()
|
|
360
|
+
expect(document.activeElement).toBe(firstButton)
|
|
361
|
+
|
|
362
|
+
// Simulate ArrowRight key
|
|
363
|
+
const keyEvent = new KeyboardEvent('keyup', { code: 'ArrowRight', bubbles: true })
|
|
364
|
+
firstButton.dispatchEvent(keyEvent)
|
|
365
|
+
|
|
366
|
+
// Wait
|
|
367
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
368
|
+
|
|
369
|
+
// Should NOT move to second tab
|
|
370
|
+
expect(document.activeElement).toBe(firstButton)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('disableArrowNav overrides arrowNav when both are set', async () => {
|
|
374
|
+
const container = await createTabs('arrow-nav="true" disable-arrow-nav="true"')
|
|
375
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
376
|
+
|
|
377
|
+
const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement
|
|
378
|
+
|
|
379
|
+
firstButton.focus()
|
|
380
|
+
|
|
381
|
+
// Simulate ArrowRight key
|
|
382
|
+
const keyEvent = new KeyboardEvent('keyup', { code: 'ArrowRight', bubbles: true })
|
|
383
|
+
firstButton.dispatchEvent(keyEvent)
|
|
384
|
+
|
|
385
|
+
// Wait
|
|
386
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
387
|
+
|
|
388
|
+
// Should NOT move to second tab (disableArrowNav overrides arrowNav)
|
|
389
|
+
expect(document.activeElement).toBe(firstButton)
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
describe('Accessibility', () => {
|
|
394
|
+
test('should not have any accessibility violations', async () => {
|
|
395
|
+
const container = await createTabs()
|
|
396
|
+
const results = await axe(container)
|
|
397
|
+
expect(results).toHaveNoViolations()
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test('sets correct ARIA attributes on buttons when arrowNav is true', async () => {
|
|
401
|
+
const container = await createTabs()
|
|
402
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
403
|
+
|
|
404
|
+
const firstButton = tabItems[0].querySelector('button')
|
|
405
|
+
const secondButton = tabItems[1].querySelector('button')
|
|
406
|
+
|
|
407
|
+
expect(firstButton).toHaveAttribute('role', 'tab')
|
|
408
|
+
expect(firstButton).toHaveAttribute('aria-selected', 'true')
|
|
409
|
+
expect(secondButton).toHaveAttribute('role', 'tab')
|
|
410
|
+
expect(secondButton).toHaveAttribute('aria-selected', 'false')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('does not set tab role or aria-selected on links when arrowNav is false', async () => {
|
|
414
|
+
const container = await createTabs(
|
|
415
|
+
'disable-arrow-nav',
|
|
416
|
+
`
|
|
417
|
+
<pkt-tab-item index="0" href="/" active>Home</pkt-tab-item>
|
|
418
|
+
<pkt-tab-item index="1" href="/about">About</pkt-tab-item>
|
|
419
|
+
`,
|
|
420
|
+
)
|
|
421
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
422
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
423
|
+
|
|
424
|
+
const homeLink = tabItems[0].querySelector('a')
|
|
425
|
+
const aboutLink = tabItems[1].querySelector('a')
|
|
426
|
+
|
|
427
|
+
expect(homeLink).not.toHaveAttribute('role')
|
|
428
|
+
expect(homeLink).not.toHaveAttribute('aria-selected')
|
|
429
|
+
expect(aboutLink).not.toHaveAttribute('role')
|
|
430
|
+
expect(aboutLink).not.toHaveAttribute('aria-selected')
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test('works with href links when arrowNav is false (no WCAG violations)', async () => {
|
|
434
|
+
const container = await createTabs(
|
|
435
|
+
'disable-arrow-nav',
|
|
436
|
+
`
|
|
437
|
+
<pkt-tab-item index="0" href="/" active>Home</pkt-tab-item>
|
|
438
|
+
<pkt-tab-item index="1" href="/about">About</pkt-tab-item>
|
|
439
|
+
`,
|
|
440
|
+
)
|
|
441
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
442
|
+
const results = await axe(container)
|
|
443
|
+
expect(results).toHaveNoViolations()
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
test('sets role="tablist" when arrowNav is true', async () => {
|
|
447
|
+
const container = await createTabs()
|
|
448
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
449
|
+
const tabList = tabs.querySelector('.pkt-tabs__list')
|
|
450
|
+
|
|
451
|
+
expect(tabList).toHaveAttribute('role', 'tablist')
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
test('sets role="navigation" when arrowNav is false', async () => {
|
|
455
|
+
const container = await createTabs('disable-arrow-nav')
|
|
456
|
+
const tabs = container.querySelector('pkt-tabs') as PktTabs
|
|
457
|
+
await tabs.updateComplete
|
|
458
|
+
const tabList = tabs.querySelector('.pkt-tabs__list')
|
|
459
|
+
|
|
460
|
+
expect(tabList).toHaveAttribute('role', 'navigation')
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
describe('Multiple tab items', () => {
|
|
465
|
+
test('works with many tab items', async () => {
|
|
466
|
+
const container = await createTabs(
|
|
467
|
+
'',
|
|
468
|
+
`
|
|
469
|
+
<pkt-tab-item index="0" active>Tab 1</pkt-tab-item>
|
|
470
|
+
<pkt-tab-item index="1">Tab 2</pkt-tab-item>
|
|
471
|
+
<pkt-tab-item index="2">Tab 3</pkt-tab-item>
|
|
472
|
+
<pkt-tab-item index="3">Tab 4</pkt-tab-item>
|
|
473
|
+
<pkt-tab-item index="4">Tab 5</pkt-tab-item>
|
|
474
|
+
`,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
const tabItems = container.querySelectorAll('pkt-tab-item')
|
|
478
|
+
expect(tabItems).toHaveLength(5)
|
|
479
|
+
|
|
480
|
+
// Test navigation from first to last
|
|
481
|
+
const firstButton = tabItems[0].querySelector('button') as HTMLButtonElement
|
|
482
|
+
firstButton.focus()
|
|
483
|
+
|
|
484
|
+
// Navigate through all tabs
|
|
485
|
+
for (let i = 0; i < 4; i++) {
|
|
486
|
+
const keyEvent = new KeyboardEvent('keyup', { code: 'ArrowRight', bubbles: true })
|
|
487
|
+
;(document.activeElement as HTMLElement).dispatchEvent(keyEvent)
|
|
488
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const lastButton = tabItems[4].querySelector('button') as HTMLButtonElement
|
|
492
|
+
expect(document.activeElement).toBe(lastButton)
|
|
493
|
+
})
|
|
494
|
+
})
|
|
495
|
+
})
|