@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.
Files changed (100) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/components/accordion/Accordion.d.ts +1 -0
  3. package/dist/components/alert/Alert.d.ts +9 -0
  4. package/dist/components/button/Button.d.ts +1 -1
  5. package/dist/components/combobox/types.d.ts +2 -0
  6. package/dist/components/datepicker/types.d.ts +3 -0
  7. package/dist/components/fileupload/FileUpload.d.ts +12 -2
  8. package/dist/components/footer/Footer.accessibility.test.d.ts +0 -0
  9. package/dist/components/footer/Footer.d.ts +12 -34
  10. package/dist/components/footer/Footer.test.d.ts +1 -0
  11. package/dist/components/footer/FooterGlobal.d.ts +23 -0
  12. package/dist/components/footerSimple/FooterSimple.d.ts +19 -16
  13. package/dist/components/footerSimple/FooterSimple.test.d.ts +1 -0
  14. package/dist/components/header/Header.d.ts +15 -3
  15. package/dist/components/header/HeaderGlobal.accessibility.test.d.ts +0 -0
  16. package/dist/components/header/HeaderGlobal.d.ts +2 -0
  17. package/dist/components/header/HeaderGlobal.test.d.ts +0 -0
  18. package/dist/components/header/HeaderService.accessibility.test.d.ts +0 -0
  19. package/dist/components/header/types.d.ts +30 -2
  20. package/dist/components/header-menu/HeaderMenu.d.ts +2 -4
  21. package/dist/components/index.d.ts +4 -0
  22. package/dist/components/inputwrapper/InputWrapper.d.ts +1 -0
  23. package/dist/components/searchinput/SearchInput.d.ts +1 -0
  24. package/dist/components/select/Select.d.ts +4 -7
  25. package/dist/components/textarea/Textarea.d.ts +2 -0
  26. package/dist/components/textinput/Textinput.d.ts +1 -0
  27. package/dist/components/timepicker/types.d.ts +3 -0
  28. package/dist/components/timepicker/useTimepickerState.d.ts +2 -0
  29. package/dist/punkt-react.cjs +1 -1
  30. package/dist/punkt-react.js +3191 -2727
  31. package/dist/shared-types/footer.d.ts +95 -0
  32. package/dist/shared-types/{header-menu.d.ts → header-footer.d.ts} +51 -4
  33. package/dist/shared-types/header.d.ts +1 -1
  34. package/dist/shared-types/index.d.ts +6 -4
  35. package/dist/shared-types/searchinput.d.ts +2 -0
  36. package/dist/shared-types/select.d.ts +3 -2
  37. package/dist/shared-types/size.d.ts +4 -0
  38. package/dist/shared-types/timepicker.d.ts +0 -6
  39. package/dist/shared-utils/datepicker-utils.d.ts +5 -4
  40. package/dist/shared-utils/fileupload/focus-trap.d.ts +4 -10
  41. package/dist/shared-utils/focus-trap/index.d.ts +22 -0
  42. package/dist/shared-utils/{header-menu → header-footer}/example-data.d.ts +5 -4
  43. package/dist/shared-utils/header-footer/external-domain.d.ts +26 -0
  44. package/dist/shared-utils/{header-menu → header-footer}/fetch-header-footer-data.d.ts +6 -2
  45. package/dist/shared-utils/{header-menu → header-footer}/index.d.ts +2 -1
  46. package/dist/shared-utils/{header-menu → header-footer}/select-megamenu.d.ts +1 -1
  47. package/dist/shared-utils/scroll-lock/index.d.ts +24 -0
  48. package/package.json +4 -4
  49. package/src/components/accordion/Accordion.test.tsx +51 -0
  50. package/src/components/accordion/Accordion.tsx +3 -3
  51. package/src/components/accordion/AccordionItem.tsx +7 -14
  52. package/src/components/alert/Alert.test.tsx +45 -9
  53. package/src/components/alert/Alert.tsx +13 -3
  54. package/src/components/button/Button.tsx +2 -2
  55. package/src/components/combobox/Combobox.core.test.tsx +14 -0
  56. package/src/components/combobox/Combobox.tsx +1 -0
  57. package/src/components/combobox/ComboboxInput.tsx +2 -0
  58. package/src/components/combobox/types.ts +2 -0
  59. package/src/components/combobox/useComboboxState.ts +2 -0
  60. package/src/components/consent/Consent.test.tsx +8 -14
  61. package/src/components/consent/Consent.tsx +9 -19
  62. package/src/components/datepicker/Datepicker.core.test.tsx +11 -0
  63. package/src/components/datepicker/Datepicker.tsx +4 -1
  64. package/src/components/datepicker/DatepickerInputs.tsx +16 -1
  65. package/src/components/datepicker/types.ts +3 -0
  66. package/src/components/datepicker/useDatepickerState.ts +17 -9
  67. package/src/components/fileupload/FileUpload.tsx +27 -9
  68. package/src/components/footer/Footer.accessibility.test.tsx +46 -0
  69. package/src/components/footer/Footer.test.tsx +192 -86
  70. package/src/components/footer/Footer.tsx +169 -159
  71. package/src/components/footer/FooterGlobal.tsx +181 -0
  72. package/src/components/footerSimple/FooterSimple.test.tsx +49 -46
  73. package/src/components/footerSimple/FooterSimple.tsx +117 -78
  74. package/src/components/header/Header.tsx +17 -4
  75. package/src/components/header/HeaderGlobal.accessibility.test.tsx +86 -0
  76. package/src/components/header/HeaderGlobal.test.tsx +254 -0
  77. package/src/components/header/HeaderGlobal.tsx +467 -0
  78. package/src/components/header/HeaderService.accessibility.test.tsx +70 -0
  79. package/src/components/header/HeaderService.test.tsx +29 -16
  80. package/src/components/header/HeaderService.tsx +24 -84
  81. package/src/components/header/types.ts +35 -0
  82. package/src/components/header-menu/HeaderMenu.test.tsx +16 -39
  83. package/src/components/header-menu/HeaderMenu.tsx +62 -70
  84. package/src/components/index.ts +4 -0
  85. package/src/components/inputwrapper/InputWrapper.tsx +6 -1
  86. package/src/components/modal/Modal.tsx +7 -11
  87. package/src/components/searchinput/SearchInput.test.tsx +10 -0
  88. package/src/components/searchinput/SearchInput.tsx +19 -7
  89. package/src/components/select/Select.test.tsx +18 -0
  90. package/src/components/select/Select.tsx +8 -4
  91. package/src/components/textarea/Textarea.test.tsx +10 -0
  92. package/src/components/textarea/Textarea.tsx +5 -1
  93. package/src/components/textinput/Textinput.test.tsx +28 -0
  94. package/src/components/textinput/Textinput.tsx +5 -2
  95. package/src/components/timepicker/Timepicker.test.tsx +10 -0
  96. package/src/components/timepicker/Timepicker.tsx +8 -7
  97. package/src/components/timepicker/types.ts +8 -5
  98. package/src/components/timepicker/useTimepickerState.ts +4 -0
  99. package/dist/shared-utils/modal-scroll-lock.d.ts +0 -10
  100. /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('renders logo without link/button when logoLink is not provided', () => {
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
- const logoButton = container.querySelector('.pkt-header-service__logo button')
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('renders logo as button when logoClick is provided', () => {
443
- const handleClick = vi.fn()
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(logoButton).toBeTruthy()
447
- fireEvent.click(logoButton!)
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('prioritizes logoLink over logoClick when both are provided', () => {
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://oslo.kommune.no" logoClick={handleClick} />,
466
+ <PktHeaderService serviceName="Svc" logoLink="https://example.com" logoClick={handleClick} />,
455
467
  )
456
468
  const logoLink = container.querySelector('.pkt-header-service__logo a')
457
- const logoButton = container.querySelector('.pkt-header-service__logo button')
458
- expect(logoLink).toBeTruthy()
459
- expect(logoButton).toBeNull()
469
+ expect(logoLink).toHaveAttribute('href', 'https://example.com')
470
+
471
+ fireEvent.click(logoLink!)
472
+ expect(handleClick).toHaveBeenCalledTimes(1)
460
473
  })
461
474
  })
462
475