@oslokommune/punkt-react 13.22.0 → 14.0.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.22.0",
3
+ "version": "14.0.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,15 +39,15 @@
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.22.0",
42
+ "@oslokommune/punkt-elements": "^14.0.1",
43
43
  "classnames": "^2.5.1",
44
44
  "prettier": "^3.3.3",
45
45
  "react-hook-form": "^7.53.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@babel/plugin-transform-private-property-in-object": "^7.25.9",
49
- "@oslokommune/punkt-assets": "^13.16.0",
50
- "@oslokommune/punkt-css": "^13.22.0",
49
+ "@oslokommune/punkt-assets": "^14.0.0",
50
+ "@oslokommune/punkt-css": "^14.0.1",
51
51
  "@testing-library/jest-dom": "^6.5.0",
52
52
  "@testing-library/react": "^16.0.1",
53
53
  "@testing-library/user-event": "^14.5.2",
@@ -103,5 +103,5 @@
103
103
  "url": "https://github.com/oslokommune/punkt/issues"
104
104
  },
105
105
  "license": "MIT",
106
- "gitHead": "ac304ed1adafb9567490e5b9a8d550712f4e68f1"
106
+ "gitHead": "6bc7e06b1757136f9460e4f4e0ea1a4df772cd1f"
107
107
  }
@@ -3,15 +3,31 @@ import '@testing-library/jest-dom'
3
3
  import { fireEvent, render, screen } from '@testing-library/react'
4
4
  import { axe, toHaveNoViolations } from 'jest-axe'
5
5
  import React from 'react'
6
+ import { vi } from 'vitest'
6
7
 
7
8
  import { PktHeader } from './Header'
8
9
 
9
10
  expect.extend(toHaveNoViolations)
10
11
 
