@oslokommune/punkt-elements 13.7.0 → 13.9.0
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 +34 -0
- package/dist/index.d.ts +77 -5
- 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/icon/icon.ts +1 -1
- 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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { PktElement } from '@/base-elements/element'
|
|
2
|
+
import { PktSlotController } from '@/controllers/pkt-slot-controller'
|
|
3
|
+
import { html, PropertyValues } from 'lit'
|
|
4
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
5
|
+
import { provide } from '@lit/context'
|
|
6
|
+
import { createRef, Ref, ref } from 'lit/directives/ref.js'
|
|
7
|
+
import { tabsContext, type TabsContext } from './tabs-context'
|
|
8
|
+
|
|
9
|
+
export interface IPktTabs {
|
|
10
|
+
arrowNav?: boolean
|
|
11
|
+
disableArrowNav?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@customElement('pkt-tabs')
|
|
15
|
+
export class PktTabs extends PktElement<IPktTabs> implements IPktTabs {
|
|
16
|
+
@property({ type: Boolean, reflect: true, attribute: 'arrow-nav' }) arrowNav: boolean = true
|
|
17
|
+
@property({ type: Boolean, reflect: true, attribute: 'disable-arrow-nav' })
|
|
18
|
+
disableArrowNav: boolean = false
|
|
19
|
+
|
|
20
|
+
@state() private tabRefs: Array<HTMLAnchorElement | HTMLButtonElement | null> = []
|
|
21
|
+
@state() private tabCount: number = 0
|
|
22
|
+
|
|
23
|
+
private get useArrowNav(): boolean {
|
|
24
|
+
return this.arrowNav && !this.disableArrowNav
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
defaultSlot: Ref<HTMLElement> = createRef()
|
|
28
|
+
slotController!: PktSlotController
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
super()
|
|
32
|
+
this.slotController = new PktSlotController(this, this.defaultSlot)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Provide context to child tab items
|
|
36
|
+
@provide({ context: tabsContext })
|
|
37
|
+
@state()
|
|
38
|
+
private context: TabsContext = {
|
|
39
|
+
useArrowNav: this.useArrowNav,
|
|
40
|
+
registerTab: this.registerTab.bind(this),
|
|
41
|
+
handleClick: this.handleClick.bind(this),
|
|
42
|
+
handleKeyUp: this.handleKeyUp.bind(this),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Update context when properties change
|
|
46
|
+
updated(changedProperties: PropertyValues) {
|
|
47
|
+
if (changedProperties.has('arrowNav') || changedProperties.has('disableArrowNav')) {
|
|
48
|
+
this.context = {
|
|
49
|
+
...this.context,
|
|
50
|
+
useArrowNav: this.useArrowNav,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private registerTab(element: HTMLElement, index: number) {
|
|
56
|
+
this.tabRefs[index] = element as HTMLAnchorElement | HTMLButtonElement
|
|
57
|
+
this.tabCount = Math.max(this.tabCount, index + 1)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private handleClick(index: number) {
|
|
61
|
+
this.dispatchEvent(
|
|
62
|
+
new CustomEvent('tab-selected', {
|
|
63
|
+
detail: { index },
|
|
64
|
+
bubbles: true,
|
|
65
|
+
composed: true,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private handleKeyUp(keyEvent: KeyboardEvent, index: number) {
|
|
71
|
+
if (!this.useArrowNav) return
|
|
72
|
+
|
|
73
|
+
if (keyEvent.code === 'ArrowLeft' && index !== 0) {
|
|
74
|
+
this.tabRefs[index - 1]?.focus()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (keyEvent.code === 'ArrowRight' && index < this.tabCount - 1) {
|
|
78
|
+
this.tabRefs[index + 1]?.focus()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (keyEvent.code === 'ArrowDown' || keyEvent.code === 'Space') {
|
|
82
|
+
this.dispatchEvent(
|
|
83
|
+
new CustomEvent('tab-selected', {
|
|
84
|
+
detail: { index },
|
|
85
|
+
bubbles: true,
|
|
86
|
+
composed: true,
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
render() {
|
|
93
|
+
const role = this.useArrowNav ? 'tablist' : 'navigation'
|
|
94
|
+
|
|
95
|
+
return html`
|
|
96
|
+
<div class="pkt-tabs">
|
|
97
|
+
<div class="pkt-tabs__list" role=${role} ${ref(this.defaultSlot)}></div>
|
|
98
|
+
</div>
|
|
99
|
+
`
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default PktTabs
|