@oslokommune/punkt-react 16.8.1 → 16.8.2

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": "16.8.1",
3
+ "version": "16.8.2",
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": "^16.7.7",
42
+ "@oslokommune/punkt-elements": "^16.8.2",
43
43
  "classnames": "^2.5.1",
44
44
  "prettier": "^3.3.3",
45
45
  "react-hook-form": "^7.53.0"
@@ -50,7 +50,7 @@
50
50
  "@eslint/eslintrc": "^3.3.3",
51
51
  "@eslint/js": "^9.37.0",
52
52
  "@oslokommune/punkt-assets": "^16.0.0",
53
- "@oslokommune/punkt-css": "^16.7.6",
53
+ "@oslokommune/punkt-css": "^16.8.2",
54
54
  "@testing-library/jest-dom": "^6.5.0",
55
55
  "@testing-library/react": "^16.0.1",
56
56
  "@testing-library/user-event": "^14.5.2",
@@ -109,5 +109,5 @@
109
109
  "url": "https://github.com/oslokommune/punkt/issues"
110
110
  },
111
111
  "license": "MIT",
112
- "gitHead": "01d75359139a8932b991c37ecaea46458e2b2d66"
112
+ "gitHead": "1ff7d19b9d19c3b3e810dcee4bc3bd1d3fcc3610"
113
113
  }
