@oslokommune/punkt-react 13.7.0 → 13.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oslokommune/punkt-react",
3
- "version": "13.7.0",
3
+ "version": "13.8.1",
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.7.0",
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": "cfe27c04046718ff79f60e3babba60062296f05b"
109
+ "gitHead": "0433bdf21345e1260c74c132ee303fe7cf57b63d"
110
110
  }
@@ -41,7 +41,7 @@ export const LitComponent = createComponent({
41
41
  onInput: 'input' as EventName<PktEventWithTarget>,
42
42
  onBlur: 'blur' as EventName<FocusEvent>,
43
43
  onFocus: 'focus' as EventName<FocusEvent>,
44
- onValueChange: 'valueChange' as EventName<CustomEvent>,
44
+ onValueChange: 'value-change' as EventName<CustomEvent>,
45
45
  onToggleHelpText: 'toggleHelpText' as EventName<CustomEvent>,
46
46
  },
47
47
  }) as ForwardRefExoticComponent<IPktCombobox>
@@ -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
+ })