@oslokommune/punkt-react 13.21.0 → 14.0.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.
@@ -0,0 +1,525 @@
1
+ 'use client'
2
+
3
+ import { ForwardedRef, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { PktButton } from '../button/Button'
5
+ import { PktHeaderUserMenu } from '../headerUserMenu/UserMenu'
6
+ import { PktIcon } from '../icon/Icon'
7
+ import { PktLink } from '../link/Link'
8
+ import { PktTextinput } from '../textinput/Textinput'
9
+ import { useElementWidth } from '../../hooks/useElementWidth'
10
+ import classNames from 'classnames'
11
+ import { useScrollLock } from '../../hooks/useScrollLock'
12
+ import { User, Representing, UserMenuItem, UserMenuFooterItem, IPktHeader, TPktHeaderServiceMenu } from './types'
13
+
14
+ export type { User, Representing, UserMenuItem, UserMenuFooterItem, TPktHeaderServiceMenu }
15
+ export type IPktHeaderService = IPktHeader
16
+
17
+ export const PktHeaderService = forwardRef(
18
+ (
19
+ {
20
+ mobileBreakpoint = 768,
21
+ tabletBreakpoint = 1280,
22
+ children,
23
+ compact = false,
24
+ 'data-mode': dataMode,
25
+ logOutButtonPlacement = 'none',
26
+ logOut,
27
+ openedMenu,
28
+ showSearch = false,
29
+ onSearch,
30
+ onSearchChange,
31
+ searchValue,
32
+ searchPlaceholder = 'Søk',
33
+ representing,
34
+ serviceLink,
35
+ serviceClick,
36
+ serviceName,
37
+ user,
38
+ userMenu,
39
+ userMenuFooter,
40
+ userOptions,
41
+ canChangeRepresentation = false,
42
+ changeRepresentation,
43
+ hideLogo = false,
44
+ logoLink,
45
+ logoClick,
46
+ position = 'fixed',
47
+ scrollBehavior = 'hide',
48
+ }: IPktHeaderService,
49
+ ref: ForwardedRef<HTMLDivElement>,
50
+ ) => {
51
+ const isFixed = position === 'fixed'
52
+ const shouldHideOnScroll = scrollBehavior === 'hide'
53
+
54
+ // Deprecation warning for userMenuFooter
55
+ useEffect(() => {
56
+ if (userMenuFooter) {
57
+ console.warn(
58
+ 'PktHeaderService: The "userMenuFooter" prop is deprecated and will be removed in a future version. Please use "userMenu" instead.',
59
+ )
60
+ }
61
+ }, [userMenuFooter])
62
+
63
+ // Warning for userOptions - no longer available
64
+ useEffect(() => {
65
+ if (userOptions) {
66
+ console.warn('PktHeaderService: The "userOptions" prop is no longer available. Please use "userMenu" instead.')
67
+ }
68
+ }, [userOptions])
69
+
70
+ // Deprecation warning for shortname
71
+ useEffect(() => {
72
+ if (user?.shortname) {
73
+ console.warn(
74
+ 'PktHeaderService: The "shortname" property on the user object is deprecated and will be removed in a future version.',
75
+ )
76
+ }
77
+ if (representing?.shortname) {
78
+ console.warn(
79
+ 'PktHeaderService: The "shortname" property on the representing object is deprecated and will be removed in a future version.',
80
+ )
81
+ }
82
+ }, [user?.shortname, representing?.shortname])
83
+
84
+ const formattedLastLoggedIn = useMemo(() => {
85
+ if (!user?.lastLoggedIn) return undefined
86
+ if (typeof user.lastLoggedIn === 'string') {
87
+ return user.lastLoggedIn
88
+ }
89
+ return new Date(user.lastLoggedIn).toLocaleString('nb-NO', {
90
+ year: 'numeric',
91
+ month: 'long',
92
+ day: 'numeric',
93
+ })
94
+ }, [user?.lastLoggedIn])
95
+
96
+ const [openMenu, setOpenMenu] = useState<'none' | 'slot' | 'search' | 'user'>(openedMenu || 'none')
97
+ const [hidden, setHidden] = useState(false)
98
+ const [lastScrollPosition, setLastScrollPosition] = useState(0)
99
+ const [alignSlotRight, setAlignSlotRight] = useState(false)
100
+ const [alignSearchRight, setAlignSearchRight] = useState(false)
101
+
102
+ const internalRef = useRef<HTMLDivElement>(null)
103
+ const userContainerRef = useRef<HTMLDivElement>(null)
104
+ const slotContainerRef = useRef<HTMLDivElement>(null)
105
+ const searchContainerRef = useRef<HTMLDivElement>(null)
106
+ const slotContentRef = useRef<HTMLDivElement>(null)
107
+ const searchMenuRef = useRef<HTMLDivElement>(null)
108
+ const lastFocusedElementRef = useRef<HTMLElement | null>(null)
109
+ const shouldRestoreFocusRef = useRef(false)
110
+
111
+ const headerWidth = useElementWidth(internalRef)
112
+ const isMobile: boolean = headerWidth < mobileBreakpoint
113
+ const isTablet: boolean = headerWidth < tabletBreakpoint
114
+
115
+ const setRefs = useCallback(
116
+ (element: HTMLDivElement | null) => {
117
+ ;(internalRef as React.MutableRefObject<HTMLDivElement | null>).current = element
118
+ if (typeof ref === 'function') {
119
+ ref(element)
120
+ } else if (ref) {
121
+ ;(ref as React.MutableRefObject<HTMLDivElement | null>).current = element
122
+ }
123
+ },
124
+ [ref],
125
+ )
126
+
127
+ useScrollLock(isFixed && isMobile && openMenu !== 'none')
128
+
129
+ const handleFocusOut = useCallback((event: React.FocusEvent<HTMLDivElement>, menuType: TPktHeaderServiceMenu) => {
130
+ const relatedTarget = event.relatedTarget as HTMLElement | null
131
+
132
+ let containerRef: React.RefObject<HTMLDivElement>
133
+ switch (menuType) {
134
+ case 'user':
135
+ containerRef = userContainerRef
136
+ break
137
+ case 'slot':
138
+ containerRef = slotContainerRef
139
+ break
140
+ case 'search':
141
+ containerRef = searchContainerRef
142
+ break
143
+ default:
144
+ return
145
+ }
146
+
147
+ const container = containerRef.current
148
+ if (!container) return
149
+
150
+ if (!relatedTarget || !container.contains(relatedTarget)) {
151
+ setOpenMenu('none')
152
+ }
153
+ }, [])
154
+
155
+ const restoreFocus = useCallback(() => {
156
+ if (
157
+ shouldRestoreFocusRef.current &&
158
+ lastFocusedElementRef.current &&
159
+ document.contains(lastFocusedElementRef.current)
160
+ ) {
161
+ lastFocusedElementRef.current.focus()
162
+ }
163
+ lastFocusedElementRef.current = null
164
+ shouldRestoreFocusRef.current = false
165
+ }, [])
166
+
167
+ useEffect(() => {
168
+ const handleClickOutside = (event: MouseEvent) => {
169
+ if (user && openMenu === 'user' && !(event.target as Element).closest('.pkt-header-service__user-container')) {
170
+ setOpenMenu('none')
171
+ }
172
+ if (openMenu === 'slot' && !(event.target as Element).closest('.pkt-header-service__slot-container')) {
173
+ setOpenMenu('none')
174
+ }
175
+ if (
176
+ openMenu === 'search' &&
177
+ !(event.target as Element).closest('.pkt-header-service__search-container') &&
178
+ !(event.target as Element).closest('.pkt-header-service__search-input')
179
+ ) {
180
+ setOpenMenu('none')
181
+ }
182
+ }
183
+
184
+ const handleEscapeKey = (event: KeyboardEvent) => {
185
+ if (event.key === 'Escape' && openMenu !== 'none') {
186
+ event.preventDefault()
187
+ shouldRestoreFocusRef.current = true
188
+ setOpenMenu('none')
189
+ }
190
+ }
191
+
192
+ if (openMenu !== 'none') {
193
+ document.addEventListener('mousedown', handleClickOutside)
194
+ document.addEventListener('keydown', handleEscapeKey)
195
+ return () => {
196
+ document.removeEventListener('mousedown', handleClickOutside)
197
+ document.removeEventListener('keydown', handleEscapeKey)
198
+ }
199
+ } else {
200
+ restoreFocus()
201
+ }
202
+ }, [openMenu, user, restoreFocus])
203
+
204
+ useEffect(() => {
205
+ const onScroll = () => {
206
+ if (shouldHideOnScroll) {
207
+ const currentScrollPosition = window.pageYOffset || document.documentElement.scrollTop
208
+ if (currentScrollPosition < 0) {
209
+ return
210
+ }
211
+ if (Math.abs(currentScrollPosition - lastScrollPosition) < 60) {
212
+ return
213
+ }
214
+ setHidden(currentScrollPosition > lastScrollPosition)
215
+ setLastScrollPosition(currentScrollPosition)
216
+ }
217
+ }
218
+
219
+ if (document) {
220
+ window.addEventListener('scroll', onScroll)
221
+ }
222
+ return () => {
223
+ if (document) {
224
+ window.removeEventListener('scroll', onScroll)
225
+ }
226
+ }
227
+ }, [shouldHideOnScroll, lastScrollPosition])
228
+
229
+ const checkDropdownAlignment = useCallback(
230
+ (mode: 'slot' | 'search') => {
231
+ const containerRef = mode === 'slot' ? slotContainerRef : searchContainerRef
232
+ const dropdownRef = mode === 'slot' ? slotContentRef : searchMenuRef
233
+ if (!containerRef.current || !dropdownRef.current || !isTablet || isMobile) return
234
+
235
+ const buttonRect = containerRef.current.getBoundingClientRect()
236
+ const dropdownWidth = dropdownRef.current.offsetWidth
237
+ const wouldOverflow = buttonRect.left + dropdownWidth > window.innerWidth
238
+
239
+ if (mode === 'slot') {
240
+ setAlignSlotRight(wouldOverflow)
241
+ } else {
242
+ setAlignSearchRight(wouldOverflow)
243
+ }
244
+ },
245
+ [isTablet, isMobile],
246
+ )
247
+
248
+ const handleMenuToggle = useCallback(
249
+ (mode: TPktHeaderServiceMenu) => {
250
+ if (openMenu !== 'none') {
251
+ setOpenMenu('none')
252
+ } else {
253
+ lastFocusedElementRef.current = document.activeElement as HTMLElement
254
+
255
+ setOpenMenu(mode)
256
+ }
257
+ },
258
+ [openMenu],
259
+ )
260
+
261
+ useEffect(() => {
262
+ if (openMenu === 'slot' || openMenu === 'search') {
263
+ requestAnimationFrame(() => {
264
+ checkDropdownAlignment(openMenu)
265
+ })
266
+ }
267
+ }, [openMenu, checkDropdownAlignment])
268
+
269
+ const headerElement = (
270
+ <header
271
+ className={classNames(
272
+ `pkt-header-service`,
273
+ compact && 'pkt-header-service--compact',
274
+ isMobile && 'pkt-header-service--mobile',
275
+ isTablet && 'pkt-header-service--tablet',
276
+ isFixed && 'pkt-header-service--fixed',
277
+ shouldHideOnScroll && 'pkt-header-service--scroll-to-hide',
278
+ hidden && 'pkt-header-service--hidden',
279
+ )}
280
+ data-mode={dataMode}
281
+ ref={setRefs}
282
+ >
283
+ <div
284
+ className={classNames(
285
+ 'pkt-header-service__logo-area',
286
+ (!serviceName || serviceName === '') && 'pkt-header-service__logo-area--without-service',
287
+ )}
288
+ >
289
+ {!hideLogo && (
290
+ <span className="pkt-header-service__logo">
291
+ {logoLink ? (
292
+ <a href={logoLink} aria-label="Tilbake til forside">
293
+ <PktIcon
294
+ name="oslologo"
295
+ aria-hidden="true"
296
+ path="https://punkt-cdn.oslo.kommune.no/latest/logos/"
297
+ ></PktIcon>
298
+ </a>
299
+ ) : logoClick ? (
300
+ <button
301
+ aria-label="Tilbake til forside"
302
+ className="pkt-link-button pkt-link pkt-header-service__logo-link"
303
+ onClick={logoClick}
304
+ >
305
+ <PktIcon
306
+ name="oslologo"
307
+ aria-hidden="true"
308
+ path="https://punkt-cdn.oslo.kommune.no/latest/logos/"
309
+ ></PktIcon>
310
+ </button>
311
+ ) : (
312
+ <PktIcon
313
+ name="oslologo"
314
+ aria-hidden="true"
315
+ path="https://punkt-cdn.oslo.kommune.no/latest/logos/"
316
+ ></PktIcon>
317
+ )}
318
+ </span>
319
+ )}
320
+ {serviceName && (
321
+ <span className="pkt-header-service__service-name">
322
+ {serviceLink ? (
323
+ <PktLink href={serviceLink} className="pkt-header-service__service-link ">
324
+ {serviceName}
325
+ </PktLink>
326
+ ) : serviceClick ? (
327
+ <button className="pkt-link-button pkt-link pkt-header-service__service-link" onClick={serviceClick}>
328
+ {serviceName}
329
+ </button>
330
+ ) : (
331
+ <span className="pkt-header-service__service-link">{serviceName}</span>
332
+ )}
333
+ </span>
334
+ )}
335
+ </div>
336
+
337
+ <div className="pkt-header-service__content">
338
+ <div
339
+ className={classNames('pkt-header-service__slot-container', openMenu === 'slot' && 'is-open')}
340
+ onBlur={(e) => handleFocusOut(e, 'slot')}
341
+ ref={slotContainerRef}
342
+ >
343
+ {isTablet && children && (
344
+ <PktButton
345
+ skin="secondary"
346
+ variant="icon-only"
347
+ iconName="menu"
348
+ size={isMobile ? 'small' : 'medium'}
349
+ state={openMenu === 'slot' ? 'active' : 'normal'}
350
+ onClick={() => handleMenuToggle('slot')}
351
+ aria-expanded={openMenu === 'slot'}
352
+ aria-controls="mobile-slot-menu"
353
+ aria-label="Åpne meny"
354
+ >
355
+ Meny
356
+ </PktButton>
357
+ )}
358
+ <div
359
+ className={classNames('pkt-header-service__slot-content', alignSlotRight && 'align-right')}
360
+ id="mobile-slot-menu"
361
+ role={isTablet ? 'menu' : undefined}
362
+ aria-label={isTablet ? 'Meny' : undefined}
363
+ ref={slotContentRef}
364
+ >
365
+ <div className="pkt-contents">{children}</div>
366
+ </div>
367
+ </div>
368
+
369
+ {showSearch && (
370
+ <>
371
+ {isTablet ? (
372
+ <div
373
+ className={classNames('pkt-header-service__search-container', openMenu === 'search' && 'is-open')}
374
+ onBlur={(e) => handleFocusOut(e, 'search')}
375
+ ref={searchContainerRef}
376
+ >
377
+ <PktButton
378
+ skin="secondary"
379
+ variant="icon-only"
380
+ iconName="magnifying-glass-big"
381
+ size={isMobile ? 'small' : 'medium'}
382
+ onClick={() => handleMenuToggle('search')}
383
+ state={openMenu === 'search' ? 'active' : 'normal'}
384
+ aria-expanded={openMenu === 'search'}
385
+ aria-controls="mobile-search-menu"
386
+ aria-label="Åpne søkefelt"
387
+ >
388
+ Søk
389
+ </PktButton>
390
+ <div
391
+ className={classNames(
392
+ 'pkt-header-service__mobile-menu',
393
+ openMenu === 'search' && 'is-open',
394
+ alignSearchRight && 'align-right',
395
+ )}
396
+ ref={searchMenuRef}
397
+ >
398
+ {openMenu === 'search' && (
399
+ <PktTextinput
400
+ id="mobile-search-menu"
401
+ className="pkt-header-service__search-input"
402
+ type="search"
403
+ label="Søk"
404
+ useWrapper={false}
405
+ placeholder={searchPlaceholder}
406
+ value={searchValue}
407
+ autoFocus
408
+ fullwidth={true}
409
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange?.(e.target.value)}
410
+ onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
411
+ if (e.key === 'Enter') {
412
+ onSearch?.((e.target as HTMLInputElement).value)
413
+ }
414
+ }}
415
+ />
416
+ )}
417
+ </div>
418
+ </div>
419
+ ) : (
420
+ <PktTextinput
421
+ id="header-service-search"
422
+ className="pkt-header-service__search-input"
423
+ type="search"
424
+ label="Søk"
425
+ useWrapper={false}
426
+ placeholder={searchPlaceholder}
427
+ value={searchValue}
428
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange?.(e.target.value)}
429
+ onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
430
+ if (e.key === 'Enter') {
431
+ onSearch?.((e.target as HTMLInputElement).value)
432
+ }
433
+ }}
434
+ />
435
+ )}
436
+ </>
437
+ )}
438
+
439
+ {isTablet && logOut && (logOutButtonPlacement === 'header' || logOutButtonPlacement === 'both') && (
440
+ <PktButton
441
+ skin="secondary"
442
+ size={isMobile ? 'small' : 'medium'}
443
+ variant="icon-only"
444
+ iconName="exit"
445
+ onClick={logOut}
446
+ >
447
+ Logg ut
448
+ </PktButton>
449
+ )}
450
+ </div>
451
+
452
+ <div className="pkt-header-service__user">
453
+ {user && (
454
+ <div
455
+ className="pkt-header-service__user-container"
456
+ onBlur={(e) => handleFocusOut(e, 'user')}
457
+ ref={userContainerRef}
458
+ >
459
+ <PktButton
460
+ className={classNames(
461
+ 'pkt-header-service__user-button',
462
+ isMobile && 'pkt-header-service__user-button--mobile',
463
+ )}
464
+ skin="secondary"
465
+ size={isMobile ? 'small' : 'medium'}
466
+ state={openMenu === 'user' ? 'active' : 'normal'}
467
+ variant="icons-right-and-left"
468
+ iconName="user"
469
+ secondIconName={openMenu === 'user' ? 'chevron-thin-up' : 'chevron-thin-down'}
470
+ onClick={() => handleMenuToggle('user')}
471
+ >
472
+ <span className="pkt-sr-only">Brukermeny: </span>
473
+ {representing?.name || user.name}
474
+ </PktButton>
475
+ {openMenu === 'user' && user && (
476
+ <div
477
+ className={classNames(
478
+ isMobile ? 'pkt-header-service__mobile-menu' : 'pkt-header-service__user-menu',
479
+ 'is-open',
480
+ )}
481
+ >
482
+ <PktHeaderUserMenu
483
+ user={user}
484
+ formattedLastLoggedIn={formattedLastLoggedIn}
485
+ representing={representing}
486
+ userMenu={userMenu}
487
+ canChangeRepresentation={canChangeRepresentation}
488
+ changeRepresentation={changeRepresentation}
489
+ logoutOnClick={
490
+ logOutButtonPlacement === 'userMenu' || logOutButtonPlacement === 'both' ? logOut : undefined
491
+ }
492
+ />
493
+ </div>
494
+ )}
495
+ </div>
496
+ )}
497
+ {!isMobile && logOut && (logOutButtonPlacement === 'header' || logOutButtonPlacement === 'both') && (
498
+ <PktButton skin="tertiary" size="medium" variant="icon-right" iconName="exit" onClick={logOut}>
499
+ Logg ut
500
+ </PktButton>
501
+ )}
502
+ </div>
503
+ </header>
504
+ )
505
+
506
+ if (isFixed) {
507
+ return (
508
+ <div className="pkt-header-service-wrapper">
509
+ {headerElement}
510
+ <div
511
+ className={classNames(
512
+ 'pkt-header-service-spacer',
513
+ compact && 'pkt-header-service-spacer--compact',
514
+ user && 'pkt-header-service-spacer--has-user',
515
+ isMobile && 'pkt-header-service-spacer--mobile',
516
+ isTablet && 'pkt-header-service-spacer--tablet',
517
+ )}
518
+ />
519
+ </div>
520
+ )
521
+ }
522
+
523
+ return headerElement
524
+ },
525
+ )
@@ -0,0 +1,90 @@
1
+ import { TBreakpoint } from '../../types/breakpoint'
2
+
3
+ /** User object for header components */
4
+ export interface User {
5
+ name: string
6
+ /** @deprecated shortname is deprecated and will be removed in a future version */
7
+ shortname?: string
8
+ lastLoggedIn?: string | Date
9
+ }
10
+
11
+ /** Representing object for header components */
12
+ export interface Representing {
13
+ name: string
14
+ /** @deprecated shortname is deprecated and will be removed in a future version */
15
+ shortname?: string
16
+ orgNumber?: string | number
17
+ }
18
+
19
+ /** User menu item for header components */
20
+ export interface UserMenuItem {
21
+ iconName?: string
22
+ title: string
23
+ target: string | (() => void)
24
+ }
25
+
26
+ /** @deprecated Use UserMenuItem instead */
27
+ export interface UserMenuFooterItem {
28
+ title: string
29
+ target: string | (() => void)
30
+ }
31
+
32
+ export type TPktHeaderServiceMenu = 'none' | 'slot' | 'search' | 'user'
33
+
34
+ export interface IPktHeader {
35
+ children?: React.ReactNode
36
+ /** Set dark mode on the header */
37
+ 'data-mode'?: 'dark'
38
+ /** Hide the Oslo logo. Default: false (logo is shown) */
39
+ hideLogo?: boolean
40
+ /** Link URL for the logo */
41
+ logoLink?: string
42
+ /** Callback when logo is clicked */
43
+ logoClick?: (event: React.MouseEvent) => void
44
+ /** Name of the service displayed in the header */
45
+ serviceName?: string
46
+ /** Link URL for the service name */
47
+ serviceLink?: string
48
+ /** Callback when service name is clicked */
49
+ serviceClick?: (event: React.MouseEvent) => void
50
+ /** Use compact header height */
51
+ compact?: boolean
52
+ /** User object with name, shortname, and lastLoggedIn */
53
+ user?: User
54
+ /** User menu items displayed in the dropdown */
55
+ userMenu?: UserMenuItem[]
56
+ /** Representing object with name, shortname, and orgNumber */
57
+ representing?: Representing
58
+ /** Whether the user can change representation. Shows "Endre organisasjon" button */
59
+ canChangeRepresentation?: boolean
60
+ /** Callback when user clicks "Endre organisasjon" */
61
+ changeRepresentation?: () => void
62
+ /** @deprecated Use userMenu instead. Will show console.warn if used. */
63
+ userMenuFooter?: UserMenuFooterItem[]
64
+ /** @deprecated userOptions is no longer available. Use userMenu instead. */
65
+ userOptions?: UserMenuItem[]
66
+ /** Where to show the logout button: 'userMenu', 'header', 'both', or 'none' */
67
+ logOutButtonPlacement?: 'userMenu' | 'header' | 'both' | 'none'
68
+ /** Callback when user clicks logout */
69
+ logOut?: () => void
70
+ /** Show search input in the header */
71
+ showSearch?: boolean
72
+ /** Callback when user submits search (presses Enter) */
73
+ onSearch?: (query: string) => void
74
+ /** Callback when search input value changes */
75
+ onSearchChange?: (query: string) => void
76
+ /** Controlled value for the search input */
77
+ searchValue?: string
78
+ /** Placeholder text for the search input. Default: "Søk" */
79
+ searchPlaceholder?: string
80
+ /** Custom breakpoint for responsive behavior (grid layout) in pixels. Default: 1024 */
81
+ mobileBreakpoint?: number
82
+ /** Custom breakpoint for tablet responsive behavior (interaction pattern) in pixels. Default: 1280 */
83
+ tabletBreakpoint?: number
84
+ /** Which menu is initially open */
85
+ openedMenu?: TPktHeaderServiceMenu
86
+ /** Header position. 'fixed' fixes to top of viewport, 'relative' follows document flow. Default: 'fixed' */
87
+ position?: 'fixed' | 'relative'
88
+ /** Scroll behavior. 'hide' hides header on scroll down, 'none' keeps it visible. Default: 'hide' */
89
+ scrollBehavior?: 'hide' | 'none'
90
+ }
@@ -0,0 +1,75 @@
1
+ import '@testing-library/jest-dom'
2
+ import React from 'react'
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { axe, toHaveNoViolations } from 'jest-axe'
5
+ import { vi } from 'vitest'
6
+
7
+ import { PktHeaderUserMenu } from './UserMenu'
8
+ import { UserMenuItem } from '../header/types'
9
+
10
+ expect.extend(toHaveNoViolations)
11
+
12
+ describe('PktHeaderUserMenu', () => {
13
+ const mockUser = {
14
+ name: 'Ola Nordmann',
15
+ }
16
+
17
+ const mockRepresenting = {
18
+ name: 'Oslo Kommune',
19
+ orgNumber: '123456789',
20
+ }
21
+
22
+ const menuItems: UserMenuItem[] = [
23
+ { title: 'Profil', target: '/profil', iconName: 'user' },
24
+ { title: 'Bookinger', target: '/bookinger', iconName: 'heart' },
25
+ { title: 'Innstillinger', target: () => {}, iconName: 'settings' },
26
+ ]
27
+
28
+ it('has no a11y violations', async () => {
29
+ const { container } = render(
30
+ <PktHeaderUserMenu user={mockUser} representing={mockRepresenting} userMenu={menuItems} />,
31
+ )
32
+ const results = await axe(container)
33
+ expect(results).toHaveNoViolations()
34
+ })
35
+
36
+ it('renders user section with default label when not provided', () => {
37
+ render(<PktHeaderUserMenu user={mockUser} />)
38
+ expect(screen.getByText('Pålogget som')).toBeInTheDocument()
39
+ expect(screen.getByText('Ola Nordmann')).toBeInTheDocument()
40
+ })
41
+
42
+ it('renders representing section with default label when not provided', () => {
43
+ render(<PktHeaderUserMenu user={mockUser} representing={mockRepresenting} />)
44
+ expect(screen.getByText('Representerer')).toBeInTheDocument()
45
+ expect(screen.getByText('Oslo Kommune')).toBeInTheDocument()
46
+ expect(screen.getByText('Org.nr. 123456789')).toBeInTheDocument()
47
+ })
48
+
49
+ it('renders provided links as list items', () => {
50
+ render(<PktHeaderUserMenu user={mockUser} userMenu={menuItems} />)
51
+ expect(screen.getByRole('navigation')).toHaveClass('pkt-user-menu')
52
+ const lists = screen.getAllByRole('list')
53
+ expect(lists.length).toBeGreaterThan(1)
54
+ expect(screen.getAllByRole('listitem').length).toBeGreaterThan(0)
55
+ expect(screen.getByText('Profil')).toBeInTheDocument()
56
+ expect(screen.getByText('Innstillinger')).toBeInTheDocument()
57
+ })
58
+
59
+ it('calls logoutOnClick when logout button is rendered and clicked', () => {
60
+ const onLogout = vi.fn()
61
+ render(<PktHeaderUserMenu user={mockUser} logoutOnClick={onLogout} />)
62
+ const logoutButton = screen.getByText('Logg ut')
63
+ fireEvent.click(logoutButton)
64
+ expect(onLogout).toHaveBeenCalled()
65
+ })
66
+
67
+ it('supports button-type menu items and triggers their onClick', () => {
68
+ const onClick = vi.fn()
69
+ const items: UserMenuItem[] = [{ title: 'Konto', target: onClick, iconName: 'user' }]
70
+ render(<PktHeaderUserMenu user={mockUser} userMenu={items} />)
71
+ const btn = screen.getByText('Konto')
72
+ fireEvent.click(btn)
73
+ expect(onClick).toHaveBeenCalled()
74
+ })
75
+ })