@oslokommune/punkt-react 16.17.4 → 16.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oslokommune/punkt-react",
3
- "version": "16.17.4",
3
+ "version": "16.19.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": "^16.17.3",
42
+ "@oslokommune/punkt-elements": "^16.19.0",
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.16.1",
53
- "@oslokommune/punkt-css": "^16.17.2",
53
+ "@oslokommune/punkt-css": "^16.18.0",
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": "28b1f5b48d08a18a1b237b2220f9f1f1e88a5b7f"
112
+ "gitHead": "e90194e9a4c25a6b7a5370c05889e262b4e6cfb0"
113
113
  }
@@ -1,6 +1,6 @@
1
1
  import '@testing-library/jest-dom'
2
2
 
3
- import { fireEvent,render, screen } from '@testing-library/react'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
4
  import { axe, toHaveNoViolations } from 'jest-axe'
5
5
  import { vi } from 'vitest'
6
6
  import { createRef } from 'react'
@@ -181,12 +181,13 @@ describe('PktAccordionItem', () => {
181
181
  </PktAccordionItem>,
182
182
  )
183
183
 
184
- await window.customElements.whenDefined('pkt-icon')
185
- const icon = container.querySelector('pkt-icon')
184
+ const icon = container.querySelector('.pkt-icon')
186
185
  expect(icon).toBeInTheDocument()
187
186
  expect(icon).toHaveAttribute('name', 'chevron-thin-down')
188
187
  expect(icon).toHaveClass('pkt-accordion-item__icon')
189
- expect(icon).toHaveAttribute('aria-hidden', 'true')
188
+ await waitFor(() =>
189
+ expect(icon?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'),
190
+ )
190
191
  })
191
192
  })
192
193
 
@@ -4,7 +4,8 @@ import classNames from 'classnames'
4
4
  import { FC, HTMLAttributes, ReactElement, ReactNode, useCallback, useState } from 'react'
5
5
  import type { TAlertRole, TAlertSkin,TAriaLive } from 'shared-types'
6
6
 
7
- import { PktButton, PktIcon } from '..'
7
+ import { PktButton } from '../button/Button'
8
+ import { PktIcon } from '../icon/Icon'
8
9
 
9
10
  export interface IPktAlert extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
10
11
  skin?: TAlertSkin
@@ -141,7 +141,7 @@ describe('PktButton icon paths', () => {
141
141
  )
142
142
 
143
143
  const icon = document.querySelector('.pkt-btn__icon:not(.pkt-btn__spinner)') as HTMLElement & { path?: string }
144
- expect(icon?.path).toBe(customPath)
144
+ expect(icon?.dataset.path).toBe(customPath)
145
145
  })
146
146
 
