@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.
- package/CHANGELOG.md +44 -0
- package/dist/index.d.ts +78 -15
- package/dist/punkt-react.es.js +5092 -3965
- package/dist/punkt-react.umd.js +751 -448
- package/package.json +5 -5
- package/src/components/header/Header.test.tsx +33 -27
- package/src/components/header/Header.tsx +23 -342
- package/src/components/header/HeaderService.test.tsx +515 -0
- package/src/components/header/HeaderService.tsx +525 -0
- package/src/components/header/types.ts +90 -0
- package/src/components/headerUserMenu/UserMenu.test.tsx +75 -0
- package/src/components/headerUserMenu/UserMenu.tsx +173 -0
- package/src/components/index.ts +1 -0
- package/src/components/interfaces.ts +1 -0
|
@@ -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
|
+
})
|