@oslokommune/punkt-react 16.18.0 → 16.20.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.18.0",
3
+ "version": "16.20.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.18.0",
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"
@@ -109,5 +109,5 @@
109
109
  "url": "https://github.com/oslokommune/punkt/issues"
110
110
  },
111
111
  "license": "MIT",
112
- "gitHead": "693c10d8d7a30c6b5c8acb6a7cb072b0afe50554"
112
+ "gitHead": "dee5033c347f7c10530f57d5dc05c5411b75033f"
113
113
  }
@@ -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
 
@@ -0,0 +1,98 @@
1
+ import '@testing-library/jest-dom'
2
+
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+ import { vi } from 'vitest'
5
+
6
+ import { PktHelptext } from './Helptext'
7
+
8
+ describe('PktHelptext', () => {
9
+ test('renders the container without the has-helptext modifier when empty', () => {
10
+ const { container } = render(<PktHelptext forId="field" />)
11
+
12
+ const root = container.querySelector('.pkt-inputwrapper__helptext-container')
13
+ expect(root).toBeInTheDocument()
14
+ expect(root).not.toHaveClass('pkt-inputwrapper__has-helptext')
15
+ })
16
+
17
+ test('renders helptext string as HTML inside the helptext div', () => {
18
+ const { container } = render(<PktHelptext forId="field" helptext="Hjelp <strong>her</strong>" />)
19
+
20
+ const helptext = container.querySelector(`#field-helptext`)
21
+ expect(helptext).toBeInTheDocument()
22
+ expect(helptext?.querySelector('strong')).toHaveTextContent('her')
23
+ expect(container.querySelector('.pkt-inputwrapper__helptext-container')).toHaveClass(
24
+ 'pkt-inputwrapper__has-helptext',
25
+ )
26
+ })
27
+
28
+ test('renders helptext ReactNode directly', () => {
29
+ render(
30
+ <PktHelptext forId="field" helptext={<em data-testid="rn">Rich</em>} />,
31
+ )
32
+
33
+ expect(screen.getByTestId('rn')).toHaveTextContent('Rich')
34
+ })
35
+
36
+ test('renders children inside pkt-contents', () => {
37
+ const { container } = render(
38
+ <PktHelptext forId="field">
39
+ <span data-testid="slot">Slot content</span>
40
+ </PktHelptext>,
41
+ )
42
+
43
+ const contents = container.querySelector('.pkt-contents')
44
+ expect(contents).toBeInTheDocument()
45
+ expect(contents?.querySelector('[data-testid="slot"]')).toHaveTextContent('Slot content')
46
+ })
47
+
48
+ test('renders the dropdown toggle button with the default label when no button text is provided', () => {
49
+ render(<PktHelptext forId="field" helptextDropdown="Mer info" />)
50
+
51
+ const button = screen.getByRole('button')
52
+ expect(button.querySelector('.pkt-btn__text')).toHaveTextContent('Les mer')
53
+ })
54
+
55
+ test('renders a custom helptextDropdownButton as HTML', () => {
56
+ render(
57
+ <PktHelptext
58
+ forId="field"
59
+ helptextDropdown="Mer info"
60
+ helptextDropdownButton="Vis <em>detaljer</em>"
61
+ />,
62
+ )
63
+
64
+ const button = screen.getByRole('button')
65
+ expect(button.querySelector('em')).toHaveTextContent('detaljer')
66
+ })
67
+
68
+ test('toggles the dropdown open class and calls onToggleHelpText', () => {
69
+ const onToggle = vi.fn()
70
+ const { container } = render(
71
+ <PktHelptext
72
+ forId="field"
73
+ helptextDropdown="Mer info"
74
+ onToggleHelpText={onToggle}
75
+ />,
76
+ )
77
+
78
+ const expandable = container.querySelector('.pkt-inputwrapper__helptext-expandable')
79
+ const innerHelptext = expandable?.querySelector('.pkt-inputwrapper__helptext')
80
+ expect(innerHelptext).toHaveClass('pkt-inputwrapper__helptext-expandable-closed')
81
+
82
+ fireEvent.click(screen.getByRole('button'))
83
+ expect(innerHelptext).toHaveClass('pkt-inputwrapper__helptext-expandable-open')
84
+ expect(onToggle).toHaveBeenLastCalledWith(true)
85
+
86
+ fireEvent.click(screen.getByRole('button'))
87
+ expect(innerHelptext).toHaveClass('pkt-inputwrapper__helptext-expandable-closed')
88
+ expect(onToggle).toHaveBeenLastCalledWith(false)
89
+ })
90
+
91
+ test('does not render an id on the helptext div when forId is missing', () => {
92
+ const { container } = render(<PktHelptext helptext="No id here" />)
93
+
94
+ const helptext = container.querySelector('.pkt-inputwrapper__helptext')
95
+ expect(helptext).toBeInTheDocument()
96
+ expect(helptext).not.toHaveAttribute('id')
97
+ })
98
+ })
@@ -1,37 +1,95 @@
1
1
  'use client'
