@oslokommune/punkt-react 13.7.0 → 13.8.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 +18 -0
- package/dist/index.d.ts +20 -2
- package/dist/punkt-react.es.js +1712 -1704
- package/dist/punkt-react.umd.js +191 -191
- package/package.json +3 -3
- package/src/components/index.ts +1 -0
- package/src/components/tabs/TabItem.tsx +77 -0
- package/src/components/tabs/Tabs.test.tsx +393 -1
- package/src/components/tabs/Tabs.tsx +86 -65
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oslokommune/punkt-react",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.8.0",
|
|
4
4
|
"description": "React komponentbibliotek til Punkt, et designsystem laget av Oslo Origo",
|
|
5
5
|
"homepage": "https://punkt.oslo.kommune.no",
|
|
6
6
|
"author": "Team Designsystem, Oslo Origo",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@lit-labs/ssr-dom-shim": "^1.2.1",
|
|
41
41
|
"@lit/react": "^1.0.7",
|
|
42
|
-
"@oslokommune/punkt-elements": "^13.
|
|
42
|
+
"@oslokommune/punkt-elements": "^13.8.0",
|
|
43
43
|
"prettier": "^3.3.3",
|
|
44
44
|
"react-element-to-jsx-string": "^15.0.0",
|
|
45
45
|
"react-hook-form": "^7.53.0",
|
|
@@ -106,5 +106,5 @@
|
|
|
106
106
|
"url": "https://github.com/oslokommune/punkt/issues"
|
|
107
107
|
},
|
|
108
108
|
"license": "MIT",
|
|
109
|
-
"gitHead": "
|
|
109
|
+
"gitHead": "291eaf91a93ce286149605e36547068da3bf90e9"
|
|
110
110
|
}
|
package/src/components/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export { PktTableHeader } from './table/TableHeader'
|
|
|
35
35
|
export { PktTableHeaderCell } from './table/TableHeaderCell'
|
|
36
36
|
export { PktTableRow } from './table/TableRow'
|
|
37
37
|
export { PktTabs } from './tabs/Tabs'
|
|
38
|
+
export { PktTabItem } from './tabs/TabItem'
|
|
38
39
|
export { PktTag } from './tag/Tag'
|
|
39
40
|
export { PktTextarea } from './textarea/Textarea'
|
|
40
41
|
export { PktTextinput } from './textinput/Textinput'
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ReactNode, MouseEvent, forwardRef } from 'react'
|
|
4
|
+
import { PktIcon } from '../icon/Icon'
|
|
5
|
+
import { PktTag } from '../tag/Tag'
|
|
6
|
+
import type { IPktTag } from '../tag/Tag'
|
|
7
|
+
import { useTabsContext } from './Tabs'
|
|
8
|
+
|
|
9
|
+
export type TSkin = IPktTag['skin']
|
|
10
|
+
|
|
11
|
+
export interface IPktTabItem {
|
|
12
|
+
children: ReactNode
|
|
13
|
+
active?: boolean
|
|
14
|
+
href?: string
|
|
15
|
+
onClick?: (event: MouseEvent) => void
|
|
16
|
+
icon?: string
|
|
17
|
+
controls?: string
|
|
18
|
+
tag?: string
|
|
19
|
+
tagSkin?: TSkin
|
|
20
|
+
index?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const PktTabItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, IPktTabItem>(
|
|
24
|
+
({ children, active, href, onClick, icon, controls, tag, tagSkin, index = 0 }, ref) => {
|
|
25
|
+
const { arrowNav, registerTabRef, handleKeyPress, selectTab } = useTabsContext()
|
|
26
|
+
|
|
27
|
+
const handleClick = (event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => {
|
|
28
|
+
selectTab(index)
|
|
29
|
+
onClick?.(event)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const commonProps = {
|
|
33
|
+
'aria-selected': arrowNav ? !!active : undefined,
|
|
34
|
+
'aria-controls': controls,
|
|
35
|
+
role: arrowNav ? 'tab' : undefined,
|
|
36
|
+
onKeyUp: (event: React.KeyboardEvent) => handleKeyPress(index, event),
|
|
37
|
+
onClick: handleClick,
|
|
38
|
+
tabIndex: active || !arrowNav ? undefined : -1,
|
|
39
|
+
ref: (el: HTMLAnchorElement | HTMLButtonElement | null) => {
|
|
40
|
+
registerTabRef(index, el)
|
|
41
|
+
if (typeof ref === 'function') {
|
|
42
|
+
ref(el)
|
|
43
|
+
} else if (ref) {
|
|
44
|
+
ref.current = el
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const content = (
|
|
50
|
+
<>
|
|
51
|
+
{icon && <PktIcon name={icon} className="pkt-icon--small" />}
|
|
52
|
+
{children}
|
|
53
|
+
{tag && (
|
|
54
|
+
<PktTag skin={tagSkin} size="small">
|
|
55
|
+
{tag}
|
|
56
|
+
</PktTag>
|
|
57
|
+
)}
|
|
58
|
+
</>
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if (href) {
|
|
62
|
+
return (
|
|
63
|
+
<a {...commonProps} href={href} className={`pkt-tabs__link ${active ? 'active' : ''}`}>
|
|
64
|
+
{content}
|
|
65
|
+
</a>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<button {...commonProps} type="button" className={`pkt-tabs__button pkt-link-button ${active ? 'active' : ''}`}>
|
|
71
|
+
{content}
|
|
72
|
+
</button>
|
|
73
|
+
)
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
PktTabItem.displayName = 'PktTabItem'
|
|
@@ -2,7 +2,7 @@ import React from 'react'
|
|
|
2
2
|
import { render, fireEvent } from '@testing-library/react'
|
|
3
3
|
import { axe, toHaveNoViolations } from 'jest-axe'
|
|
4
4
|
import '@testing-library/jest-dom'
|
|
5
|
-
import { PktTabs } from './Tabs'
|
|
5
|
+
import { PktTabs, PktTabItem } from './Tabs'
|
|
6
6
|
|
|
7
7
|
expect.extend(toHaveNoViolations)
|
|
8
8
|
|
|
@@ -55,3 +55,395 @@ describe('Tabs component', () => {
|
|
|
55
55
|
})
|
|
56
56
|
})
|
|
57
57
|
})
|
|
58
|
+
|
|
59
|
+
describe('PktTabItem children format', () => {
|
|
60
|
+
it('renders tabs from children', () => {
|
|
61
|
+
const { getByText } = render(
|
|
62
|
+
<PktTabs>
|
|
63
|
+
<PktTabItem index={0}>First Tab</PktTabItem>
|
|
64
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
65
|
+
<PktTabItem index={2}>Third Tab</PktTabItem>
|
|
66
|
+
</PktTabs>,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
expect(getByText('First Tab')).toBeInTheDocument()
|
|
70
|
+
expect(getByText('Second Tab')).toBeInTheDocument()
|
|
71
|
+
expect(getByText('Third Tab')).toBeInTheDocument()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('applies active class to the active tab', () => {
|
|
75
|
+
const { getByText } = render(
|
|
76
|
+
<PktTabs>
|
|
77
|
+
<PktTabItem index={0}>First Tab</PktTabItem>
|
|
78
|
+
<PktTabItem index={1} active>
|
|
79
|
+
Second Tab
|
|
80
|
+
</PktTabItem>
|
|
81
|
+
<PktTabItem index={2}>Third Tab</PktTabItem>
|
|
82
|
+
</PktTabs>,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(getByText('Second Tab')).toHaveClass('active')
|
|
86
|
+
expect(getByText('First Tab')).not.toHaveClass('active')
|
|
87
|
+
expect(getByText('Third Tab')).not.toHaveClass('active')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('calls onClick handler when tab is clicked', () => {
|
|
91
|
+
const handleClick = jest.fn()
|
|
92
|
+
const { getByText } = render(
|
|
93
|
+
<PktTabs>
|
|
94
|
+
<PktTabItem index={0}>First Tab</PktTabItem>
|
|
95
|
+
<PktTabItem index={1} onClick={handleClick}>
|
|
96
|
+
Second Tab
|
|
97
|
+
</PktTabItem>
|
|
98
|
+
<PktTabItem index={2}>Third Tab</PktTabItem>
|
|
99
|
+
</PktTabs>,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
fireEvent.click(getByText('Second Tab'))
|
|
103
|
+
expect(handleClick).toHaveBeenCalled()
|
|
104
|
+
expect(handleClick.mock.calls[0][0]).toHaveProperty('type', 'click')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('calls onTabSelected when tab is clicked', () => {
|
|
108
|
+
const handleTabSelected = jest.fn()
|
|
109
|
+
const { getByText } = render(
|
|
110
|
+
<PktTabs onTabSelected={handleTabSelected}>
|
|
111
|
+
<PktTabItem index={0}>First Tab</PktTabItem>
|
|
112
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
113
|
+
<PktTabItem index={2}>Third Tab</PktTabItem>
|
|
114
|
+
</PktTabs>,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
fireEvent.click(getByText('Second Tab'))
|
|
118
|
+
expect(handleTabSelected).toHaveBeenCalledWith(1)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('calls both onClick and onTabSelected when tab is clicked', () => {
|
|
122
|
+
const handleClick = jest.fn()
|
|
123
|
+
const handleTabSelected = jest.fn()
|
|
124
|
+
const { getByText } = render(
|
|
125
|
+
<PktTabs onTabSelected={handleTabSelected}>
|
|
126
|
+
<PktTabItem index={0}>First Tab</PktTabItem>
|
|
127
|
+
<PktTabItem index={1} onClick={handleClick}>
|
|
128
|
+
Second Tab
|
|
129
|
+
</PktTabItem>
|
|
130
|
+
<PktTabItem index={2}>Third Tab</PktTabItem>
|
|
131
|
+
</PktTabs>,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
fireEvent.click(getByText('Second Tab'))
|
|
135
|
+
expect(handleClick).toHaveBeenCalled()
|
|
136
|
+
expect(handleTabSelected).toHaveBeenCalledWith(1)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('renders as button when no href is provided', () => {
|
|
140
|
+
const { getByText } = render(
|
|
141
|
+
<PktTabs>
|
|
142
|
+
<PktTabItem index={0}>First Tab</PktTabItem>
|
|
143
|
+
</PktTabs>,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const tab = getByText('First Tab')
|
|
147
|
+
expect(tab.tagName).toBe('BUTTON')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('renders as link when href is provided', () => {
|
|
151
|
+
const { getByText } = render(
|
|
152
|
+
<PktTabs>
|
|
153
|
+
<PktTabItem index={0} href="/first">
|
|
154
|
+
First Tab
|
|
155
|
+
</PktTabItem>
|
|
156
|
+
</PktTabs>,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const tab = getByText('First Tab')
|
|
160
|
+
expect(tab.tagName).toBe('A')
|
|
161
|
+
expect(tab).toHaveAttribute('href', '/first')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('renders icon when icon prop is provided', () => {
|
|
165
|
+
const { container } = render(
|
|
166
|
+
<PktTabs>
|
|
167
|
+
<PktTabItem index={0} icon="user">
|
|
168
|
+
First Tab
|
|
169
|
+
</PktTabItem>
|
|
170
|
+
</PktTabs>,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const button = container.querySelector('.pkt-tabs__button')
|
|
174
|
+
expect(button).toBeInTheDocument()
|
|
175
|
+
const iconElement = container.querySelector('.pkt-icon--small')
|
|
176
|
+
expect(iconElement).toBeInTheDocument()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('renders tag when tag and tagSkin props are provided', () => {
|
|
180
|
+
const { getByText } = render(
|
|
181
|
+
<PktTabs>
|
|
182
|
+
<PktTabItem index={0} tag="New" tagSkin="blue">
|
|
183
|
+
First Tab
|
|
184
|
+
</PktTabItem>
|
|
185
|
+
</PktTabs>,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
expect(getByText('New')).toBeInTheDocument()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('handles keyboard navigation with ArrowRight', () => {
|
|
192
|
+
const { getByText } = render(
|
|
193
|
+
<PktTabs>
|
|
194
|
+
<PktTabItem index={0} active>
|
|
195
|
+
First Tab
|
|
196
|
+
</PktTabItem>
|
|
197
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
198
|
+
<PktTabItem index={2}>Third Tab</PktTabItem>
|
|
199
|
+
</PktTabs>,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const firstTab = getByText('First Tab')
|
|
203
|
+
const secondTab = getByText('Second Tab')
|
|
204
|
+
|
|
205
|
+
firstTab.focus()
|
|
206
|
+
fireEvent.keyUp(firstTab, { code: 'ArrowRight' })
|
|
207
|
+
|
|
208
|
+
expect(secondTab).toHaveFocus()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('handles keyboard navigation with ArrowLeft', () => {
|
|
212
|
+
const { getByText } = render(
|
|
213
|
+
<PktTabs>
|
|
214
|
+
<PktTabItem index={0}>First Tab</PktTabItem>
|
|
215
|
+
<PktTabItem index={1} active>
|
|
216
|
+
Second Tab
|
|
217
|
+
</PktTabItem>
|
|
218
|
+
<PktTabItem index={2}>Third Tab</PktTabItem>
|
|
219
|
+
</PktTabs>,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const firstTab = getByText('First Tab')
|
|
223
|
+
const secondTab = getByText('Second Tab')
|
|
224
|
+
|
|
225
|
+
secondTab.focus()
|
|
226
|
+
fireEvent.keyUp(secondTab, { code: 'ArrowLeft' })
|
|
227
|
+
|
|
228
|
+
expect(firstTab).toHaveFocus()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('stays on last tab when navigating past it with ArrowRight', () => {
|
|
232
|
+
const { getByText } = render(
|
|
233
|
+
<PktTabs>
|
|
234
|
+
<PktTabItem index={0}>First Tab</PktTabItem>
|
|
235
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
236
|
+
<PktTabItem index={2} active>
|
|
237
|
+
Third Tab
|
|
238
|
+
</PktTabItem>
|
|
239
|
+
</PktTabs>,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
const thirdTab = getByText('Third Tab')
|
|
243
|
+
|
|
244
|
+
thirdTab.focus()
|
|
245
|
+
fireEvent.keyUp(thirdTab, { code: 'ArrowRight' })
|
|
246
|
+
|
|
247
|
+
expect(thirdTab).toHaveFocus()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('stays on first tab when navigating before it with ArrowLeft', () => {
|
|
251
|
+
const { getByText } = render(
|
|
252
|
+
<PktTabs>
|
|
253
|
+
<PktTabItem index={0} active>
|
|
254
|
+
First Tab
|
|
255
|
+
</PktTabItem>
|
|
256
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
257
|
+
<PktTabItem index={2}>Third Tab</PktTabItem>
|
|
258
|
+
</PktTabs>,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
const firstTab = getByText('First Tab')
|
|
262
|
+
|
|
263
|
+
firstTab.focus()
|
|
264
|
+
fireEvent.keyUp(firstTab, { code: 'ArrowLeft' })
|
|
265
|
+
|
|
266
|
+
expect(firstTab).toHaveFocus()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('selects tab when Space key is pressed', () => {
|
|
270
|
+
const handleTabSelected = jest.fn()
|
|
271
|
+
const { getByText } = render(
|
|
272
|
+
<PktTabs onTabSelected={handleTabSelected}>
|
|
273
|
+
<PktTabItem index={0}>First Tab</PktTabItem>
|
|
274
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
275
|
+
</PktTabs>,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
const secondTab = getByText('Second Tab')
|
|
279
|
+
fireEvent.keyUp(secondTab, { code: 'Space' })
|
|
280
|
+
|
|
281
|
+
expect(handleTabSelected).toHaveBeenCalledWith(1)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('selects tab when ArrowDown key is pressed', () => {
|
|
285
|
+
const handleTabSelected = jest.fn()
|
|
286
|
+
const { getByText } = render(
|
|
287
|
+
<PktTabs onTabSelected={handleTabSelected}>
|
|
288
|
+
<PktTabItem index={0}>First Tab</PktTabItem>
|
|
289
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
290
|
+
</PktTabs>,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
const secondTab = getByText('Second Tab')
|
|
294
|
+
fireEvent.keyUp(secondTab, { code: 'ArrowDown' })
|
|
295
|
+
|
|
296
|
+
expect(handleTabSelected).toHaveBeenCalledWith(1)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('disables keyboard navigation when arrowNav is false', () => {
|
|
300
|
+
const { getByText } = render(
|
|
301
|
+
<PktTabs arrowNav={false}>
|
|
302
|
+
<PktTabItem index={0} active>
|
|
303
|
+
First Tab
|
|
304
|
+
</PktTabItem>
|
|
305
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
306
|
+
</PktTabs>,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
const firstTab = getByText('First Tab')
|
|
310
|
+
const secondTab = getByText('Second Tab')
|
|
311
|
+
|
|
312
|
+
firstTab.focus()
|
|
313
|
+
fireEvent.keyUp(firstTab, { code: 'ArrowRight' })
|
|
314
|
+
|
|
315
|
+
expect(secondTab).not.toHaveFocus()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('disables keyboard navigation when disableArrowNav is true', () => {
|
|
319
|
+
const { getByText } = render(
|
|
320
|
+
<PktTabs disableArrowNav={true}>
|
|
321
|
+
<PktTabItem index={0} active>
|
|
322
|
+
First Tab
|
|
323
|
+
</PktTabItem>
|
|
324
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
325
|
+
</PktTabs>,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
const firstTab = getByText('First Tab')
|
|
329
|
+
const secondTab = getByText('Second Tab')
|
|
330
|
+
|
|
331
|
+
firstTab.focus()
|
|
332
|
+
fireEvent.keyUp(firstTab, { code: 'ArrowRight' })
|
|
333
|
+
|
|
334
|
+
expect(secondTab).not.toHaveFocus()
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('disableArrowNav overrides arrowNav when both are set', () => {
|
|
338
|
+
const { getByText } = render(
|
|
339
|
+
<PktTabs arrowNav={true} disableArrowNav={true}>
|
|
340
|
+
<PktTabItem index={0} active>
|
|
341
|
+
First Tab
|
|
342
|
+
</PktTabItem>
|
|
343
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
344
|
+
</PktTabs>,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
const firstTab = getByText('First Tab')
|
|
348
|
+
const secondTab = getByText('Second Tab')
|
|
349
|
+
|
|
350
|
+
// Even though arrowNav is true, disableArrowNav should override it
|
|
351
|
+
firstTab.focus()
|
|
352
|
+
fireEvent.keyUp(firstTab, { code: 'ArrowRight' })
|
|
353
|
+
|
|
354
|
+
expect(secondTab).not.toHaveFocus()
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('works with mapped children when explicit index is provided', () => {
|
|
358
|
+
const items = ['First', 'Second', 'Third']
|
|
359
|
+
const handleTabSelected = jest.fn()
|
|
360
|
+
|
|
361
|
+
const { getByText } = render(
|
|
362
|
+
<PktTabs onTabSelected={handleTabSelected}>
|
|
363
|
+
{items.map((item, i) => (
|
|
364
|
+
<PktTabItem key={i} index={i}>
|
|
365
|
+
{item}
|
|
366
|
+
</PktTabItem>
|
|
367
|
+
))}
|
|
368
|
+
</PktTabs>,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
fireEvent.click(getByText('Second'))
|
|
372
|
+
expect(handleTabSelected).toHaveBeenCalledWith(1)
|
|
373
|
+
|
|
374
|
+
fireEvent.click(getByText('Third'))
|
|
375
|
+
expect(handleTabSelected).toHaveBeenCalledWith(2)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
describe('Accessibility', () => {
|
|
379
|
+
it('should not have any accessibility violations', async () => {
|
|
380
|
+
const { container } = render(
|
|
381
|
+
<PktTabs>
|
|
382
|
+
<PktTabItem index={0} active>
|
|
383
|
+
First Tab
|
|
384
|
+
</PktTabItem>
|
|
385
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
386
|
+
<PktTabItem index={2}>Third Tab</PktTabItem>
|
|
387
|
+
</PktTabs>,
|
|
388
|
+
)
|
|
389
|
+
const results = await axe(container)
|
|
390
|
+
expect(results).toHaveNoViolations()
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('sets correct ARIA attributes on buttons', () => {
|
|
394
|
+
const { getByText } = render(
|
|
395
|
+
<PktTabs>
|
|
396
|
+
<PktTabItem index={0} active>
|
|
397
|
+
First Tab
|
|
398
|
+
</PktTabItem>
|
|
399
|
+
<PktTabItem index={1}>Second Tab</PktTabItem>
|
|
400
|
+
</PktTabs>,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
const firstTab = getByText('First Tab')
|
|
404
|
+
const secondTab = getByText('Second Tab')
|
|
405
|
+
|
|
406
|
+
expect(firstTab).toHaveAttribute('role', 'tab')
|
|
407
|
+
expect(firstTab).toHaveAttribute('aria-selected', 'true')
|
|
408
|
+
expect(secondTab).toHaveAttribute('role', 'tab')
|
|
409
|
+
expect(secondTab).toHaveAttribute('aria-selected', 'false')
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('does not set tab role or aria-selected on links when arrowNav is false', () => {
|
|
413
|
+
const { getByText } = render(
|
|
414
|
+
<PktTabs arrowNav={false}>
|
|
415
|
+
<PktTabItem index={0} href="/" active>
|
|
416
|
+
Home
|
|
417
|
+
</PktTabItem>
|
|
418
|
+
<PktTabItem index={1} href="/about">
|
|
419
|
+
About
|
|
420
|
+
</PktTabItem>
|
|
421
|
+
</PktTabs>,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
const homeLink = getByText('Home')
|
|
425
|
+
const aboutLink = getByText('About')
|
|
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
|
+
it('works with href links when arrowNav is false (no WCAG violations)', async () => {
|
|
434
|
+
const { container } = render(
|
|
435
|
+
<PktTabs arrowNav={false}>
|
|
436
|
+
<PktTabItem index={0} href="/" active>
|
|
437
|
+
Home
|
|
438
|
+
</PktTabItem>
|
|
439
|
+
<PktTabItem index={1} href="/about">
|
|
440
|
+
About
|
|
441
|
+
</PktTabItem>
|
|
442
|
+
</PktTabs>,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
const results = await axe(container)
|
|
446
|
+
expect(results).toHaveNoViolations()
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
})
|
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
Ref,
|
|
6
|
+
useRef,
|
|
7
|
+
useEffect,
|
|
8
|
+
KeyboardEvent,
|
|
9
|
+
ReactNode,
|
|
10
|
+
Children,
|
|
11
|
+
createContext,
|
|
12
|
+
useContext,
|
|
13
|
+
} from 'react'
|
|
14
|
+
import { PktTabItem } from './TabItem'
|
|
6
15
|
|
|
7
16
|
export type TSkin = 'blue' | 'green' | 'red' | 'beige' | 'yellow' | 'grey' | 'gray' | 'blue-light'
|
|
8
17
|
|
|
18
|
+
// Context for passing tab navigation logic to children
|
|
19
|
+
interface ITabsContext {
|
|
20
|
+
arrowNav: boolean
|
|
21
|
+
registerTabRef: (index: number, el: HTMLAnchorElement | HTMLButtonElement | null) => void
|
|
22
|
+
handleKeyPress: (index: number, event: KeyboardEvent) => void
|
|
23
|
+
selectTab: (index: number) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TabsContext = createContext<ITabsContext | null>(null)
|
|
27
|
+
|
|
28
|
+
export const useTabsContext = () => {
|
|
29
|
+
const context = useContext(TabsContext)
|
|
30
|
+
if (!context) {
|
|
31
|
+
throw new Error('TabItem must be used within a Tabs component')
|
|
32
|
+
}
|
|
33
|
+
return context
|
|
34
|
+
}
|
|
35
|
+
|
|
9
36
|
export interface IPktTab {
|
|
10
37
|
text: string
|
|
11
38
|
href?: string
|
|
@@ -21,88 +48,82 @@ export interface IPktTab {
|
|
|
21
48
|
|
|
22
49
|
export interface IPktTabs {
|
|
23
50
|
arrowNav?: boolean
|
|
24
|
-
|
|
51
|
+
disableArrowNav?: boolean
|
|
52
|
+
tabs?: IPktTab[]
|
|
25
53
|
onTabSelected?: (index: number) => void
|
|
54
|
+
children?: ReactNode
|
|
26
55
|
}
|
|
27
56
|
|
|
28
57
|
export const PktTabs = forwardRef(
|
|
29
|
-
(
|
|
58
|
+
(
|
|
59
|
+
{ arrowNav = true, disableArrowNav = false, tabs, onTabSelected, children }: IPktTabs,
|
|
60
|
+
ref: Ref<HTMLDivElement>,
|
|
61
|
+
): JSX.Element => {
|
|
30
62
|
const tabRefs = useRef<Array<HTMLAnchorElement | HTMLButtonElement | null>>([])
|
|
63
|
+
|
|
64
|
+
const useArrowNav = arrowNav && !disableArrowNav
|
|
65
|
+
|
|
66
|
+
// Determine if we're using children or tabs array
|
|
67
|
+
const hasChildren = children && Children.count(children) > 0
|
|
68
|
+
const tabCount = hasChildren ? Children.count(children) : tabs?.length || 0
|
|
69
|
+
|
|
31
70
|
useEffect(() => {
|
|
32
|
-
tabRefs.current = tabRefs.current.slice(0,
|
|
33
|
-
}, [
|
|
34
|
-
|
|
35
|
-
|
|
71
|
+
tabRefs.current = tabRefs.current.slice(0, tabCount)
|
|
72
|
+
}, [tabCount])
|
|
73
|
+
|
|
74
|
+
const selectTab = (index: number): void => {
|
|
75
|
+
const tab = tabs?.[index]
|
|
76
|
+
if (tab?.action) {
|
|
77
|
+
tab.action(index)
|
|
78
|
+
}
|
|
36
79
|
if (onTabSelected) onTabSelected(index)
|
|
37
80
|
}
|
|
81
|
+
|
|
38
82
|
const handleKeyPress = (index: number, event: KeyboardEvent) => {
|
|
39
|
-
if (
|
|
83
|
+
if (useArrowNav) {
|
|
40
84
|
if (event.code === 'ArrowLeft' && index !== 0) {
|
|
41
85
|
tabRefs.current[index - 1]?.focus()
|
|
42
86
|
}
|
|
43
|
-
if (event.code === 'ArrowRight' && index <
|
|
87
|
+
if (event.code === 'ArrowRight' && index < tabCount - 1) {
|
|
44
88
|
tabRefs.current[index + 1]?.focus()
|
|
45
89
|
}
|
|
46
90
|
if (event.code === 'ArrowDown' || event.code === 'Space') {
|
|
47
|
-
selectTab(index
|
|
91
|
+
selectTab(index)
|
|
48
92
|
}
|
|
49
93
|
}
|
|
50
94
|
}
|
|
95
|
+
|
|
96
|
+
const registerTabRef = (index: number, el: HTMLAnchorElement | HTMLButtonElement | null) => {
|
|
97
|
+
tabRefs.current[index] = el
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// If tabs as prop instead of children
|
|
101
|
+
const tabItems = tabs?.map((tab, index) => (
|
|
102
|
+
<PktTabItem
|
|
103
|
+
key={index}
|
|
104
|
+
active={tab.active}
|
|
105
|
+
href={tab.href}
|
|
106
|
+
onClick={() => selectTab(index)}
|
|
107
|
+
icon={tab.icon}
|
|
108
|
+
controls={tab.controls}
|
|
109
|
+
tag={tab.tag?.text}
|
|
110
|
+
tagSkin={tab.tag?.skin}
|
|
111
|
+
index={index}
|
|
112
|
+
>
|
|
113
|
+
{tab.text}
|
|
114
|
+
</PktTabItem>
|
|
115
|
+
))
|
|
116
|
+
|
|
51
117
|
return (
|
|
52
|
-
<
|
|
53
|
-
<div className="pkt-
|
|
54
|
-
{
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
<a
|
|
58
|
-
aria-selected={!!tab.active}
|
|
59
|
-
role={arrowNav ? 'tab' : undefined}
|
|
60
|
-
href={tab.href}
|
|
61
|
-
aria-controls={tab.controls}
|
|
62
|
-
className={`pkt-tabs__link ${tab.active ? 'active' : ''}`}
|
|
63
|
-
onKeyUp={(event) => handleKeyPress(index, event)}
|
|
64
|
-
onClick={() => selectTab(index, tab)}
|
|
65
|
-
tabIndex={tab.active || !arrowNav ? undefined : -1}
|
|
66
|
-
ref={(el) => {
|
|
67
|
-
tabRefs.current[index] = el
|
|
68
|
-
}}
|
|
69
|
-
>
|
|
70
|
-
{tab.icon && <PktIcon name={tab.icon} className="pkt-icon--small" />}
|
|
71
|
-
{tab.text}
|
|
72
|
-
{tab.tag && (
|
|
73
|
-
<PktTag skin={tab.tag.skin} size="small">
|
|
74
|
-
{tab.tag.text}
|
|
75
|
-
</PktTag>
|
|
76
|
-
)}
|
|
77
|
-
</a>
|
|
78
|
-
) : (
|
|
79
|
-
<button
|
|
80
|
-
aria-selected={!!tab.active}
|
|
81
|
-
role={arrowNav ? 'tab' : undefined}
|
|
82
|
-
type="button"
|
|
83
|
-
aria-controls={tab.controls}
|
|
84
|
-
className={`pkt-tabs__button pkt-link-button ${tab.active ? 'active' : ''}`}
|
|
85
|
-
key={'b-' + index}
|
|
86
|
-
onKeyUp={(event) => handleKeyPress(index, event)}
|
|
87
|
-
onClick={() => selectTab(index, tab)}
|
|
88
|
-
tabIndex={tab.active || !arrowNav ? undefined : -1}
|
|
89
|
-
ref={(el) => {
|
|
90
|
-
tabRefs.current[index] = el
|
|
91
|
-
}}
|
|
92
|
-
>
|
|
93
|
-
{tab.icon && <PktIcon name={tab.icon} className="pkt-icon--small" />}
|
|
94
|
-
{tab.text}
|
|
95
|
-
{tab.tag && (
|
|
96
|
-
<PktTag skin={tab.tag.skin} size="small">
|
|
97
|
-
{tab.tag.text}
|
|
98
|
-
</PktTag>
|
|
99
|
-
)}
|
|
100
|
-
</button>
|
|
101
|
-
)}
|
|
102
|
-
</Fragment>
|
|
103
|
-
))}
|
|
118
|
+
<TabsContext.Provider value={{ arrowNav: useArrowNav, registerTabRef, handleKeyPress, selectTab }}>
|
|
119
|
+
<div className="pkt-tabs" ref={ref}>
|
|
120
|
+
<div className="pkt-tabs__list" role={useArrowNav ? 'tablist' : 'navigation'}>
|
|
121
|
+
{children || tabItems}
|
|
122
|
+
</div>
|
|
104
123
|
</div>
|
|
105
|
-
</
|
|
124
|
+
</TabsContext.Provider>
|
|
106
125
|
)
|
|
107
126
|
},
|
|
108
127
|
)
|
|
128
|
+
|
|
129
|
+
export { PktTabItem } from './TabItem'
|