12
+ // Mock useWindowWidth hook to control viewport size
13
+ let mockedWidth = 1400
14
+ vi.mock('../../hooks/useWindowWidth', () => ({
15
+ useWindowWidth: () => mockedWidth,
16
+ }))
17
+
11
18
  describe('PktHeader', () => {
19
+ beforeAll(() => {
20
+ // jsdom does not implement scrollTo; our scroll lock hook uses it
21
+ window.scrollTo = vi.fn()
22
+ })
23
+
24
+ beforeEach(() => {
25
+ mockedWidth = 1400 // Reset to desktop by default
26
+ vi.clearAllMocks()
27
+ })
28
+
12
29
  const mockUser = {
13
30
  name: 'John Doe',
14
- shortname: 'JD',
15
31
  lastLoggedIn: '2023-08-20T12:34:56Z',
16
32
  }
17
33
 
@@ -22,59 +38,49 @@ describe('PktHeader', () => {
22
38
 
23
39
  const mockRepresenting = {
24
40
  name: 'Org Name',
25
- shortname: 'ON',
26
41
  orgNumber: '123456789',
27
42
  }
28
43
 
29
- const mockUserMenuFooter = [{ title: 'Help', target: '/help' }]
30
-
31
- it('renders header with default props', () => {
32
- render(<PktHeader />)
33
- // Add assertions to check the presence of certain elements or classes
34
- expect(screen.getByTestId('pkt-header')).toBeInTheDocument()
44
+ it('renders header with service name', () => {
45
+ const { container } = render(<PktHeader serviceName="Test Service" />)
46
+ expect(container.querySelector('.pkt-header-service')).toBeInTheDocument()
47
+ expect(screen.getByText('Test Service')).toBeInTheDocument()
35
48
  })
36
49
 
37
50
  it('renders user menu when user is present', () => {
38
- render(<PktHeader user={mockUser} userMenu={mockUserMenu} />)
39
- const userButton = screen.getByRole('button', { name: 'John Doe JD' })
51
+ render(<PktHeader serviceName="Test" user={mockUser} userMenu={mockUserMenu} />)
52
+ const userButton = screen.getByRole('button', { name: /John Doe/ })
40
53
  fireEvent.click(userButton)
41
- // Add assertions to check if user menu items are displayed
42
54
  expect(screen.getByText('Profile')).toBeInTheDocument()
43
55
  expect(screen.getByText('Settings')).toBeInTheDocument()
44
56
  })
45
57
 
46
58
  it('renders representing organization when representing is present', () => {
47
- render(<PktHeader representing={mockRepresenting} />)
48
- // Add assertions to check if representing organization info is displayed
49
- expect(screen.getAllByText('Org Name')[1]).toBeInTheDocument()
59
+ render(<PktHeader serviceName="Test" user={mockUser} representing={mockRepresenting} />)
60
+ const userButton = screen.getByRole('button', { name: /Org Name/ })
61
+ fireEvent.click(userButton)
50
62
  expect(screen.getByText('Org.nr. 123456789')).toBeInTheDocument()
51
63
  })
52
64
 
53
65
  it('calls logOut function when Log Out button is clicked', async () => {
54
- const mockLogOut = jest.fn()
55
- render(<PktHeader showLogOutButton logOut={mockLogOut} />)
56
- await window.customElements.whenDefined('pkt-button')
66
+ const mockLogOut = vi.fn()
67
+ render(<PktHeader serviceName="Test" logOutButtonPlacement="header" logOut={mockLogOut} />)
57
68
  const logOutButton = screen.getByRole('button', { name: 'Logg ut' })
58
69
  await fireEvent.click(logOutButton)
59
70
  expect(mockLogOut).toHaveBeenCalled()
60
71
  })
61
72
 
62
73
  it('toggles user menu when user button is clicked', () => {
63
- render(<PktHeader user={mockUser} userMenu={mockUserMenu} userMenuFooter={mockUserMenuFooter} />)
64
- const userButton = screen.getByRole('button', { name: 'John Doe JD' })
74
+ render(<PktHeader serviceName="Test" user={mockUser} userMenu={mockUserMenu} />)
75
+ const userButton = screen.getByRole('button', { name: /John Doe/ })
65
76
  fireEvent.click(userButton)
66
- // Add assertions to check if user menu is open
67
- expect(screen.getByTestId('usermenu').classList.contains('pkt-header--open-dropdown')).toBe(true)
68
- fireEvent.click(userButton)
69
- // Add assertions to check if user menu is closed
70
- expect(screen.getByTestId('usermenu').classList.contains('pkt-header--open-dropdown')).toBe(false)
77
+ // User menu should be open - check for menu items
78
+ expect(screen.getByText('Profile')).toBeInTheDocument()
71
79
  })
72
80
 
73
81
  describe('accessibility', () => {
74
82
  it('renders with no wcag errors with axe', async () => {
75
- const { container } = render(
76
- <PktHeader user={mockUser} userMenu={mockUserMenu} userMenuFooter={mockUserMenuFooter} />,
77
- )
83
+ const { container } = render(<PktHeader serviceName="Test" user={mockUser} userMenu={mockUserMenu} />)
78
84
  const results = await axe(container)
79
85
  expect(results).toHaveNoViolations()
80
86
  })
@@ -1,344 +1,25 @@
1
1
  'use client'
2
2
 
3
- import classNames from 'classnames'
4
- import React, { ForwardedRef, forwardRef, HTMLAttributes } from 'react'
5
-
6
- import { PktButton } from '../button/Button'
7
- import { PktIcon } from '../icon/Icon'
8
-
9
- export interface User {
10
- name?: string
11
- shortname?: string
12
- lastLoggedIn?: string | Date
13
- }
14
-
15
- export interface UserMenuItem {
16
- iconName?: string
17
- title: string
18
- target: string | (() => void)
19
- }
20
-
21
- export interface Representing {
22
- name?: string
23
- shortname?: string
24
- orgNumber?: string | number
25
- }
26
-
27
- export interface UserMenuFooterItem {
28
- title: string
29
- target: string | (() => void)
30
- }
31
-
32
- export interface IPktHeader extends HTMLAttributes<HTMLDivElement> {
33
- logoLink?: string | (() => void)
34
- serviceName?: string
35
- fixed?: boolean
36
- scrollToHide?: boolean
37
- user?: User
38
- userMenu?: UserMenuItem[]
39
- representing?: Representing
40
- userOptions?: UserMenuItem[]
41
- userMenuFooter?: UserMenuFooterItem[]
42
- canChangeRepresentation?: boolean
43
- showMenuButton?: boolean
44
- showLogOutButton?: boolean
45
- openMenu?: () => void
46
- logOut?: () => void
47
- changeRepresentation?: () => void
48
- children?: React.ReactNode | React.ReactNode[]
49
- }
50
-
51
- export const PktHeader = forwardRef(
52
- (
53
- {
54
- className,
55
- logoLink = 'https://www.oslo.kommune.no/',
56
- serviceName,
57
- fixed = true,
58
- scrollToHide = true,
59
- user,
60
- userMenu,
61
- representing,
62
- userOptions,
63
- userMenuFooter,
64
- canChangeRepresentation = true,
65
- showMenuButton = false,
66
- showLogOutButton = false,
67
- openMenu,
68
- logOut,
69
- changeRepresentation,
70
- children,
71
- ...props
72
- }: IPktHeader,
73
- ref: ForwardedRef<HTMLDivElement>,
74
- ) => {
75
- const lastLoggedIn = React.useMemo(() => {
76
- if (typeof user?.lastLoggedIn === 'string') {
77
- return user.lastLoggedIn
78
- }
79
- return user?.lastLoggedIn
80
- ? new Date(user.lastLoggedIn).toLocaleString('nb-NO', {
81
- year: 'numeric',
82
- month: 'long',
83
- day: 'numeric',
84
- })
85
- : ''
86
- }, [user])
87
-
88
- const [hidden, setHidden] = React.useState(false)
89
- const [lastScrollPosition, setLastScrollPosition] = React.useState(0)
90
- const [userMenuOpen, setUserMenuOpen] = React.useState(false)
91
-
92
- const userMenuRef = React.useRef<HTMLLIElement>(null)
93
-
94
- React.useEffect(() => {
95
- if (document) {
96
- document.addEventListener('mouseup', clickOutside)
97
- window.addEventListener('scroll', onScroll)
98
- }
99
- return () => {
100
- if (document) {
101
- document.removeEventListener('mouseup', clickOutside)
102
- window.removeEventListener('scroll', onScroll)
103
- }
104
- }
105
- })
106
-
107
- const openUserMenu = () => {
108
- setUserMenuOpen(!userMenuOpen)
109
- }
110
-
111
- const clickOutside = (e: MouseEvent) => {
112
- if (userMenuRef.current && !userMenuRef.current.contains(e.target as HTMLElement)) {
113
- setUserMenuOpen(false)
114
- }
115
- }
116
-
117
- const onScroll = () => {
118
- if (scrollToHide) {
119
- const currentScrollPosition = window.pageYOffset || document.documentElement.scrollTop
120
- if (currentScrollPosition < 0) {
121
- return
122
- }
123
- if (Math.abs(currentScrollPosition - lastScrollPosition) < 60) {
124
- return
125
- }
126
- setHidden(currentScrollPosition > lastScrollPosition)
127
- setLastScrollPosition(currentScrollPosition)
128
- }
129
- }
130
-
131
- return (
132
- <header
133
- {...props}
134
- id="pkt-header"
135
- data-testid="pkt-header"
136
- aria-label="Topp"
137
- className={classNames(className, 'pkt-header', {
138
- 'pkt-header--fixed': fixed,
139
- 'pkt-header--scroll-to-hide': scrollToHide,
140
- 'pkt-header--hidden': hidden,
141
- })}
142
- ref={ref}
143
- >
144
- <div className="pkt-header__logo">
145
- {typeof logoLink === 'string' ? (
146
- <a aria-label="Tilbake til forside" className="pkt-header__logo-link" href={logoLink}>
147
- <PktIcon
148
- name="oslologo"
149
- className="pkt-header__logo-svg"
150
- aria-hidden="true"
151
- path="https://punkt-cdn.oslo.kommune.no/latest/logos/"
152
- ></PktIcon>
153
- </a>
154
- ) : (
155
- <button
156
- aria-label="Tilbake til forside"
157
- className="pkt-link-button pkt-link pkt-header__logo-link"
158
- onClick={logoLink}
159
- >
160
- <PktIcon
161
- name="oslologo"
162
- className="pkt-header__logo-svg"
163
- aria-hidden="true"
164
- path="https://punkt-cdn.oslo.kommune.no/latest/logos/"
165
- ></PktIcon>
166
- </button>
167
- )}
168
- <span className="pkt-header__logo-service" translate="no">
169
- {serviceName}
170
- </span>
171
- </div>
172
- <nav className="pkt-header__actions">
173
- <ul className="pkt-header__actions-row">
174
- {showMenuButton && (
175
- <li>
176
- <PktButton
177
- className="pkt-header__menu-btn"
178
- skin="secondary"
179
- variant="icon-right"
180
- iconName="menu"
181
- onClick={openMenu}
182
- >
183
- Meny
184
- </PktButton>
185
- </li>
186
- )}
187
- {(user || representing) && (
188
- <li
189
- data-testid="usermenu"
190
- className={`pkt-header--has-dropdown ${userMenuOpen && !hidden ? 'pkt-header--open-dropdown' : ''}`}
191
- ref={userMenuRef}
192
- >
193
- <button
194
- className="pkt-header__user-btn pkt-btn pkt-btn--secondary pkt-btn--icons-right-and-left"
195
- type="button"
196
- role="button"
197
- aria-controls="pktUserDropdown"
198
- aria-expanded={userMenuOpen}
199
- onClick={openUserMenu}
200
- >
201
- <PktIcon name="user" className="pkt-btn__icon" />
202
- <span className="pkt-header__user-fullname" translate="no">
203
- {representing?.name || user?.name}
204
- </span>
205
- <span className="pkt-header__user-shortname" translate="no">
206
- {representing?.shortname || user?.shortname}
207
- </span>
208
- <PktIcon name="chevron-thin-down" className="pkt-btn--closed" />
209
- <PktIcon name="chevron-thin-up" className="pkt-btn--open" />
210
- </button>
211
- <ul id="pktUserDropdown" className="pkt-header__dropdown pkt-user-menu">
212
- {user && (
213
- <li>
214
- <div className="pkt-user-menu__label">Pålogget som</div>
215
- <div className="pkt-user-menu__name" translate="no">
216
- {user.name}
217
- </div>
218
- {user.lastLoggedIn && (
219
- <div className="pkt-user-menu__last-logged-in">
220
- Sist pålogget: <time>{lastLoggedIn}</time>
221
- </div>
222
- )}
223
- </li>
224
- )}
225
- {userMenu && (
226
- <li>
227
- <ul className="pkt-list">
228
- {userMenu.map((item, index) => (
229
- <li key={`userMenu-${index}`}>
230
- {typeof item.target === 'string' ? (
231
- <a href={item.target} className="pkt-link">
232
- {item.iconName && <PktIcon name={item.iconName} className="pkt-link__icon" />}
233
- {item.title}
234
- </a>
235
- ) : (
236
- <button className="pkt-link-button pkt-link" onClick={item.target}>
237
- {item.iconName && <PktIcon name={item.iconName} className="pkt-link__icon" />}
238
- {item.title}
239
- </button>
240
- )}
241
- </li>
242
- ))}
243
- </ul>
244
- </li>
245
- )}
246
- {(representing || canChangeRepresentation) && (
247
- <li>
248
- {representing && (
249
- <>
250
- <div className="pkt-user-menu__label">Representerer</div>
251
- <div className="pkt-user-menu__name" translate="no">
252
- {representing.name}
253
- </div>
254
- {representing.orgNumber && (
255
- <div className="pkt-user-menu__org-number">Org.nr. {representing.orgNumber}</div>
256
- )}
257
- </>
258
- )}
259
- <ul className="pkt-list mt-size-16">
260
- {canChangeRepresentation && (
261
- <li>
262
- <button className="pkt-link-button pkt-link" onClick={changeRepresentation}>
263
- <PktIcon name="cogwheel" className="pkt-link__icon" />
264
- Endre organisasjon
265
- </button>
266
- </li>
267
- )}
268
- </ul>
269
- </li>
270
- )}
271
- <li>
272
- <ul className="pkt-list">
273
- {(userOptions || !showLogOutButton) && (
274
- <>
275
- {userOptions?.map((item, index) => (
276
- <li key={`userOptions-${index}`}>
277
- {typeof item.target === 'string' ? (
278
- <a href={item.target} className="pkt-link">
279
- {item.iconName && <PktIcon name={item.iconName} className="pkt-link__icon" />}
280
- {item.title}
281
- </a>
282
- ) : (
283
- <button className="pkt-link-button pkt-link" onClick={item.target}>
284
- {item.iconName && <PktIcon name={item.iconName} className="pkt-link__icon" />}
285
- {item.title}
286
- </button>
287
- )}
288
- </li>
289
- ))}
290
- {!showLogOutButton && (
291
- <li>
292
- <button className="pkt-link-button pkt-link" onClick={logOut}>
293
- <PktIcon name="exit" className="pkt-link__icon" />
294
- Logg ut
295
- </button>
296
- </li>
297
- )}
298
- </>
299
- )}
300
- </ul>
301
- </li>
302
- {userMenuFooter && (
303
- <li className="footer">
304
- <ul className="pkt-list-horizontal bordered">
305
- {userMenuFooter.map((item, index) => (
306
- <li key={`userMenuFooter-${index}`}>
307
- {typeof item.target === 'string' ? (
308
- <a href={item.target} className="pkt-link">
309
- {item.title}
310
- </a>
311
- ) : (
312
- <button className="pkt-link-button pkt-link" onClick={item.target}>
313
- {item.title}
314
- </button>
315
- )}
316
- </li>
317
- ))}
318
- </ul>
319
- </li>
320
- )}
321
- </ul>
322
- </li>
323
- )}
324
- {children && <li>{children}</li>}
325
- {showLogOutButton && (
326
- <li>
327
- <PktButton
328
- className="pkt-header__user-btn pkt-header__user-btn-logout"
329
- iconName="exit"
330
- role="button"
331
- onClick={logOut}
332
- skin="secondary"
333
- variant="icon-right"
334
- >
335
- Logg ut
336
- </PktButton>
337
- </li>
338
- )}
339
- </ul>
340
- </nav>
341
- </header>
342
- )
343
- },
344
- )
3
+ import { ForwardedRef, forwardRef } from 'react'
4
+ import { PktHeaderService } from './HeaderService'
5
+ import { IPktHeader } from './types'
6
+
7
+ export * from './types'
8
+
9
+ /**
10
+ * PktHeader - Main header component for Oslo kommune services
11
+ *
12
+ * This component provides a complete header solution with:
13
+ * - Logo and service name
14
+ * - User menu with login/logout functionality
15
+ * - Search functionality
16
+ * - Responsive mobile menu
17
+ * - Fixed positioning with scroll-to-hide
18
+ *
19
+ * TODO: Add `type` prop to switch between `service` and `global` header types
20
+ */
21
+ export const PktHeader = forwardRef((props: IPktHeader, ref: ForwardedRef<HTMLDivElement>) => {
22
+ return <PktHeaderService {...props} ref={ref} />
23
+ })
24
+
25
+ PktHeader.displayName = 'PktHeader'