2
2
 
3
- import { createComponent, EventName } from '@lit/react'
4
- import { PktHelptext as PktElHelptext } from '@oslokommune/punkt-elements'
5
- // eslint-disable-next-line no-restricted-syntax -- React is required for createComponent
6
- import React, { FC, forwardRef, ForwardRefExoticComponent, HTMLAttributes, LegacyRef, type ReactNode } from 'react'
3
+ import classNames from 'classnames'
4
+ import { forwardRef, HTMLAttributes, ReactNode, useState } from 'react'
5
+
6
+ import { PktIcon } from '../icon/Icon'
7
+
8
+ const defaultDropdownButton = "Les mer <span class='pkt-sr-only'>om feltet</span>"
7
9
 
8
10
  export interface IPktHelptextProps extends HTMLAttributes<HTMLDivElement> {
9
- helptext?: string | ReactNode | ReactNode[]
10
- helptextDropdown?: string | ReactNode | ReactNode[]
11
+ helptext?: string | ReactNode
12
+ helptextDropdown?: string | ReactNode
11
13
  helptextDropdownButton?: string
12
14
  forId?: string
13
- ref?: LegacyRef<HTMLDivElement>
14
- onToggleHelpText?: (e: CustomEvent) => void
15
+ onToggleHelpText?: (isOpen: boolean) => void
15
16
  }
16
17
 
