@oslokommune/punkt-react 16.26.3 → 17.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 +111 -0
- package/dist/components/accordion/Accordion.d.ts +1 -0
- package/dist/components/alert/Alert.d.ts +9 -0
- package/dist/components/button/Button.d.ts +1 -1
- package/dist/components/combobox/types.d.ts +2 -0
- package/dist/components/datepicker/types.d.ts +3 -0
- package/dist/components/fileupload/FileUpload.d.ts +12 -2
- package/dist/components/footer/Footer.accessibility.test.d.ts +0 -0
- package/dist/components/footer/Footer.d.ts +12 -34
- package/dist/components/footer/Footer.test.d.ts +1 -0
- package/dist/components/footer/FooterGlobal.d.ts +23 -0
- package/dist/components/footerSimple/FooterSimple.d.ts +19 -16
- package/dist/components/footerSimple/FooterSimple.test.d.ts +1 -0
- package/dist/components/header/Header.d.ts +15 -3
- package/dist/components/header/HeaderGlobal.accessibility.test.d.ts +0 -0
- package/dist/components/header/HeaderGlobal.d.ts +2 -0
- package/dist/components/header/HeaderGlobal.test.d.ts +0 -0
- package/dist/components/header/HeaderService.accessibility.test.d.ts +0 -0
- package/dist/components/header/types.d.ts +30 -2
- package/dist/components/header-menu/HeaderMenu.d.ts +2 -4
- package/dist/components/index.d.ts +4 -0
- package/dist/components/inputwrapper/InputWrapper.d.ts +1 -0
- package/dist/components/searchinput/SearchInput.d.ts +1 -0
- package/dist/components/select/Select.d.ts +4 -7
- package/dist/components/textarea/Textarea.d.ts +2 -0
- package/dist/components/textinput/Textinput.d.ts +1 -0
- package/dist/components/timepicker/types.d.ts +3 -0
- package/dist/components/timepicker/useTimepickerState.d.ts +2 -0
- package/dist/punkt-react.cjs +1 -1
- package/dist/punkt-react.js +3191 -2727
- package/dist/shared-types/footer.d.ts +95 -0
- package/dist/shared-types/{header-menu.d.ts → header-footer.d.ts} +51 -4
- package/dist/shared-types/header.d.ts +1 -1
- package/dist/shared-types/index.d.ts +6 -4
- package/dist/shared-types/searchinput.d.ts +2 -0
- package/dist/shared-types/select.d.ts +3 -2
- package/dist/shared-types/size.d.ts +4 -0
- package/dist/shared-types/timepicker.d.ts +0 -6
- package/dist/shared-utils/datepicker-utils.d.ts +5 -4
- package/dist/shared-utils/fileupload/focus-trap.d.ts +4 -10
- package/dist/shared-utils/focus-trap/index.d.ts +22 -0
- package/dist/shared-utils/{header-menu → header-footer}/example-data.d.ts +5 -4
- package/dist/shared-utils/header-footer/external-domain.d.ts +26 -0
- package/dist/shared-utils/{header-menu → header-footer}/fetch-header-footer-data.d.ts +6 -2
- package/dist/shared-utils/{header-menu → header-footer}/index.d.ts +2 -1
- package/dist/shared-utils/{header-menu → header-footer}/select-megamenu.d.ts +1 -1
- package/dist/shared-utils/scroll-lock/index.d.ts +24 -0
- package/package.json +4 -4
- package/src/components/accordion/Accordion.test.tsx +51 -0
- package/src/components/accordion/Accordion.tsx +3 -3
- package/src/components/accordion/AccordionItem.tsx +7 -14
- package/src/components/alert/Alert.test.tsx +45 -9
- package/src/components/alert/Alert.tsx +13 -3
- package/src/components/button/Button.tsx +2 -2
- package/src/components/combobox/Combobox.core.test.tsx +14 -0
- package/src/components/combobox/Combobox.tsx +1 -0
- package/src/components/combobox/ComboboxInput.tsx +2 -0
- package/src/components/combobox/types.ts +2 -0
- package/src/components/combobox/useComboboxState.ts +2 -0
- package/src/components/consent/Consent.test.tsx +8 -14
- package/src/components/consent/Consent.tsx +9 -19
- package/src/components/datepicker/Datepicker.core.test.tsx +11 -0
- package/src/components/datepicker/Datepicker.tsx +4 -1
- package/src/components/datepicker/DatepickerInputs.tsx +16 -1
- package/src/components/datepicker/types.ts +3 -0
- package/src/components/datepicker/useDatepickerState.ts +17 -9
- package/src/components/fileupload/FileUpload.tsx +27 -9
- package/src/components/footer/Footer.accessibility.test.tsx +46 -0
- package/src/components/footer/Footer.test.tsx +192 -86
- package/src/components/footer/Footer.tsx +169 -159
- package/src/components/footer/FooterGlobal.tsx +181 -0
- package/src/components/footerSimple/FooterSimple.test.tsx +49 -46
- package/src/components/footerSimple/FooterSimple.tsx +117 -78
- package/src/components/header/Header.tsx +17 -4
- package/src/components/header/HeaderGlobal.accessibility.test.tsx +86 -0
- package/src/components/header/HeaderGlobal.test.tsx +254 -0
- package/src/components/header/HeaderGlobal.tsx +467 -0
- package/src/components/header/HeaderService.accessibility.test.tsx +70 -0
- package/src/components/header/HeaderService.test.tsx +29 -16
- package/src/components/header/HeaderService.tsx +24 -84
- package/src/components/header/types.ts +35 -0
- package/src/components/header-menu/HeaderMenu.test.tsx +16 -39
- package/src/components/header-menu/HeaderMenu.tsx +62 -70
- package/src/components/index.ts +4 -0
- package/src/components/inputwrapper/InputWrapper.tsx +6 -1
- package/src/components/modal/Modal.tsx +7 -11
- package/src/components/searchinput/SearchInput.test.tsx +10 -0
- package/src/components/searchinput/SearchInput.tsx +19 -7
- package/src/components/select/Select.test.tsx +18 -0
- package/src/components/select/Select.tsx +8 -4
- package/src/components/textarea/Textarea.test.tsx +10 -0
- package/src/components/textarea/Textarea.tsx +5 -1
- package/src/components/textinput/Textinput.test.tsx +28 -0
- package/src/components/textinput/Textinput.tsx +5 -2
- package/src/components/timepicker/Timepicker.test.tsx +10 -0
- package/src/components/timepicker/Timepicker.tsx +8 -7
- package/src/components/timepicker/types.ts +8 -5
- package/src/components/timepicker/useTimepickerState.ts +4 -0
- package/dist/shared-utils/modal-scroll-lock.d.ts +0 -10
- /package/dist/shared-utils/{header-menu → header-footer}/icon-map.d.ts +0 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import classNames from 'classnames'
|
|
4
|
+
import { ForwardedRef, forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
import { type THeaderFooterApi, DEFAULT_HEADER_LOGO_LINK } from 'shared-types'
|
|
6
|
+
import { fetchHeaderFooterData, selectLocaleData } from 'shared-utils/header-footer'
|
|
7
|
+
import { getDeepActiveElement, getFocusableElementsDeep } from 'shared-utils/focus-trap'
|
|
8
|
+
|
|
9
|
+
import { useHeaderChrome } from '../../hooks/useHeaderChrome'
|
|
10
|
+
import { useScrollLock } from '../../hooks/useScrollLock'
|
|
11
|
+
import { PktButton } from '../button/Button'
|
|
12
|
+
import { PktHeaderMenu } from '../header-menu/HeaderMenu'
|
|
13
|
+
import { PktHeaderUserMenu } from '../headerUserMenu/UserMenu'
|
|
14
|
+
import { PktIcon } from '../icon/Icon'
|
|
15
|
+
import { PktSearchInput } from '../searchinput/SearchInput'
|
|
16
|
+
import { IPktHeaderGlobal } from './types'
|
|
17
|
+
|
|
18
|
+
const defaultLogoPath = 'https://punkt-cdn.oslo.kommune.no/latest/logos/'
|
|
19
|
+
|
|
20
|
+
if (typeof window !== 'undefined') {
|
|
21
|
+
window.pktLogoPath = window.pktLogoPath || defaultLogoPath
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MENU_ID = 'pkt-header-global-menu'
|
|
25
|
+
const MENU_TOGGLE_ID = 'pkt-header-global-menu-toggle'
|
|
26
|
+
const SEARCH_PANEL_ID = 'pkt-header-global-search-panel'
|
|
27
|
+
const SEARCH_TOGGLE_ID = 'pkt-header-global-search-toggle'
|
|
28
|
+
|
|
29
|
+
type GlobalMenu = 'none' | 'menu' | 'user' | 'search'
|
|
30
|
+
|
|
31
|
+
export const PktHeaderGlobal = forwardRef(
|
|
32
|
+
(
|
|
33
|
+
{
|
|
34
|
+
className,
|
|
35
|
+
'data-mode': dataMode,
|
|
36
|
+
dataUrl,
|
|
37
|
+
data,
|
|
38
|
+
locale = 'nb-NO',
|
|
39
|
+
logoLink = DEFAULT_HEADER_LOGO_LINK,
|
|
40
|
+
logoClick,
|
|
41
|
+
logoPath = typeof window !== 'undefined' ? window.pktLogoPath : defaultLogoPath,
|
|
42
|
+
showSearch = true,
|
|
43
|
+
searchPlaceholder,
|
|
44
|
+
onSearch,
|
|
45
|
+
showContact = true,
|
|
46
|
+
user,
|
|
47
|
+
userMenu,
|
|
48
|
+
representing,
|
|
49
|
+
canChangeRepresentation = false,
|
|
50
|
+
changeRepresentation,
|
|
51
|
+
logOutButtonPlacement = 'none',
|
|
52
|
+
logOut,
|
|
53
|
+
position = 'fixed',
|
|
54
|
+
scrollBehavior = 'hide',
|
|
55
|
+
mobileBreakpoint = 768,
|
|
56
|
+
tabletBreakpoint = 1024,
|
|
57
|
+
onDataLoaded,
|
|
58
|
+
onDataError,
|
|
59
|
+
}: IPktHeaderGlobal,
|
|
60
|
+
ref: ForwardedRef<HTMLElement>,
|
|
61
|
+
) => {
|
|
62
|
+
const shouldHideOnScroll = scrollBehavior === 'hide'
|
|
63
|
+
|
|
64
|
+
const [openMenu, setOpenMenu] = useState<GlobalMenu>('none')
|
|
65
|
+
const [fetchedData, setFetchedData] = useState<THeaderFooterApi | undefined>(undefined)
|
|
66
|
+
const [loadError, setLoadError] = useState(false)
|
|
67
|
+
|
|
68
|
+
const onDataLoadedRef = useRef(onDataLoaded)
|
|
69
|
+
const onDataErrorRef = useRef(onDataError)
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
onDataLoadedRef.current = onDataLoaded
|
|
72
|
+
}, [onDataLoaded])
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
onDataErrorRef.current = onDataError
|
|
75
|
+
}, [onDataError])
|
|
76
|
+
|
|
77
|
+
const { setRef, isMobile, isTablet, isFixed, isSticky, hidden, captureFocus, flagRestoreFocus, restoreFocus } =
|
|
78
|
+
useHeaderChrome({
|
|
79
|
+
forwardedRef: ref,
|
|
80
|
+
mobileBreakpoint,
|
|
81
|
+
tabletBreakpoint,
|
|
82
|
+
position,
|
|
83
|
+
scrollBehavior,
|
|
84
|
+
pauseScrollHide: openMenu !== 'none',
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
useScrollLock(openMenu === 'menu' || openMenu === 'search')
|
|
88
|
+
|
|
89
|
+
const headerElRef = useRef<HTMLElement | null>(null)
|
|
90
|
+
const setHeaderRef = useCallback(
|
|
91
|
+
(element: HTMLElement | null) => {
|
|
92
|
+
headerElRef.current = element
|
|
93
|
+
setRef(element)
|
|
94
|
+
},
|
|
95
|
+
[setRef],
|
|
96
|
+
)
|
|
97
|
+
useLayoutEffect(() => {
|
|
98
|
+
if (openMenu !== 'menu' && openMenu !== 'search') return
|
|
99
|
+
const overlay = document.getElementById(openMenu === 'menu' ? MENU_ID : SEARCH_PANEL_ID)
|
|
100
|
+
const rect = headerElRef.current?.getBoundingClientRect()
|
|
101
|
+
if (!overlay || !rect) return
|
|
102
|
+
overlay.style.setProperty('--pkt-header-global-top', `${rect.top}px`)
|
|
103
|
+
overlay.style.setProperty('--pkt-header-global-left', `${rect.left}px`)
|
|
104
|
+
overlay.style.setProperty('--pkt-header-global-width', `${rect.width}px`)
|
|
105
|
+
}, [openMenu, isMobile, isTablet])
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (data) onDataLoadedRef.current?.(data)
|
|
109
|
+
}, [data])
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (data) return
|
|
113
|
+
const controller = new AbortController()
|
|
114
|
+
fetchHeaderFooterData<string>(dataUrl, controller.signal)
|
|
115
|
+
.then((payload) => {
|
|
116
|
+
if (controller.signal.aborted) return
|
|
117
|
+
setFetchedData(payload)
|
|
118
|
+
setLoadError(false)
|
|
119
|
+
onDataLoadedRef.current?.(payload)
|
|
120
|
+
})
|
|
121
|
+
.catch((error: Error) => {
|
|
122
|
+
if (error.name === 'AbortError') return
|
|
123
|
+
setLoadError(true)
|
|
124
|
+
onDataErrorRef.current?.(error)
|
|
125
|
+
})
|
|
126
|
+
return () => controller.abort()
|
|
127
|
+
}, [data, dataUrl])
|
|
128
|
+
|
|
129
|
+
const effectiveData = data ?? fetchedData
|
|
130
|
+
const localeData = useMemo(() => selectLocaleData(effectiveData, locale), [effectiveData, locale])
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
134
|
+
const target = event.target as Element
|
|
135
|
+
if (
|
|
136
|
+
openMenu === 'user' &&
|
|
137
|
+
!target.closest('.pkt-header-global__user-container') &&
|
|
138
|
+
!target.closest('.pkt-header-global__user-menu')
|
|
139
|
+
) {
|
|
140
|
+
setOpenMenu('none')
|
|
141
|
+
}
|
|
142
|
+
if (openMenu === 'menu' && !target.closest('.pkt-header-menu') && !target.closest(`#${MENU_TOGGLE_ID}`)) {
|
|
143
|
+
setOpenMenu('none')
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
openMenu === 'search' &&
|
|
147
|
+
!target.closest('.pkt-header-global__search-panel-inner') &&
|
|
148
|
+
!target.closest(`#${SEARCH_TOGGLE_ID}`)
|
|
149
|
+
) {
|
|
150
|
+
setOpenMenu('none')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const handleEscapeKey = (event: KeyboardEvent) => {
|
|
155
|
+
if (event.key === 'Escape' && openMenu !== 'none') {
|
|
156
|
+
event.preventDefault()
|
|
157
|
+
flagRestoreFocus()
|
|
158
|
+
setOpenMenu('none')
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (openMenu !== 'none') {
|
|
163
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
164
|
+
document.addEventListener('keydown', handleEscapeKey)
|
|
165
|
+
return () => {
|
|
166
|
+
document.removeEventListener('mousedown', handleClickOutside)
|
|
167
|
+
document.removeEventListener('keydown', handleEscapeKey)
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
restoreFocus()
|
|
171
|
+
}
|
|
172
|
+
}, [openMenu, restoreFocus, flagRestoreFocus])
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (openMenu !== 'menu' && openMenu !== 'search') return
|
|
176
|
+
const panelId = openMenu === 'menu' ? MENU_ID : SEARCH_PANEL_ID
|
|
177
|
+
const toggleId = openMenu === 'menu' ? MENU_TOGGLE_ID : SEARCH_TOGGLE_ID
|
|
178
|
+
const getPanel = () => document.getElementById(panelId)
|
|
179
|
+
const getToggle = () => document.getElementById(toggleId)
|
|
180
|
+
|
|
181
|
+
const closeToTrigger = (toggle: HTMLElement) => {
|
|
182
|
+
setOpenMenu('none')
|
|
183
|
+
;(toggle.querySelector<HTMLElement>('button') ?? toggle).focus()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
187
|
+
if (event.key !== 'Tab') return
|
|
188
|
+
const panel = getPanel()
|
|
189
|
+
const toggle = getToggle()
|
|
190
|
+
if (!panel || !toggle) return
|
|
191
|
+
const focusables = getFocusableElementsDeep(panel)
|
|
192
|
+
const active = getDeepActiveElement()
|
|
193
|
+
|
|
194
|
+
if (active && toggle.contains(active)) {
|
|
195
|
+
if (!event.shiftKey && focusables.length > 0) {
|
|
196
|
+
event.preventDefault()
|
|
197
|
+
focusables[0].focus()
|
|
198
|
+
}
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (focusables.length === 0) return
|
|
203
|
+
const first = focusables[0]
|
|
204
|
+
const last = focusables[focusables.length - 1]
|
|
205
|
+
const tabbingForwardOut = !event.shiftKey && active === last
|
|
206
|
+
const tabbingBackwardOut = event.shiftKey && active === first
|
|
207
|
+
if (tabbingForwardOut || tabbingBackwardOut) {
|
|
208
|
+
event.preventDefault()
|
|
209
|
+
closeToTrigger(toggle)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const handleFocusIn = (event: FocusEvent) => {
|
|
214
|
+
const target = event.target as Node | null
|
|
215
|
+
if (!target) return
|
|
216
|
+
const panel = getPanel()
|
|
217
|
+
if (!panel) return
|
|
218
|
+
const toggle = getToggle()
|
|
219
|
+
if (panel.contains(target) || toggle?.contains(target)) return
|
|
220
|
+
setOpenMenu('none')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
document.addEventListener('keydown', handleKeydown)
|
|
224
|
+
document.addEventListener('focusin', handleFocusIn)
|
|
225
|
+
return () => {
|
|
226
|
+
document.removeEventListener('keydown', handleKeydown)
|
|
227
|
+
document.removeEventListener('focusin', handleFocusIn)
|
|
228
|
+
}
|
|
229
|
+
}, [openMenu])
|
|
230
|
+
|
|
231
|
+
const formattedLastLoggedIn = useMemo(() => {
|
|
232
|
+
if (!user?.lastLoggedIn) return undefined
|
|
233
|
+
if (typeof user.lastLoggedIn === 'string') return user.lastLoggedIn
|
|
234
|
+
return new Date(user.lastLoggedIn).toLocaleString('nb-NO', {
|
|
235
|
+
year: 'numeric',
|
|
236
|
+
month: 'long',
|
|
237
|
+
day: 'numeric',
|
|
238
|
+
})
|
|
239
|
+
}, [user?.lastLoggedIn])
|
|
240
|
+
|
|
241
|
+
const handleMenuToggle = useCallback(
|
|
242
|
+
(mode: GlobalMenu) => {
|
|
243
|
+
setOpenMenu((current) => {
|
|
244
|
+
if (current === mode) return 'none'
|
|
245
|
+
captureFocus()
|
|
246
|
+
return mode
|
|
247
|
+
})
|
|
248
|
+
},
|
|
249
|
+
[captureFocus],
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
const showLogoutInUserMenu = logOutButtonPlacement === 'userMenu' || logOutButtonPlacement === 'both'
|
|
253
|
+
|
|
254
|
+
const search = localeData?.search
|
|
255
|
+
const contact = localeData?.header?.contact
|
|
256
|
+
const menuText = localeData?.i18n?.menu || 'Meny'
|
|
257
|
+
const searchText = localeData?.i18n?.search || 'Søk'
|
|
258
|
+
const isMenuOpen = openMenu === 'menu'
|
|
259
|
+
const isUserOpen = openMenu === 'user'
|
|
260
|
+
const isSearchOpen = openMenu === 'search'
|
|
261
|
+
const compact = isMobile
|
|
262
|
+
const hasSearch = Boolean(showSearch && search)
|
|
263
|
+
const searchId = search?.input.id || 'pkt-header-global-search'
|
|
264
|
+
const isLoading = !localeData && !loadError
|
|
265
|
+
|
|
266
|
+
const headerElement = (
|
|
267
|
+
<header
|
|
268
|
+
className={classNames(
|
|
269
|
+
'pkt-header-global',
|
|
270
|
+
isMobile && 'pkt-header-global--mobile',
|
|
271
|
+
isTablet && 'pkt-header-global--tablet',
|
|
272
|
+
isFixed && 'pkt-header-global--fixed',
|
|
273
|
+
isSticky && 'pkt-header-global--sticky',
|
|
274
|
+
shouldHideOnScroll && 'pkt-header-global--scroll-to-hide',
|
|
275
|
+
hidden && 'pkt-header-global--hidden',
|
|
276
|
+
className,
|
|
277
|
+
)}
|
|
278
|
+
data-mode={dataMode}
|
|
279
|
+
ref={setHeaderRef}
|
|
280
|
+
>
|
|
281
|
+
<div className="pkt-header-global__logo-area">
|
|
282
|
+
<span className="pkt-header-global__logo">
|
|
283
|
+
<a href={logoLink} aria-label="Til hovedsiden" onClick={logoClick}>
|
|
284
|
+
<PktIcon name="oslologo" aria-hidden="true" path={logoPath} />
|
|
285
|
+
</a>
|
|
286
|
+
</span>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<div className="pkt-header-global__content">
|
|
290
|
+
{!compact && hasSearch && search && (
|
|
291
|
+
<div className="pkt-header-global__search">
|
|
292
|
+
<PktSearchInput
|
|
293
|
+
appearance="global"
|
|
294
|
+
inputSize={isTablet ? 'small' : 'medium'}
|
|
295
|
+
fullwidth
|
|
296
|
+
id={searchId}
|
|
297
|
+
name={search.input.name}
|
|
298
|
+
aria-label={searchText}
|
|
299
|
+
placeholder={searchPlaceholder ?? search.input.placeholder}
|
|
300
|
+
action={search.endpoint}
|
|
301
|
+
method="get"
|
|
302
|
+
onSearch={onSearch}
|
|
303
|
+
onChange={() => {}}
|
|
304
|
+
/>
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
{compact && hasSearch && (
|
|
309
|
+
<PktButton
|
|
310
|
+
id={SEARCH_TOGGLE_ID}
|
|
311
|
+
className="pkt-header-global__search-toggle"
|
|
312
|
+
skin="primary"
|
|
313
|
+
color={isSearchOpen ? undefined : 'yellow'}
|
|
314
|
+
variant="icon-only"
|
|
315
|
+
iconName={isSearchOpen ? 'close' : 'magnifying-glass-big'}
|
|
316
|
+
size={isMobile ? 'small' : 'medium'}
|
|
317
|
+
state={isSearchOpen ? 'active' : 'normal'}
|
|
318
|
+
aria-expanded={isSearchOpen}
|
|
319
|
+
aria-controls={SEARCH_PANEL_ID}
|
|
320
|
+
onClick={() => handleMenuToggle('search')}
|
|
321
|
+
>
|
|
322
|
+
{searchText}
|
|
323
|
+
</PktButton>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
<PktButton
|
|
327
|
+
id={MENU_TOGGLE_ID}
|
|
328
|
+
className="pkt-header-global__menu-toggle"
|
|
329
|
+
skin={compact ? 'tertiary' : 'secondary'}
|
|
330
|
+
variant={compact ? 'icon-only' : 'icon-right'}
|
|
331
|
+
iconName={isMenuOpen ? 'close' : 'menu'}
|
|
332
|
+
size={isTablet ? 'small' : 'medium'}
|
|
333
|
+
state={isMenuOpen ? 'active' : 'normal'}
|
|
334
|
+
aria-expanded={isMenuOpen}
|
|
335
|
+
aria-controls={MENU_ID}
|
|
336
|
+
disabled={isLoading}
|
|
337
|
+
onClick={() => handleMenuToggle('menu')}
|
|
338
|
+
>
|
|
339
|
+
{menuText}
|
|
340
|
+
</PktButton>
|
|
341
|
+
|
|
342
|
+
{!compact && showContact && contact && !user && (
|
|
343
|
+
<a
|
|
344
|
+
className={classNames(
|
|
345
|
+
'pkt-header-global__contact',
|
|
346
|
+
'pkt-btn',
|
|
347
|
+
'pkt-btn--secondary',
|
|
348
|
+
'pkt-btn--label-only',
|
|
349
|
+
isTablet ? 'pkt-btn--small' : 'pkt-btn--medium',
|
|
350
|
+
)}
|
|
351
|
+
href={contact.url}
|
|
352
|
+
>
|
|
353
|
+
<span className="pkt-btn__text">{contact.text}</span>
|
|
354
|
+
</a>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
{user && (
|
|
359
|
+
<div className="pkt-header-global__user">
|
|
360
|
+
<div className={classNames('pkt-header-global__user-container', isUserOpen && 'is-open')}>
|
|
361
|
+
<PktButton
|
|
362
|
+
className="pkt-header-global__user-button"
|
|
363
|
+
skin="secondary"
|
|
364
|
+
size={isTablet ? 'small' : 'medium'}
|
|
365
|
+
state={isUserOpen ? 'active' : 'normal'}
|
|
366
|
+
variant="icons-right-and-left"
|
|
367
|
+
iconName="user"
|
|
368
|
+
secondIconName={isUserOpen ? 'chevron-thin-up' : 'chevron-thin-down'}
|
|
369
|
+
onClick={() => handleMenuToggle('user')}
|
|
370
|
+
>
|
|
371
|
+
<span className="pkt-sr-only">Brukermeny: </span>
|
|
372
|
+
<span translate="no">{representing?.name || user.name}</span>
|
|
373
|
+
</PktButton>
|
|
374
|
+
{isUserOpen && (
|
|
375
|
+
<div className="pkt-header-global__user-menu is-open">
|
|
376
|
+
<PktHeaderUserMenu
|
|
377
|
+
user={user}
|
|
378
|
+
formattedLastLoggedIn={formattedLastLoggedIn}
|
|
379
|
+
representing={representing}
|
|
380
|
+
userMenu={userMenu}
|
|
381
|
+
canChangeRepresentation={canChangeRepresentation}
|
|
382
|
+
changeRepresentation={changeRepresentation}
|
|
383
|
+
logoutOnClick={showLogoutInUserMenu ? logOut : undefined}
|
|
384
|
+
/>
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
</header>
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
const menuOverlay = isMenuOpen && (
|
|
394
|
+
<div
|
|
395
|
+
className={classNames(
|
|
396
|
+
'pkt-header-global__menu',
|
|
397
|
+
isMobile && 'pkt-header-global__menu--mobile',
|
|
398
|
+
isTablet && !isMobile && 'pkt-header-global__menu--tablet',
|
|
399
|
+
)}
|
|
400
|
+
id={MENU_ID}
|
|
401
|
+
>
|
|
402
|
+
{localeData ? (
|
|
403
|
+
<PktHeaderMenu data={effectiveData} locale={locale} open mobileBreakpoint={mobileBreakpoint} />
|
|
404
|
+
) : (
|
|
405
|
+
<nav className="pkt-header-menu pkt-header-menu--open">
|
|
406
|
+
<p className="pkt-header-menu__error">Kunne ikke laste meny.</p>
|
|
407
|
+
</nav>
|
|
408
|
+
)}
|
|
409
|
+
</div>
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
const searchOverlay = isSearchOpen && hasSearch && search && (
|
|
413
|
+
<div
|
|
414
|
+
className={classNames(
|
|
415
|
+
'pkt-header-global__search-panel',
|
|
416
|
+
isMobile && 'pkt-header-global__search-panel--mobile',
|
|
417
|
+
isTablet && !isMobile && 'pkt-header-global__search-panel--tablet',
|
|
418
|
+
)}
|
|
419
|
+
id={SEARCH_PANEL_ID}
|
|
420
|
+
>
|
|
421
|
+
<div className="pkt-header-global__search-panel-inner">
|
|
422
|
+
<PktSearchInput
|
|
423
|
+
appearance="global"
|
|
424
|
+
fullwidth
|
|
425
|
+
id={searchId}
|
|
426
|
+
name={search.input.name}
|
|
427
|
+
aria-label={searchText}
|
|
428
|
+
placeholder={searchPlaceholder ?? search.input.placeholder}
|
|
429
|
+
action={search.endpoint}
|
|
430
|
+
method="get"
|
|
431
|
+
onSearch={onSearch}
|
|
432
|
+
onChange={() => {}}
|
|
433
|
+
autoFocus
|
|
434
|
+
/>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
if (isFixed) {
|
|
440
|
+
return (
|
|
441
|
+
<div className="pkt-header-global-wrapper">
|
|
442
|
+
{headerElement}
|
|
443
|
+
<div
|
|
444
|
+
className={classNames(
|
|
445
|
+
'pkt-header-global-spacer',
|
|
446
|
+
isMobile && 'pkt-header-global-spacer--mobile',
|
|
447
|
+
isTablet && !isMobile && 'pkt-header-global-spacer--tablet',
|
|
448
|
+
user && 'pkt-header-global-spacer--has-user',
|
|
449
|
+
)}
|
|
450
|
+
/>
|
|
451
|
+
{searchOverlay}
|
|
452
|
+
{menuOverlay}
|
|
453
|
+
</div>
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return (
|
|
458
|
+
<>
|
|
459
|
+
{headerElement}
|
|
460
|
+
{searchOverlay}
|
|
461
|
+
{menuOverlay}
|
|
462
|
+
</>
|
|
463
|
+
)
|
|
464
|
+
},
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
PktHeaderGlobal.displayName = 'PktHeaderGlobal'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render } from '@testing-library/react'
|
|
4
|
+
import { axe, toHaveNoViolations } from 'jest-axe'
|
|
5
|
+
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
// Control the measured width to switch between desktop and tablet layouts.
|
|
8
|
+
let mockedWidth = 1280
|
|
9
|
+
|
|
10
|
+
vi.mock('../../hooks/useElementWidth', async () => ({
|
|
11
|
+
useElementWidth: () => mockedWidth,
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
import { PktHeaderService } from './HeaderService'
|
|
15
|
+
|
|
16
|
+
expect.extend(toHaveNoViolations)
|
|
17
|
+
|
|
18
|
+
describe('PktHeaderService – tilgjengelighet', () => {
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
window.scrollTo = vi.fn()
|
|
21
|
+
})
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
mockedWidth = 1280
|
|
24
|
+
vi.clearAllMocks()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('ingen brudd i standardvisning (desktop)', async () => {
|
|
28
|
+
const { container } = render(
|
|
29
|
+
<PktHeaderService serviceName="Min tjeneste" serviceLink="#" position="relative">
|
|
30
|
+
<a href="#en">Lenke én</a>
|
|
31
|
+
<a href="#to">Lenke to</a>
|
|
32
|
+
</PktHeaderService>,
|
|
33
|
+
)
|
|
34
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('ingen brudd med søk og innlogget bruker', async () => {
|
|
38
|
+
const { container } = render(
|
|
39
|
+
<PktHeaderService
|
|
40
|
+
serviceName="Min tjeneste"
|
|
41
|
+
position="relative"
|
|
42
|
+
showSearch
|
|
43
|
+
user={{ name: 'Aksel Web Opdahl' }}
|
|
44
|
+
/>,
|
|
45
|
+
)
|
|
46
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('ingen brudd med åpen brukermeny', async () => {
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<PktHeaderService
|
|
52
|
+
serviceName="Min tjeneste"
|
|
53
|
+
position="relative"
|
|
54
|
+
user={{ name: 'Aksel Web Opdahl' }}
|
|
55
|
+
/>,
|
|
56
|
+
)
|
|
57
|
+
fireEvent.click(container.querySelector('.pkt-header-service__user-button') as Element)
|
|
58
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('ingen brudd i nettbrettvisning (meny som knapp)', async () => {
|
|
62
|
+
mockedWidth = 800
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<PktHeaderService serviceName="Min tjeneste" position="relative" showSearch>
|
|
65
|
+
<a href="#en">Lenke én</a>
|
|
66
|
+
</PktHeaderService>,
|
|
67
|
+
)
|
|
68
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -110,18 +110,16 @@ describe('PktHeaderService', () => {
|
|
|
110
110
|
// New tests for logoLink prop
|
|
111
111
|
describe('logoLink prop', () => {
|
|
112
112
|
it('renders logo as link when logoLink is a string', () => {
|
|
113
|
-
const { container } = render(<PktHeaderService serviceName="Svc" logoLink="https://oslo.kommune.no" />)
|
|
113
|
+
const { container } = render(<PktHeaderService serviceName="Svc" logoLink="https://www.oslo.kommune.no" />)
|
|
114
114
|
const logoLink = container.querySelector('.pkt-header-service__logo a')
|
|
115
115
|
expect(logoLink).toBeTruthy()
|
|
116
|
-
expect(logoLink).toHaveAttribute('href', 'https://oslo.kommune.no')
|
|
116
|
+
expect(logoLink).toHaveAttribute('href', 'https://www.oslo.kommune.no')
|
|
117
117
|
})
|
|
118
118
|
|
|
119
|
-
it('
|
|
119
|
+
it('lenker logoen til www.oslo.kommune.no som standard når logoLink ikke er oppgitt', () => {
|
|
120
120
|
const { container } = render(<PktHeaderService serviceName="Svc" />)
|
|
121
121
|
const logoLink = container.querySelector('.pkt-header-service__logo a')
|
|
122
|
-
|
|
123
|
-
expect(logoLink).toBeNull()
|
|
124
|
-
expect(logoButton).toBeNull()
|
|
122
|
+
expect(logoLink).toHaveAttribute('href', 'https://www.oslo.kommune.no')
|
|
125
123
|
})
|
|
126
124
|
})
|
|
127
125
|
|
|
@@ -139,6 +137,16 @@ describe('PktHeaderService', () => {
|
|
|
139
137
|
expect(header).not.toHaveClass('pkt-header-service--fixed')
|
|
140
138
|
})
|
|
141
139
|
|
|
140
|
+
it('applies sticky class when position="sticky", without wrapper/spacer', () => {
|
|
141
|
+
const { container } = render(<PktHeaderService serviceName="Svc" position="sticky" />)
|
|
142
|
+
const header = container.querySelector('.pkt-header-service')
|
|
143
|
+
expect(header).toHaveClass('pkt-header-service--sticky')
|
|
144
|
+
expect(header).not.toHaveClass('pkt-header-service--fixed')
|
|
145
|
+
// Sticky stays in the document flow — no fixed-position wrapper or spacer.
|
|
146
|
+
expect(container.querySelector('.pkt-header-service-wrapper')).toBeNull()
|
|
147
|
+
expect(container.querySelector('.pkt-header-service-spacer')).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
142
150
|
it('applies scroll-to-hide class by default (scrollBehavior="hide")', () => {
|
|
143
151
|
const { container } = render(<PktHeaderService serviceName="Svc" />)
|
|
144
152
|
const header = container.querySelector('.pkt-header-service')
|
|
@@ -439,24 +447,29 @@ describe('PktHeaderService', () => {
|
|
|
439
447
|
|
|
440
448
|
// Tests for logoClick prop
|
|
441
449
|
describe('logoClick prop', () => {
|
|
442
|
-
it('
|
|
443
|
-
|
|
450
|
+
it('logoen er alltid en lenke og kaller logoClick ved klikk', () => {
|
|
451
|
+
// preventDefault: jsdom kan ikke navigere
|
|
452
|
+
const handleClick = vi.fn((e: React.MouseEvent) => e.preventDefault())
|
|
444
453
|
const { container } = render(<PktHeaderService serviceName="Svc" logoClick={handleClick} />)
|
|
454
|
+
const logoLink = container.querySelector('.pkt-header-service__logo a')
|
|
445
455
|
const logoButton = container.querySelector('.pkt-header-service__logo button')
|
|
446
|
-
expect(
|
|
447
|
-
|
|
456
|
+
expect(logoLink).toBeTruthy()
|
|
457
|
+
expect(logoButton).toBeNull()
|
|
458
|
+
|
|
459
|
+
fireEvent.click(logoLink!)
|
|
448
460
|
expect(handleClick).toHaveBeenCalledTimes(1)
|
|
449
461
|
})
|
|
450
462
|
|
|
451
|
-
it('
|
|
452
|
-
const handleClick = vi.fn()
|
|
463
|
+
it('bruker logoLink som href når oppgitt, sammen med logoClick', () => {
|
|
464
|
+
const handleClick = vi.fn((e: React.MouseEvent) => e.preventDefault())
|
|
453
465
|
const { container } = render(
|
|
454
|
-
<PktHeaderService serviceName="Svc" logoLink="https://
|
|
466
|
+
<PktHeaderService serviceName="Svc" logoLink="https://example.com" logoClick={handleClick} />,
|
|
455
467
|
)
|
|
456
468
|
const logoLink = container.querySelector('.pkt-header-service__logo a')
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
469
|
+
expect(logoLink).toHaveAttribute('href', 'https://example.com')
|
|
470
|
+
|
|
471
|
+
fireEvent.click(logoLink!)
|
|
472
|
+
expect(handleClick).toHaveBeenCalledTimes(1)
|
|
460
473
|
})
|
|
461
474
|
})
|
|
462
475
|
|