@@ -11,6 +11,7 @@ export type TSkin = IPktTag['skin']
11
11
  export interface IPktTabItem {
12
12
  children: ReactNode
13
13
  active?: boolean
14
+ disabled?: boolean
14
15
  href?: string
15
16
  onClick?: (event: MouseEvent) => void
16
17
  icon?: string
@@ -21,23 +22,39 @@ export interface IPktTabItem {
21
22
  }
22
23
 
23
24
  export const PktTabItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, IPktTabItem>(
24
- ({ children, active, href, onClick, icon, controls, tag, tagSkin, index = 0 }, ref) => {
25
+ ({ children, active, disabled = false, href, onClick, icon, controls, tag, tagSkin, index = 0 }, ref) => {
25
26
  const { arrowNav, registerTabRef, handleKeyPress, selectTab } = useTabsContext()
27
+ const isActive = !!active && !disabled
26
28
 
27
29
  const handleClick = (event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => {
30
+ if (disabled) {
31
+ event.preventDefault()
32
+ event.stopPropagation()
33
+ return
34
+ }
28
35
  selectTab(index)
29
36
  onClick?.(event)
30
37
  }
31
38
 
39
+ const handleKeyDown = (event: KeyboardEvent<HTMLAnchorElement | HTMLButtonElement>) => {
40
+ if (disabled && (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar')) {
41
+ event.preventDefault()
42
+ event.stopPropagation()
43
+ return
44
+ }
45
+
46
+ handleKeyPress(index, event)
47
+ }
48
+
32
49
  const commonProps = {
33
- 'aria-selected': arrowNav ? !!active : undefined,
50
+ 'aria-selected': arrowNav ? isActive : undefined,
34
51
  'aria-controls': controls,
35
52
  role: arrowNav ? 'tab' : undefined,
36
- onKeyUp: (event: KeyboardEvent) => handleKeyPress(index, event),
53
+ onKeyDown: handleKeyDown,
37
54
  onClick: handleClick,
38
- tabIndex: active || !arrowNav ? undefined : -1,
55
+ tabIndex: disabled ? -1 : isActive || !arrowNav ? undefined : -1,
39
56
  ref: (el: HTMLAnchorElement | HTMLButtonElement | null) => {
40
- registerTabRef(index, el)
57
+ registerTabRef(index, el, disabled)
41
58
  if (typeof ref === 'function') {
42
59
  ref(el)
43
60
  } else if (ref) {
@@ -60,14 +77,25 @@ export const PktTabItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, IPkt
60
77
 
61
78
  if (href) {
62
79
  return (
63
- <a {...commonProps} href={href} className={`pkt-tabs__link ${active ? 'active' : ''}`}>
80
+ <a
81
+ {...commonProps}
82
+ href={disabled ? undefined : href}
83
+ aria-disabled={disabled || undefined}
84
+ className={`pkt-tabs__link ${isActive ? 'active' : ''} ${disabled ? 'pkt-tabs__item--disabled' : ''}`}
85
+ >
64
86
  {content}
65
87
  </a>
66
88
  )
67
89
  }
68
90
 
69
91
  return (
70
- <button {...commonProps} type="button" className={`pkt-tabs__button pkt-link-button ${active ? 'active' : ''}`}>
92
+ <button
93
+ {...commonProps}
94
+ type="button"
95
+ disabled={disabled}
96
+ aria-disabled={disabled || undefined}
97
+ className={`pkt-tabs__button pkt-link-button ${isActive ? 'active' : ''} ${disabled ? 'pkt-tabs__item--disabled' : ''}`}
98
+ >
71
99
  {content}
72
100
  </button>
73
101
  )
@@ -205,7 +205,7 @@ describe('PktTabItem children format', () => {
205
205
  const secondTab = getByText('Second Tab')
206
206
 
207
207
  firstTab.focus()
208
- fireEvent.keyUp(firstTab, { code: 'ArrowRight' })
208
+ fireEvent.keyDown(firstTab, { key: 'ArrowRight' })
209
209
 
210
210
  expect(secondTab).toHaveFocus()
211
211
  })
@@ -225,7 +225,7 @@ describe('PktTabItem children format', () => {
225
225
  const secondTab = getByText('Second Tab')
226
226
 
227
227
  secondTab.focus()
228
- fireEvent.keyUp(secondTab, { code: 'ArrowLeft' })
228
+ fireEvent.keyDown(secondTab, { key: 'ArrowLeft' })
229
229
 
230
230
  expect(firstTab).toHaveFocus()
231
231
  })
@@ -244,7 +244,7 @@ describe('PktTabItem children format', () => {
244
244
  const thirdTab = getByText('Third Tab')
245
245
 
246
246
  thirdTab.focus()
247
- fireEvent.keyUp(thirdTab, { code: 'ArrowRight' })
247
+ fireEvent.keyDown(thirdTab, { key: 'ArrowRight' })
248
248
 
249
249
  expect(thirdTab).toHaveFocus()
250
250
  })
@@ -263,7 +263,7 @@ describe('PktTabItem children format', () => {
263
263
  const firstTab = getByText('First Tab')
264
264
 
265
265
  firstTab.focus()
266
- fireEvent.keyUp(firstTab, { code: 'ArrowLeft' })
266
+ fireEvent.keyDown(firstTab, { key: 'ArrowLeft' })
267
267
 
268
268
  expect(firstTab).toHaveFocus()
269
269
  })
@@ -278,7 +278,7 @@ describe('PktTabItem children format', () => {
278
278
  )
279
279
 
280
280
  const secondTab = getByText('Second Tab')
281
- fireEvent.keyUp(secondTab, { code: 'Space' })
281
+ fireEvent.keyDown(secondTab, { key: ' ' })
282
282
 
283
283
  expect(handleTabSelected).toHaveBeenCalledWith(1)
284
284
  })
@@ -293,7 +293,7 @@ describe('PktTabItem children format', () => {
293
293
  )
294
294
 
295
295
  const secondTab = getByText('Second Tab')
296
- fireEvent.keyUp(secondTab, { code: 'ArrowDown' })
296
+ fireEvent.keyDown(secondTab, { key: 'ArrowDown' })
297
297
 
298
298
  expect(handleTabSelected).toHaveBeenCalledWith(1)
299
299
  })
@@ -312,7 +312,7 @@ describe('PktTabItem children format', () => {
312
312
  const secondTab = getByText('Second Tab')
313
313
 
314
314
  firstTab.focus()
315
- fireEvent.keyUp(firstTab, { code: 'ArrowRight' })
315
+ fireEvent.keyDown(firstTab, { key: 'ArrowRight' })
316
316
 
317
317
  expect(secondTab).not.toHaveFocus()
318
318
  })
@@ -331,7 +331,7 @@ describe('PktTabItem children format', () => {
331
331
  const secondTab = getByText('Second Tab')
332
332
 
333
333
  firstTab.focus()
334
- fireEvent.keyUp(firstTab, { code: 'ArrowRight' })
334
+ fireEvent.keyDown(firstTab, { key: 'ArrowRight' })
335
335
 
336
336
  expect(secondTab).not.toHaveFocus()
337
337
  })
@@ -351,7 +351,7 @@ describe('PktTabItem children format', () => {
351
351
 
352
352
  // Even though arrowNav is true, disableArrowNav should override it
353
353
  firstTab.focus()
354
- fireEvent.keyUp(firstTab, { code: 'ArrowRight' })
354
+ fireEvent.keyDown(firstTab, { key: 'ArrowRight' })
355
355
 
356
356
  expect(secondTab).not.toHaveFocus()
357
357
  })
@@ -377,6 +377,104 @@ describe('PktTabItem children format', () => {
377
377
  expect(handleTabSelected).toHaveBeenCalledWith(2)
378
378
  })
379
379
 
380
+ describe('Disabled tabs', () => {
381
+ it('does not call onTabSelected or action for disabled button tab from tabs prop', () => {
382
+ const onTabSelected = vi.fn()
383
+ const action = vi.fn()
384
+ const { getByText } = render(
385
+ <PktTabs
386
+ onTabSelected={onTabSelected}
387
+ tabs={[
388
+ { text: 'Enabled', action: vi.fn() },
389
+ { text: 'Disabled', action, disabled: true },
390
+ ]}
391
+ />,
392
+ )
393
+
394
+ fireEvent.click(getByText('Disabled'))
395
+
396
+ expect(action).not.toHaveBeenCalled()
397
+ expect(onTabSelected).not.toHaveBeenCalled()
398
+ })
399
+
400
+ it('renders disabled button tab as native disabled', () => {
401
+ const { getByText } = render(
402
+ <PktTabs>
403
+ <PktTabItem index={0} disabled>
404
+ Disabled button
405
+ </PktTabItem>
406
+ </PktTabs>,
407
+ )
408
+
409
+ expect(getByText('Disabled button')).toBeDisabled()
410
+ })
411
+
412
+ it('renders disabled link tab with aria-disabled and blocks interaction', () => {
413
+ const handleTabSelected = vi.fn()
414
+ const handleClick = vi.fn()
415
+ const { getByText } = render(
416
+ <PktTabs onTabSelected={handleTabSelected}>
417
+ <PktTabItem index={0} href="/enabled">
418
+ Enabled link
419
+ </PktTabItem>
420
+ <PktTabItem index={1} href="/disabled" disabled onClick={handleClick}>
421
+ Disabled link
422
+ </PktTabItem>
423
+ </PktTabs>,
424
+ )
425
+
426
+ const disabledLink = getByText('Disabled link')
427
+ fireEvent.click(disabledLink)
428
+ fireEvent.keyDown(disabledLink, { key: 'Enter' })
429
+
430
+ expect(disabledLink).toHaveAttribute('aria-disabled', 'true')
431
+ expect(disabledLink).toHaveAttribute('tabindex', '-1')
432
+ expect(disabledLink).not.toHaveAttribute('href')
433
+ expect(handleClick).not.toHaveBeenCalled()
434
+ expect(handleTabSelected).not.toHaveBeenCalled()
435
+ })
436
+
437
+ it('skips disabled tabs during arrow navigation', () => {
438
+ const { getByText } = render(
439
+ <PktTabs>
440
+ <PktTabItem index={0} active>
441
+ First
442
+ </PktTabItem>
443
+ <PktTabItem index={1} disabled>
444
+ Disabled
445
+ </PktTabItem>
446
+ <PktTabItem index={2}>Third</PktTabItem>
447
+ </PktTabs>,
448
+ )
449
+
450
+ const first = getByText('First')
451
+ const third = getByText('Third')
452
+
453
+ first.focus()
454
+ fireEvent.keyDown(first, { key: 'ArrowRight' })
455
+
456
+ expect(third).toHaveFocus()
457
+ })
458
+
459
+ it('prevents disabled active tab from being selectable', () => {
460
+ const handleTabSelected = vi.fn()
461
+ const { getByText } = render(
462
+ <PktTabs onTabSelected={handleTabSelected}>
463
+ <PktTabItem index={0} active disabled>
464
+ Disabled active
465
+ </PktTabItem>
466
+ <PktTabItem index={1}>Enabled</PktTabItem>
467
+ </PktTabs>,
468
+ )
469
+
470
+ const disabledTab = getByText('Disabled active')
471
+ expect(disabledTab).toHaveAttribute('aria-selected', 'false')
472
+
473
+ fireEvent.keyDown(disabledTab, { key: 'Enter' })
474
+ expect(handleTabSelected).not.toHaveBeenCalled()
475
+ })
476
+ })
477
+
380
478
  describe('Accessibility', () => {
381
479
  it('should not have any accessibility violations', async () => {
382
480
  const { container } = render(
@@ -19,8 +19,12 @@ export type TSkin = 'blue' | 'green' | 'red' | 'beige' | 'yellow' | 'grey' | 'gr
19
19
  // Context for passing tab navigation logic to children
20
20
  interface ITabsContext {
21
21
  arrowNav: boolean
22
- registerTabRef: (index: number, el: HTMLAnchorElement | HTMLButtonElement | null) => void
23
- handleKeyPress: (index: number, event: KeyboardEvent) => void
22
+ registerTabRef: (
23
+ index: number,
24
+ el: HTMLAnchorElement | HTMLButtonElement | null,
25
+ disabled?: boolean,
26
+ ) => void
27
+ handleKeyPress: (index: number, event: KeyboardEvent<HTMLAnchorElement | HTMLButtonElement>) => void
24
28
  selectTab: (index: number) => void
25
29
  }
26
30
 
@@ -38,6 +42,7 @@ export interface IPktTab {
38
42
  text: string
39
43
  href?: string
40
44
  action?: (index: number) => void
45
+ disabled?: boolean
41
46
  icon?: string
42
47
  controls?: string
43
48
  tag?: {
@@ -61,6 +66,7 @@ export const PktTabs = forwardRef(
61
66
  ref: Ref<HTMLDivElement>,
62
67
  ): JSX.Element => {
63
68
  const tabRefs = useRef<Array<HTMLAnchorElement | HTMLButtonElement | null>>([])
69
+ const disabledMap = useRef<Record<number, boolean>>({})
64
70
 
65
71
  const useArrowNav = arrowNav && !disableArrowNav
66
72
 
@@ -70,9 +76,19 @@ export const PktTabs = forwardRef(
70
76
 
71
77
  useEffect(() => {
72
78
  tabRefs.current = tabRefs.current.slice(0, tabCount)
79
+ Object.keys(disabledMap.current).forEach((key) => {
80
+ const index = Number(key)
81
+ if (index >= tabCount) delete disabledMap.current[index]
82
+ })
73
83
  }, [tabCount])
74
84
 
85
+ const isTabDisabled = (index: number): boolean => {
86
+ if (tabs) return !!tabs[index]?.disabled
87
+ return !!disabledMap.current[index]
88
+ }
89
+
75
90
  const selectTab = (index: number): void => {
91
+ if (isTabDisabled(index)) return
76
92
  const tab = tabs?.[index]
77
93
  if (tab?.action) {
78
94
  tab.action(index)
@@ -80,31 +96,56 @@ export const PktTabs = forwardRef(
80
96
  if (onTabSelected) onTabSelected(index)
81
97
  }
82
98
 
83
- const handleKeyPress = (index: number, event: KeyboardEvent) => {
84
- if (useArrowNav) {
85
- if (event.code === 'ArrowLeft' && index !== 0) {
86
- tabRefs.current[index - 1]?.focus()
87
- }
88
- if (event.code === 'ArrowRight' && index < tabCount - 1) {
89
- tabRefs.current[index + 1]?.focus()
90
- }
91
- if (event.code === 'ArrowDown' || event.code === 'Space') {
99
+ const findEnabledIndex = (startIndex: number, direction: -1 | 1): number | null => {
100
+ let current = startIndex + direction
101
+
102
+ while (current >= 0 && current < tabCount) {
103
+ if (!isTabDisabled(current)) return current
104
+ current += direction
105
+ }
106
+
107
+ return null
108
+ }
109
+
110
+ const handleKeyPress = (index: number, event: KeyboardEvent<HTMLAnchorElement | HTMLButtonElement>) => {
111
+ if (!useArrowNav) return
112
+
113
+ if (event.key === 'ArrowLeft') {
114
+ event.preventDefault()
115
+ const previousEnabled = findEnabledIndex(index, -1)
116
+ if (previousEnabled !== null) tabRefs.current[previousEnabled]?.focus()
117
+ }
118
+
119
+ if (event.key === 'ArrowRight') {
120
+ event.preventDefault()
121
+ const nextEnabled = findEnabledIndex(index, 1)
122
+ if (nextEnabled !== null) tabRefs.current[nextEnabled]?.focus()
123
+ }
124
+
125
+ if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar' || event.key === 'ArrowDown') {
126
+ event.preventDefault()
127
+ if (!isTabDisabled(index)) {
92
128
  selectTab(index)
93
129
  }
94
130
  }
95
131
  }
96
132
 
97
- const registerTabRef = (index: number, el: HTMLAnchorElement | HTMLButtonElement | null) => {
133
+ const registerTabRef = (
134
+ index: number,
135
+ el: HTMLAnchorElement | HTMLButtonElement | null,
136
+ disabled = false,
137
+ ) => {
98
138
  tabRefs.current[index] = el
139
+ disabledMap.current[index] = disabled
99
140
  }
100
141
 
101
142
  // If tabs as prop instead of children
102
143
  const tabItems = tabs?.map((tab, index) => (
103
144
  <PktTabItem
104
145
  key={index}
105
- active={tab.active}
146
+ active={tab.disabled ? false : tab.active}
147
+ disabled={tab.disabled}
106
148
  href={tab.href}
107
- onClick={() => selectTab(index)}
108
149
  icon={tab.icon}
109
150
  controls={tab.controls}
110
151
  tag={tab.tag?.text}