17
- export const LitComponent = createComponent({
18
- tagName: 'pkt-helptext',
19
- elementClass: PktElHelptext,
20
- react: React,
21
- displayName: 'PktHelptext',
22
- events: {
23
- onToggleHelpText: 'toggleHelpText' as EventName<CustomEvent>,
24
- },
25
- }) as ForwardRefExoticComponent<IPktHelptextProps>
26
-
27
- export const PktHelptext: FC<IPktHelptextProps> = forwardRef(
28
- ({ children, ...props }, ref: LegacyRef<HTMLDivElement>) => {
29
- return (
30
- <LitComponent {...props} ref={ref}>
31
- <div className="pkt-contents">{children}</div>
32
- </LitComponent>
33
- )
18
+ const renderContent = (content: string | ReactNode | undefined): ReactNode => {
19
+ if (content === undefined || content === null || content === '') return null
20
+ if (typeof content === 'string') {
21
+ return <span dangerouslySetInnerHTML={{ __html: content }} />
22
+ }
23
+ return content
24
+ }
25
+
26
+ export const PktHelptext = forwardRef<HTMLDivElement, IPktHelptextProps>(function PktHelptext(
27
+ {
28
+ helptext,
29
+ helptextDropdown,
30
+ helptextDropdownButton,
31
+ forId,
32
+ onToggleHelpText,
33
+ className,
34
+ children,
35
+ ...rest
34
36
  },
35
- )
37
+ ref,
38
+ ) {
39
+ const [isOpen, setIsOpen] = useState(false)
40
+
41
+ const toggle = () => {
42
+ const next = !isOpen
43
+ setIsOpen(next)
44
+ onToggleHelpText?.(next)
45
+ }
46
+
47
+ const hasContent = Boolean(helptext || helptextDropdown || children)
48
+
49
+ const containerClasses = classNames(
50
+ 'pkt-inputwrapper__helptext-container',
51
+ hasContent && 'pkt-inputwrapper__has-helptext',
52
+ className,
53
+ )
54
+
55
+ return (
56
+ <div {...rest} ref={ref} className={containerClasses}>
57
+ <div className="pkt-inputwrapper__helptext" id={forId ? `${forId}-helptext` : undefined}>
58
+ {children && <div className="pkt-contents">{children}</div>}
59
+ {renderContent(helptext)}
60
+ </div>
61
+ {helptextDropdown && (
62
+ <div className="pkt-inputwrapper__helptext-expandable">
63
+ <button
64
+ type="button"
65
+ className="pkt-link pkt-link--icon-right pkt-btn pkt-btn--small pkt-btn--tertiary pkt-btn--icon-right"
66
+ onClick={toggle}
67
+ >
68
+ <PktIcon
69
+ className="pkt-btn__icon"
70
+ name={isOpen ? 'chevron-thin-up' : 'chevron-thin-down'}
71
+ />
72
+ <span
73
+ className="pkt-btn__text"
74
+ dangerouslySetInnerHTML={{
75
+ __html: helptextDropdownButton ?? defaultDropdownButton,
76
+ }}
77
+ />
78
+ </button>
79
+ <div
80
+ className={classNames(
81
+ 'pkt-inputwrapper__helptext',
82
+ isOpen
83
+ ? 'pkt-inputwrapper__helptext-expandable-open'
84
+ : 'pkt-inputwrapper__helptext-expandable-closed',
85
+ )}
86
+ >
87
+ {renderContent(helptextDropdown)}
88
+ </div>
89
+ </div>
90
+ )}
91
+ </div>
92
+ )
93
+ })
36
94
 
37
95
  PktHelptext.displayName = 'PktHelptext'
@@ -1,10 +1,10 @@
1
1
  'use client'
2
2
 
3
- import { ForwardedRef, forwardRef, type ReactNode, RefAttributes, useState } from 'react'
3
+ import { ForwardedRef, forwardRef, type ReactNode, RefAttributes } from 'react'
4
4
 
5
5
  import { useFocusModality } from '../../hooks/useFocusModality'
6
6
  import { PktAlert } from '../alert/Alert'
7
- import { PktButton } from '../button/Button'
7
+ import { PktHelptext } from '../helptext/Helptext'
8
8
 
9
9
  export interface IPktInputWrapper extends RefAttributes<HTMLElement> {
10
10
  forId: string
@@ -63,8 +63,6 @@ export const PktInputWrapper = forwardRef(
63
63
  }: IPktInputWrapper,
64
64
  ref: ForwardedRef<HTMLDivElement>,
65
65
  ) => {
66
- const [isHelpTextOpen, setIsHelpTextOpen] = useState(false)
67
-
68
66
  const focusModality = useFocusModality<HTMLDivElement>(ref)
69
67
 
70
68
  const describedBy = ariaDescribedby || (helptext ? `${forId}-helptext` : undefined)
@@ -72,8 +70,6 @@ export const PktInputWrapper = forwardRef(
72
70
  const counterTop = showCounter && counterPosition === 'top'
73
71
  const counterBottom = showCounter && counterPosition === 'bottom'
74
72
 
75
- const toggleHelpText = () => setIsHelpTextOpen((prev) => !prev)
76
-
77
73
  const wrapperClasses = [
78
74
  'pkt-inputwrapper',
79
75
  className,
@@ -134,43 +130,16 @@ export const PktInputWrapper = forwardRef(
134
130
  }
135
131
 
136
132
  const renderHelptext = () => {
137
- if (!helptext && !helptextDropdown) return null
133
+ const visibleHelptext = useWrapper ? helptext : undefined
134
+ if (!visibleHelptext && !helptextDropdown) return null
138
135
 
139
136
  return (
140
- <>
141
- {helptext && useWrapper && (
142
- <div className="pkt-inputwrapper__helptext" id={`${forId}-helptext`}>
143
- {helptext}
144
- </div>
145
- )}
146
- {helptextDropdown && (
147
- <div className="pkt-inputwrapper__helptext-expandable">
148
- <PktButton
149
- skin="tertiary"
150
- size="small"
151
- variant="icon-right"
152
- iconName={isHelpTextOpen ? 'chevron-thin-up' : 'chevron-thin-down'}
153
- className="pkt-link pkt-link--icon-right"
154
- onClick={toggleHelpText}
155
- >
156
- <span
157
- dangerouslySetInnerHTML={{
158
- __html: helptextDropdownButton ?? 'Les mer <span class="pkt-sr-only">om inputfeltet</span>',
159
- }}
160
- />
161
- </PktButton>
162
- <div
163
- className={`pkt-inputwrapper__helptext ${
164
- isHelpTextOpen
165
- ? 'pkt-inputwrapper__helptext-expandable-open'
166
- : 'pkt-inputwrapper__helptext-expandable-closed'
167
- }`}
168
- >
169
- {helptextDropdown}
170
- </div>
171
- </div>
172
- )}
173
- </>
137
+ <PktHelptext
138
+ forId={forId}
139
+ helptext={visibleHelptext}
140
+ helptextDropdown={helptextDropdown}
141
+ helptextDropdownButton={helptextDropdownButton}
142
+ />
174
143
  )
175
144
  }
176
145