147
147
  test('does not set path property when iconPath is not provided', () => {
@@ -153,7 +153,7 @@ describe('PktButton icon paths', () => {
153
153
 
154
154
  const icon = document.querySelector('.pkt-btn__icon:not(.pkt-btn__spinner)') as HTMLElement & { path?: string }
155
155
  // When not provided, path uses PktIcon's default
156
- expect(icon?.path).toBe('https://punkt-cdn.oslo.kommune.no/latest/icons/')
156
+ expect(icon?.dataset.path).toBe('https://punkt-cdn.oslo.kommune.no/latest/icons/')
157
157
  })
158
158
 
159
159
  test('passes secondIconPath to second PktIcon when provided', () => {
@@ -168,7 +168,7 @@ describe('PktButton icon paths', () => {
168
168
  HTMLElement & { path?: string }
169
169
  >
170
170
  expect(icons).toHaveLength(2)
171
- expect(icons[1]?.path).toBe(customPath)
171
+ expect(icons[1]?.dataset.path).toBe(customPath)
172
172
  })
173
173
 
174
174
  test('handles both iconPath and secondIconPath independently', () => {
@@ -190,7 +190,7 @@ describe('PktButton icon paths', () => {
190
190
  HTMLElement & { path?: string }
191
191
  >
192
192
  expect(icons).toHaveLength(2)
193
- expect(icons[0]?.path).toBe(iconPath)
194
- expect(icons[1]?.path).toBe(secondIconPath)
193
+ expect(icons[0]?.dataset.path).toBe(iconPath)
194
+ expect(icons[1]?.dataset.path).toBe(secondIconPath)
195
195
  })
196
196
  })
@@ -14,7 +14,7 @@ import {
14
14
 
15
15
  import { defaultFileUploadStrings } from 'shared-types'
16
16
 
17
- import { PktIcon } from '..'
17
+ import { PktIcon } from '../icon/Icon'
18
18
  import { FileItem, TFileItemList, TUploadStrategy } from './types'
19
19
 
20
20
  /**
@@ -6,7 +6,7 @@ import {
6
6
  type TProgressState,
7
7
  } from 'shared-utils/fileupload'
8
8
 
9
- import { PktIcon } from '..'
9
+ import { PktIcon } from '../icon/Icon'
10
10
  import { OperationButton, TransferError, TransferInProgress } from './Subcomponents'
11
11
  import {
12
12
  TFileAndTransfer,
@@ -2,7 +2,9 @@ import { RefObject, useEffect, useState } from 'react'
2
2
 
3
3
  import { getDisplayFilename, trapTabInside } from 'shared-utils/fileupload'
4
4
 
5
- import { PktButton, PktIcon, PktProgressbar } from '..'
5
+ import { PktButton } from '../button/Button'
6
+ import { PktIcon } from '../icon/Icon'
7
+ import { PktProgressbar } from '../progressbar/Progressbar'
6
8
  import { PktModal } from '../modal/Modal'
7
9
  import { Truncate } from './Truncate'
8
10
  import { TFileAndTransfer, TQueueItemOperation, TQueueOperationContext, TTransferItemInProgress } from './types'
@@ -81,8 +81,7 @@ describe('PktHeaderMenu rendering', () => {
81
81
 
82
82
  test('maps ODS icon names on service links', async () => {
83
83
  const { container } = render(<PktHeaderMenu data={LOCAL_DATA} open />)
84
- await window.customElements.whenDefined('pkt-icon')
85
- const icons = container.querySelectorAll<HTMLElement>('.pkt-header-menu__service-icon')
84
+ const icons = container.querySelectorAll<HTMLElement>('.pkt-header-menu__service-icon')
86
85
  await waitFor(() => expect(icons[0]).toHaveAttribute('name', '24h'))
87
86
  expect(icons[1]).toHaveAttribute('name', 'shield-fire')
88
87
  // Unmapped passes through
@@ -123,15 +122,14 @@ describe('PktHeaderMenu rendering', () => {
123
122
 
124
123
  test('renders social links with derived aria-labels and icons', async () => {
125
124
  const { container } = render(<PktHeaderMenu data={LOCAL_DATA} open />)
126
- await window.customElements.whenDefined('pkt-icon')
127
- const social = container.querySelectorAll<HTMLElement>('.pkt-header-menu__social-link')
125
+ const social = container.querySelectorAll<HTMLElement>('.pkt-header-menu__social-link')
128
126
  expect(social).toHaveLength(2)
129
127
  expect(social[0]).toHaveAttribute('aria-label', 'Facebook')
130
128
  expect(social[1]).toHaveAttribute('aria-label', 'LinkedIn')
131
129
  await waitFor(() =>
132
- expect(social[0].querySelector('pkt-icon')).toHaveAttribute('name', 'facebook'),
130
+ expect(social[0].querySelector('.pkt-icon')).toHaveAttribute('name', 'facebook'),
133
131
  )
134
- expect(social[1].querySelector('pkt-icon')).toHaveAttribute('name', 'linkedin')
132
+ expect(social[1].querySelector('.pkt-icon')).toHaveAttribute('name', 'linkedin')
135
133
  })
136
134
 
137
135
  test('calls onDataLoaded with the supplied payload', async () => {
@@ -1,20 +1,19 @@
1
1
  import '@testing-library/jest-dom'
2
2
 
3
- import { render, screen, waitFor } from '@testing-library/react'
3
+ import { render } from '@testing-library/react'
4
4
  import { axe, toHaveNoViolations } from 'jest-axe'
5
- import { createRef } from 'react'
6
5
 
7
6
  import { PktHeading } from './Heading'
8
7
 
9
8
  expect.extend(toHaveNoViolations)
10
9
 
11
10
  describe('PktHeading', () => {
12
- test('renders without errors', async () => {
13
- render(<PktHeading />)
14
- await window.customElements.whenDefined('pkt-heading')
11
+ test('renders without errors', () => {
12
+ const { container } = render(<PktHeading />)
13
+ expect(container.querySelector('h2')).toBeInTheDocument()
15
14
  })
16
15
 
17
- test('renders with different levels', async () => {
16
+ test('renders with different levels', () => {
18
17
  const { container } = render(
19
18
  <>
20
19
  <PktHeading level={1}>Heading 1</PktHeading>
@@ -22,39 +21,41 @@ describe('PktHeading', () => {
22
21
  <PktHeading level={3}>Heading 3</PktHeading>
23
22
  </>,
24
23
  )
25
- await window.customElements.whenDefined('pkt-heading')
26
24
 
27
- const headings = container.querySelectorAll('pkt-heading')
28
- expect(headings[0]).toHaveAttribute('aria-level', '1')
29
- expect(headings[1]).toHaveAttribute('aria-level', '2')
30
- expect(headings[2]).toHaveAttribute('aria-level', '3')
25
+ expect(container.querySelector('h1')).toHaveTextContent('Heading 1')
26
+ expect(container.querySelector('h2')).toHaveTextContent('Heading 2')
27
+ expect(container.querySelector('h3')).toHaveTextContent('Heading 3')
31
28
  })
32
29
 
33
- test('renders with different sizes', async () => {
34
- const ref = createRef<any>()
30
+ test('applies default size class based on level', () => {
31
+ const { container } = render(<PktHeading level={1}>Heading</PktHeading>)
32
+ expect(container.querySelector('h1')).toHaveClass('pkt-heading--xlarge')
33
+ })
34
+
35
+ test('renders with explicit size', () => {
35
36
  const { container } = render(<PktHeading size="xlarge">Large Heading</PktHeading>)
36
- await window.customElements.whenDefined('pkt-heading')
37
+ const heading = container.querySelector('h2')
38
+ expect(heading).toHaveClass('pkt-heading--xlarge')
39
+ })
37
40
 
38
- const headingElement = container.querySelector('pkt-heading')
39
- expect(headingElement).toHaveClass('pkt-heading--xlarge')
40
- expect(headingElement).toHaveAttribute('size', 'xlarge')
41
+ test('renders with explicit weight', () => {
42
+ const { container } = render(<PktHeading weight="bold">Bold Heading</PktHeading>)
43
+ expect(container.querySelector('h2')).toHaveClass('pkt-heading--fw-bold')
41
44
  })
42
45
 
43
- test('renders with visually hidden prop', async () => {
46
+ test('renders with visually hidden prop', () => {
44
47
  const { container } = render(<PktHeading visuallyHidden>Hidden Heading</PktHeading>)
45
- await window.customElements.whenDefined('pkt-heading')
46
-
47
- const headingElement = container.querySelector('pkt-heading')
48
- expect(headingElement).toHaveClass('pkt-sr-only')
49
- expect(headingElement).toHaveAttribute('visuallyHidden')
48
+ expect(container.querySelector('h2')).toHaveClass('pkt-sr-only')
50
49
  })
51
50
 
52
- test('renders with align props', async () => {
51
+ test('renders with align prop', () => {
53
52
  const { container } = render(<PktHeading align="center">Center Heading</PktHeading>)
54
- await window.customElements.whenDefined('pkt-heading')
53
+ expect(container.querySelector('h2')).toHaveClass('pkt-heading--center')
54
+ })
55
55
 
56
- const headingElement = container.querySelector('pkt-heading')
57
- expect(headingElement).toHaveAttribute('align', 'center')
56
+ test('always carries the pkt-heading base class', () => {
57
+ const { container } = render(<PktHeading>Heading</PktHeading>)
58
+ expect(container.querySelector('h2')).toHaveClass('pkt-heading')
58
59
  })
59
60
 
60
61
  describe('accessibility', () => {
@@ -64,7 +65,6 @@ describe('PktHeading', () => {
64
65
  Accessible Heading
65
66
  </PktHeading>,
66
67
  )
67
- await window.customElements.whenDefined('pkt-heading')
68
68
  const results = await axe(container)
69
69
  expect(results).toHaveNoViolations()
70
70
  })
@@ -1,29 +1,77 @@
1
1
  'use client'
2
2
 
3
- import { createComponent } from '@lit/react'
4
- import { IPktHeading as IPktElHeading, PktHeading as PktElHeading } from '@oslokommune/punkt-elements'
5
- // eslint-disable-next-line no-restricted-syntax -- React is required for createComponent
6
- import React, { FC, forwardRef, ForwardRefExoticComponent, type LegacyRef, type ReactNode } from 'react'
3
+ import classNames from 'classnames'
4
+ import { forwardRef, HTMLAttributes, ReactNode } from 'react'
5
+ import type { THeadingLevel, THeadingSize, THeadingWeight } from 'shared-types'
7
6
 
8
- import { PktElConstructor } from '@/interfaces/IPktElements'
7
+ export type TPktHeadingLevel = THeadingLevel
8
+ export type TPktHeadingSize = THeadingSize
9
+ export type TPktHeadingWeight = THeadingWeight
9
10
 
10
- export interface IPktHeading extends IPktElHeading {
11
- children?: ReactNode | ReactNode[]
12
- ref?: LegacyRef<HTMLElement>
11
+ export interface IPktHeading extends HTMLAttributes<HTMLHeadingElement> {
12
+ size?: TPktHeadingSize
13
+ level?: TPktHeadingLevel
14
+ weight?: TPktHeadingWeight
15
+ visuallyHidden?: boolean
16
+ align?: 'start' | 'center' | 'end'
17
+ children?: ReactNode
13
18
  }
14
19
 
15
- const LitComponent: FC<IPktHeading> = createComponent({
16
- tagName: 'pkt-heading',
17
- elementClass: PktElHeading as PktElConstructor<HTMLElement>,
18
- react: React,
19
- displayName: 'PktHeading',
20
- }) as ForwardRefExoticComponent<IPktHeading>
20
+ const defaultSizeForLevel = (level: TPktHeadingLevel): TPktHeadingSize => {
21
+ switch (level) {
22
+ case 1:
23
+ return 'xlarge'
24
+ case 2:
25
+ return 'large'
26
+ case 3:
27
+ return 'medium'
28
+ case 4:
29
+ return 'small'
30
+ case 5:
31
+ case 6:
32
+ return 'xsmall'
33
+ default:
34
+ return 'medium'
35
+ }
36
+ }
37
+
38
+ const defaultWeightForLevel = (level: TPktHeadingLevel): TPktHeadingWeight => {
39
+ switch (level) {
40
+ case 1:
41
+ case 2:
42
+ return 'regular'
43
+ case 3:
44
+ case 4:
45
+ case 5:
46
+ case 6:
47
+ return 'medium'
48
+ default:
49
+ return 'medium'
50
+ }
51
+ }
52
+
53
+ export const PktHeading = forwardRef<HTMLHeadingElement, IPktHeading>(function PktHeading(
54
+ { level = 2, size, weight, visuallyHidden, align, className, children, ...rest },
55
+ ref,
56
+ ) {
57
+ const effectiveSize = size ?? defaultSizeForLevel(level)
58
+ const effectiveWeight = weight ?? defaultWeightForLevel(level)
59
+
60
+ const classes = classNames(
61
+ 'pkt-heading',
62
+ `pkt-heading--${effectiveSize}`,
63
+ `pkt-heading--fw-${effectiveWeight}`,
64
+ align && `pkt-heading--${align}`,
65
+ visuallyHidden && 'pkt-sr-only',
66
+ className,
67
+ )
68
+
69
+ const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
21
70
 
22
- export const PktHeading: FC<IPktHeading> = forwardRef(({ children, ...props }: IPktHeading, ref) => {
23
71
  return (
24
- <LitComponent data-testid="pkt-heading" {...props} ref={ref}>
72
+ <Tag {...rest} ref={ref} className={classes} data-testid="pkt-heading">
25
73
  {children}
26
- </LitComponent>
74
+ </Tag>
27
75
  )
28
76
  })
29
77
 
@@ -21,11 +21,11 @@ describe('PktIcon', () => {
21
21
  describe('with default fetcher', () => {
22
22
  test('fetches SVG with default fetcher', async () => {
23
23
  await render(<PktIcon name="arrow" />)
24
- await window.customElements.whenDefined('pkt-icon')
25
24
 
26
- const icon = await screen.findByRole('img')
25
+ const icon = await screen.findByTestId('icon')
27
26
  expect(icon).toMatchInlineSnapshot(`
28
27
  <svg
28
+ aria-hidden="true"
29
29
  data-filename="stiarrow.svg"
30
30
  data-testid="icon"
31
31
  role="img"
@@ -43,7 +43,6 @@ describe('PktIcon', () => {
43
43
  <PktIcon name={'some-icon'} />
44
44
  </div>,
45
45
  )
46
- await window.customElements.whenDefined('pkt-icon')
47
46
 
48
47
  await waitFor(async () => {
49
48
  const icons = await screen.findAllByTestId('icon')
@@ -51,4 +50,70 @@ describe('PktIcon', () => {
51
50
  })
52
51
  })
53
52
  })
53
+
54
+ describe('aria-hidden', () => {
55
+ test('defaults to true on the SVG', async () => {
56
+ await render(<PktIcon name="arrow" />)
57
+ const svg = await screen.findByTestId('icon')
58
+
59
+ expect(svg).toHaveAttribute('aria-hidden', 'true')
60
+ expect(svg).not.toHaveAttribute('aria-label')
61
+ })
62
+
63
+ test('ariaHidden={false} sets aria-hidden="false" and aria-label fallback to name', async () => {
64
+ await render(<PktIcon name="arrow" ariaHidden={false} />)
65
+ const svg = await screen.findByTestId('icon')
66
+
67
+ expect(svg).toHaveAttribute('aria-hidden', 'false')
68
+ expect(svg).toHaveAttribute('aria-label', 'arrow')
69
+ })
70
+
71
+ test('ariaHidden="false" string is treated as boolean false', async () => {
72
+ await render(<PktIcon name="arrow" ariaHidden="false" />)
73
+ const svg = await screen.findByTestId('icon')
74
+
75
+ expect(svg).toHaveAttribute('aria-hidden', 'false')
76
+ })
77
+
78
+ test('ariaHidden="true" string is treated as boolean true', async () => {
79
+ await render(<PktIcon name="arrow" ariaHidden="true" />)
80
+ const svg = await screen.findByTestId('icon')
81
+
82
+ expect(svg).toHaveAttribute('aria-hidden', 'true')
83
+ })
84
+
85
+ test('back-compat: aria-hidden kebab attribute still routes to the SVG', async () => {
86
+ const { container } = await render(<PktIcon name="arrow" aria-hidden={false} />)
87
+ const svg = await screen.findByTestId('icon')
88
+
89
+ expect(svg).toHaveAttribute('aria-hidden', 'false')
90
+ // Wrapper span itself should not carry aria-hidden
91
+ expect(container.querySelector('.pkt-icon')).not.toHaveAttribute('aria-hidden')
92
+ })
93
+ })
94
+
95
+ describe('aria-label', () => {
96
+ test('ariaLabel alone makes aria-hidden default to false', async () => {
97
+ await render(<PktIcon name="arrow" ariaLabel="Gå til forsiden" />)
98
+ const svg = await screen.findByTestId('icon')
99
+
100
+ expect(svg).toHaveAttribute('aria-hidden', 'false')
101
+ expect(svg).toHaveAttribute('aria-label', 'Gå til forsiden')
102
+ })
103
+
104
+ test('ariaLabel + ariaHidden={true} keeps aria-hidden="true" and omits aria-label', async () => {
105
+ await render(<PktIcon name="arrow" ariaLabel="Lukk" ariaHidden={true} />)
106
+ const svg = await screen.findByTestId('icon')
107
+
108
+ expect(svg).toHaveAttribute('aria-hidden', 'true')
109
+ expect(svg).not.toHaveAttribute('aria-label')
110
+ })
111
+
112
+ test('ariaHidden={false} without ariaLabel falls back to the icon name', async () => {
113
+ await render(<PktIcon name="chevron-right" ariaHidden={false} />)
114
+ const svg = await screen.findByTestId('icon')
115
+
116
+ expect(svg).toHaveAttribute('aria-label', 'chevron-right')
117
+ })
118
+ })
54
119
  })
@@ -1,23 +1,182 @@
1
1
  'use client'
2
2
 
3
- import { createComponent } from '@lit/react'
4
- import { PktIcon as PktEl } from '@oslokommune/punkt-elements'
5
- // eslint-disable-next-line no-restricted-syntax -- React is required for createComponent
6
- import React, { FC } from 'react'
3
+ import classNames from 'classnames'
4
+ import {
5
+ forwardRef,
6
+ HTMLAttributes,
7
+ MutableRefObject,
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useRef,
11
+ } from 'react'
7
12
  import type { Booleanish } from 'shared-types'
8
13
 
9
- import { PktElConstructor, PktElType } from '@/interfaces/IPktElements'
14
+ const defaultPath = 'https://punkt-cdn.oslo.kommune.no/latest/icons/'
10
15
 
11
- interface IPktIcon extends PktElType {
16
+ if (typeof window !== 'undefined') {
17
+ window.pktFetch =
18
+ window.pktFetch === undefined ? (fetch as unknown as typeof window.pktFetch) : window.pktFetch
19
+ window.pktIconPath = window.pktIconPath || defaultPath
20
+ }
21
+
22
+ const errorSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"></svg>'
23
+ const dlCache: { [key: string]: Promise<string> } = {}
24
+ const MAX_RETRIES = 2
25
+ const RETRY_DELAY = 1500
26
+
27
+ const isSessionStorageAvailable =
28
+ typeof Storage !== 'undefined' && typeof sessionStorage !== 'undefined'
29
+
30
+ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
31
+
32
+ const getCachedIconSync = (name: string, path: string | undefined): string => {
33
+ if (!name || !isSessionStorageAvailable) return ''
34
+ return sessionStorage.getItem(path + name + '.svg') ?? ''
35
+ }
36
+
37
+ const fetchIcon = (url: string): Promise<string> =>
38
+ (window.pktFetch as unknown as typeof fetch)(url).then((response) => {
39
+ if (!response.ok) {
40
+ throw new Error('Missing icon: ' + url)
41
+ }
42
+ return response.text()
43
+ })
44
+
45
+ const fetchIconWithRetry = async (url: string, retries = MAX_RETRIES): Promise<string> => {
46
+ try {
47
+ return await fetchIcon(url)
48
+ } catch (error) {
49
+ if (retries > 0) {
50
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY))
51
+ return fetchIconWithRetry(url, retries - 1)
52
+ }
53
+ // eslint-disable-next-line no-console
54
+ console.error('Failed to fetch icon: ' + url)
55
+ return errorSvg
56
+ }
57
+ }
58
+
59
+ const downloadIconOrGetFromCache = async (
60
+ name: string,
61
+ path: string | undefined,
62
+ ): Promise<string | null> => {
63
+ const key = path + name + '.svg'
64
+
65
+ if (isSessionStorageAvailable && sessionStorage.getItem(key)) {
66
+ return sessionStorage.getItem(key)
67
+ }
68
+
69
+ if (key in dlCache) {
70
+ return dlCache[key]
71
+ }
72
+
73
+ if (typeof window !== 'undefined' && typeof window.pktFetch === 'function') {
74
+ dlCache[key] = fetchIconWithRetry(key).then((text) => {
75
+ if (text !== errorSvg && isSessionStorageAvailable) {
76
+ sessionStorage.setItem(key, text)
77
+ }
78
+ delete dlCache[key]
79
+ return text
80
+ })
81
+ return dlCache[key]
82
+ }
83
+ return errorSvg
84
+ }
85
+
86
+ interface IPktIcon extends HTMLAttributes<HTMLElement> {
12
87
  name?: string
13
88
  path?: string
14
- 'aria-hidden'?: Booleanish | undefined
89
+ ariaHidden?: Booleanish
90
+ ariaLabel?: string
91
+ }
92
+
93
+ const toBoolean = (v: Booleanish | undefined): boolean | undefined => {
94
+ if (v === undefined) return undefined
95
+ return v === true || v === 'true'
15
96
  }
16
97
 
17
- export const PktIcon: FC<IPktIcon> = createComponent({
18
- tagName: 'pkt-icon',
19
- elementClass: PktEl as PktElConstructor<HTMLElement>,
20
- react: React,
21
- displayName: 'PktIcon',
22
- events: {},
98
+ export const PktIcon = forwardRef<HTMLSpanElement, IPktIcon>(function PktIcon(
99
+ {
100
+ name = '',
101
+ path,
102
+ className,
103
+ ariaHidden,
104
+ ariaLabel,
105
+ 'aria-hidden': ariaHiddenAttr,
106
+ 'aria-label': ariaLabelAttr,
107
+ ...rest
108
+ },
109
+ forwardedRef,
110
+ ) {
111
+ const resolvedPath =
112
+ path ?? (typeof window !== 'undefined' ? window.pktIconPath : defaultPath)
113
+ const elementRef = useRef<HTMLSpanElement | null>(null)
114
+
115
+ const explicitAriaHidden = toBoolean(ariaHidden) ?? toBoolean(ariaHiddenAttr)
116
+ const explicitAriaLabel = ariaLabel ?? ariaLabelAttr
117
+ const effectiveAriaHidden =
118
+ explicitAriaHidden !== undefined ? explicitAriaHidden : !explicitAriaLabel
119
+ const effectiveAriaLabel = effectiveAriaHidden ? undefined : explicitAriaLabel || name
120
+
121
+ useIsomorphicLayoutEffect(() => {
122
+ const el = elementRef.current
123
+ if (!el) return
124
+
125
+ const applyAria = (root: HTMLElement) => {
126
+ const svg = root.querySelector('svg')
127
+ if (!svg) return
128
+ svg.setAttribute('aria-hidden', String(effectiveAriaHidden))
129
+ if (effectiveAriaLabel) {
130
+ svg.setAttribute('aria-label', effectiveAriaLabel)
131
+ } else {
132
+ svg.removeAttribute('aria-label')
133
+ }
134
+ }
135
+
136
+ if (!name) {
137
+ el.innerHTML = ''
138
+ return
139
+ }
140
+
141
+ const cached = getCachedIconSync(name, resolvedPath)
142
+ if (cached) {
143
+ el.innerHTML = cached
144
+ applyAria(el)
145
+ return
146
+ }
147
+
148
+ let cancelled = false
149
+ downloadIconOrGetFromCache(name, resolvedPath).then((text) => {
150
+ if (cancelled) return
151
+ const current = elementRef.current
152
+ if (current) {
153
+ current.innerHTML = text || errorSvg
154
+ applyAria(current)
155
+ }
156
+ })
157
+ return () => {
158
+ cancelled = true
159
+ }
160
+ }, [name, resolvedPath, effectiveAriaHidden, effectiveAriaLabel])
161
+
162
+ const setRef = (el: HTMLSpanElement | null) => {
163
+ elementRef.current = el
164
+ if (typeof forwardedRef === 'function') {
165
+ forwardedRef(el)
166
+ } else if (forwardedRef) {
167
+ ;(forwardedRef as MutableRefObject<HTMLSpanElement | null>).current = el
168
+ }
169
+ }
170
+
171
+ return (
172
+ <span
173
+ {...rest}
174
+ ref={setRef}
175
+ className={classNames('pkt-icon', className)}
176
+ {...(name ? { name } : {})}
177
+ data-path={resolvedPath}
178
+ />
179
+ )
23
180
  })
181
+
182
+ PktIcon.displayName = 'PktIcon'
@@ -3,7 +3,7 @@
3
3
  import classNames from 'classnames'
4
4
  import { FC, LinkHTMLAttributes } from 'react'
5
5
 
6
- import { PktIcon } from '..'
6
+ import { PktIcon } from '../icon/Icon'
7
7
 
8
8
  interface IPktLink extends LinkHTMLAttributes<HTMLAnchorElement> {
9
9
  href?: string