@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
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
forwardRef,
|
|
9
9
|
type KeyboardEvent,
|
|
10
10
|
type MouseEvent as ReactMouseEvent,
|
|
11
|
-
type MutableRefObject,
|
|
12
11
|
type RefObject,
|
|
13
12
|
useCallback,
|
|
14
13
|
useEffect,
|
|
@@ -17,13 +16,15 @@ import {
|
|
|
17
16
|
useState,
|
|
18
17
|
} from 'react'
|
|
19
18
|
|
|
20
|
-
import {
|
|
19
|
+
import { useHeaderChrome } from '../../hooks/useHeaderChrome'
|
|
21
20
|
import { useScrollLock } from '../../hooks/useScrollLock'
|
|
22
21
|
import { PktButton } from '../button/Button'
|
|
23
22
|
import { PktHeaderUserMenu } from '../headerUserMenu/UserMenu'
|
|
24
23
|
import { PktIcon } from '../icon/Icon'
|
|
25
24
|
import { PktLink } from '../link/Link'
|
|
26
25
|
import { PktTextinput } from '../textinput/Textinput'
|
|
26
|
+
import { DEFAULT_HEADER_LOGO_LINK } from 'shared-types'
|
|
27
|
+
|
|
27
28
|
import { IPktHeader, Representing, THeaderMenu, User, UserMenuFooterItem, UserMenuItem } from './types'
|
|
28
29
|
|
|
29
30
|
export type { Representing, THeaderMenu, User, UserMenuFooterItem, UserMenuItem }
|
|
@@ -69,7 +70,7 @@ export const PktHeaderService = forwardRef(
|
|
|
69
70
|
canChangeRepresentation = false,
|
|
70
71
|
changeRepresentation,
|
|
71
72
|
hideLogo = false,
|
|
72
|
-
logoLink,
|
|
73
|
+
logoLink = DEFAULT_HEADER_LOGO_LINK,
|
|
73
74
|
logoClick,
|
|
74
75
|
logoPath = typeof window !== 'undefined' ? window.pktLogoPath : defaultLogoPath,
|
|
75
76
|
position = 'fixed',
|
|
@@ -79,7 +80,6 @@ export const PktHeaderService = forwardRef(
|
|
|
79
80
|
}: IPktHeaderService,
|
|
80
81
|
ref: ForwardedRef<HTMLDivElement>,
|
|
81
82
|
) => {
|
|
82
|
-
const isFixed = position === 'fixed'
|
|
83
83
|
const shouldHideOnScroll = scrollBehavior === 'hide'
|
|
84
84
|
|
|
85
85
|
// Deprecation warning for userMenuFooter
|
|
@@ -136,37 +136,26 @@ export const PktHeaderService = forwardRef(
|
|
|
136
136
|
}
|
|
137
137
|
}, [openedMenu])
|
|
138
138
|
|
|
139
|
-
const [hidden, setHidden] = useState(false)
|
|
140
|
-
const [lastScrollPosition, setLastScrollPosition] = useState(0)
|
|
141
139
|
const [alignSlotRight, setAlignSlotRight] = useState(false)
|
|
142
140
|
const [alignSearchRight, setAlignSearchRight] = useState(false)
|
|
143
141
|
|
|
144
|
-
const internalRef = useRef<HTMLDivElement>(null)
|
|
145
142
|
const userContainerRef = useRef<HTMLDivElement>(null)
|
|
146
143
|
const slotContainerRef = useRef<HTMLDivElement>(null)
|
|
147
144
|
const searchContainerRef = useRef<HTMLDivElement>(null)
|
|
148
145
|
const slotContentRef = useRef<HTMLDivElement>(null)
|
|
149
146
|
const searchMenuRef = useRef<HTMLDivElement>(null)
|
|
150
|
-
const lastFocusedElementRef = useRef<HTMLElement | null>(null)
|
|
151
|
-
const shouldRestoreFocusRef = useRef(false)
|
|
152
|
-
|
|
153
|
-
const headerWidth = useElementWidth(internalRef)
|
|
154
|
-
const isMobile: boolean = headerWidth < mobileBreakpoint
|
|
155
|
-
const isTablet: boolean = headerWidth < tabletBreakpoint
|
|
156
|
-
|
|
157
|
-
const setRefs = useCallback(
|
|
158
|
-
(element: HTMLDivElement | null) => {
|
|
159
|
-
;(internalRef as MutableRefObject<HTMLDivElement | null>).current = element
|
|
160
|
-
if (typeof ref === 'function') {
|
|
161
|
-
ref(element)
|
|
162
|
-
} else if (ref) {
|
|
163
|
-
;(ref as MutableRefObject<HTMLDivElement | null>).current = element
|
|
164
|
-
}
|
|
165
|
-
},
|
|
166
|
-
[ref],
|
|
167
|
-
)
|
|
168
147
|
|
|
169
|
-
|
|
148
|
+
// Shared shell logic (ref merge, breakpoints, scroll-to-hide, focus return).
|
|
149
|
+
const { setRef, isMobile, isTablet, isFixed, isSticky, hidden, captureFocus, flagRestoreFocus, restoreFocus } =
|
|
150
|
+
useHeaderChrome<HTMLDivElement>({
|
|
151
|
+
forwardedRef: ref,
|
|
152
|
+
mobileBreakpoint,
|
|
153
|
+
tabletBreakpoint,
|
|
154
|
+
position,
|
|
155
|
+
scrollBehavior,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
useScrollLock((isFixed || isSticky) && isMobile && openMenu !== 'none')
|
|
170
159
|
|
|
171
160
|
const handleFocusOut = useCallback((event: FocusEvent<HTMLDivElement>, menuType: THeaderMenu) => {
|
|
172
161
|
const relatedTarget = event.relatedTarget as HTMLElement | null
|
|
@@ -194,18 +183,6 @@ export const PktHeaderService = forwardRef(
|
|
|
194
183
|
}
|
|
195
184
|
}, [])
|
|
196
185
|
|
|
197
|
-
const restoreFocus = useCallback(() => {
|
|
198
|
-
if (
|
|
199
|
-
shouldRestoreFocusRef.current &&
|
|
200
|
-
lastFocusedElementRef.current &&
|
|
201
|
-
document.contains(lastFocusedElementRef.current)
|
|
202
|
-
) {
|
|
203
|
-
lastFocusedElementRef.current.focus()
|
|
204
|
-
}
|
|
205
|
-
lastFocusedElementRef.current = null
|
|
206
|
-
shouldRestoreFocusRef.current = false
|
|
207
|
-
}, [])
|
|
208
|
-
|
|
209
186
|
useEffect(() => {
|
|
210
187
|
const handleClickOutside = (event: MouseEvent) => {
|
|
211
188
|
if (user && openMenu === 'user' && !(event.target as Element).closest('.pkt-header-service__user-container')) {
|
|
@@ -226,7 +203,7 @@ export const PktHeaderService = forwardRef(
|
|
|
226
203
|
const handleEscapeKey = (event: globalThis.KeyboardEvent) => {
|
|
227
204
|
if (event.key === 'Escape' && openMenu !== 'none') {
|
|
228
205
|
event.preventDefault()
|
|
229
|
-
|
|
206
|
+
flagRestoreFocus()
|
|
230
207
|
setOpenMenu('none')
|
|
231
208
|
}
|
|
232
209
|
}
|
|
@@ -241,32 +218,7 @@ export const PktHeaderService = forwardRef(
|
|
|
241
218
|
} else {
|
|
242
219
|
restoreFocus()
|
|
243
220
|
}
|
|
244
|
-
}, [openMenu, user, restoreFocus])
|
|
245
|
-
|
|
246
|
-
useEffect(() => {
|
|
247
|
-
const onScroll = () => {
|
|
248
|
-
if (shouldHideOnScroll) {
|
|
249
|
-
const currentScrollPosition = window.pageYOffset || document.documentElement.scrollTop
|
|
250
|
-
if (currentScrollPosition < 0) {
|
|
251
|
-
return
|
|
252
|
-
}
|
|
253
|
-
if (Math.abs(currentScrollPosition - lastScrollPosition) < 60) {
|
|
254
|
-
return
|
|
255
|
-
}
|
|
256
|
-
setHidden(currentScrollPosition > lastScrollPosition)
|
|
257
|
-
setLastScrollPosition(currentScrollPosition)
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (document) {
|
|
262
|
-
window.addEventListener('scroll', onScroll)
|
|
263
|
-
}
|
|
264
|
-
return () => {
|
|
265
|
-
if (document) {
|
|
266
|
-
window.removeEventListener('scroll', onScroll)
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}, [shouldHideOnScroll, lastScrollPosition])
|
|
221
|
+
}, [openMenu, user, restoreFocus, flagRestoreFocus])
|
|
270
222
|
|
|
271
223
|
const checkDropdownAlignment = useCallback(
|
|
272
224
|
(mode: 'slot' | 'search') => {
|
|
@@ -309,12 +261,11 @@ export const PktHeaderService = forwardRef(
|
|
|
309
261
|
if (openMenu !== 'none') {
|
|
310
262
|
setOpenMenu('none')
|
|
311
263
|
} else {
|
|
312
|
-
|
|
313
|
-
|
|
264
|
+
captureFocus()
|
|
314
265
|
setOpenMenu(mode)
|
|
315
266
|
}
|
|
316
267
|
},
|
|
317
|
-
[openMenu],
|
|
268
|
+
[openMenu, captureFocus],
|
|
318
269
|
)
|
|
319
270
|
|
|
320
271
|
useEffect(() => {
|
|
@@ -333,12 +284,13 @@ export const PktHeaderService = forwardRef(
|
|
|
333
284
|
isMobile && 'pkt-header-service--mobile',
|
|
334
285
|
isTablet && 'pkt-header-service--tablet',
|
|
335
286
|
isFixed && 'pkt-header-service--fixed',
|
|
287
|
+
isSticky && 'pkt-header-service--sticky',
|
|
336
288
|
shouldHideOnScroll && 'pkt-header-service--scroll-to-hide',
|
|
337
289
|
hidden && 'pkt-header-service--hidden',
|
|
338
290
|
className,
|
|
339
291
|
)}
|
|
340
292
|
data-mode={dataMode}
|
|
341
|
-
ref={
|
|
293
|
+
ref={setRef}
|
|
342
294
|
>
|
|
343
295
|
<div
|
|
344
296
|
className={classNames(
|
|
@@ -348,21 +300,9 @@ export const PktHeaderService = forwardRef(
|
|
|
348
300
|
>
|
|
349
301
|
{!hideLogo && (
|
|
350
302
|
<span className="pkt-header-service__logo">
|
|
351
|
-
{logoLink
|
|
352
|
-
<a href={logoLink} aria-label="Tilbake til forside">
|
|
353
|
-
<PktIcon name="oslologo" aria-hidden="true" path={logoPath}></PktIcon>
|
|
354
|
-
</a>
|
|
355
|
-
) : logoClick ? (
|
|
356
|
-
<button
|
|
357
|
-
aria-label="Tilbake til forside"
|
|
358
|
-
className="pkt-link-button pkt-link pkt-header-service__logo-link"
|
|
359
|
-
onClick={logoClick}
|
|
360
|
-
>
|
|
361
|
-
<PktIcon name="oslologo" aria-hidden="true" path={logoPath}></PktIcon>
|
|
362
|
-
</button>
|
|
363
|
-
) : (
|
|
303
|
+
<a href={logoLink} aria-label="Tilbake til forside" onClick={logoClick}>
|
|
364
304
|
<PktIcon name="oslologo" aria-hidden="true" path={logoPath}></PktIcon>
|
|
365
|
-
|
|
305
|
+
</a>
|
|
366
306
|
</span>
|
|
367
307
|
)}
|
|
368
308
|
{serviceName && (
|
|
@@ -409,7 +349,7 @@ export const PktHeaderService = forwardRef(
|
|
|
409
349
|
<div
|
|
410
350
|
className={classNames('pkt-header-service__slot-content', alignSlotRight && 'align-right')}
|
|
411
351
|
id="mobile-slot-menu"
|
|
412
|
-
role={isTablet ? '
|
|
352
|
+
role={isTablet ? 'group' : undefined}
|
|
413
353
|
aria-label={isTablet ? 'Meny' : undefined}
|
|
414
354
|
ref={slotContentRef}
|
|
415
355
|
onClick={handleSlotContentClick}
|
|
@@ -5,6 +5,9 @@ import type {
|
|
|
5
5
|
THeaderScrollBehavior,
|
|
6
6
|
TLogOutButtonPlacement,
|
|
7
7
|
TSlotMenuVariant,
|
|
8
|
+
THeaderFooterApi,
|
|
9
|
+
THeaderMenuLocale,
|
|
10
|
+
IPktHeaderGlobal as ISharedPktHeaderGlobal,
|
|
8
11
|
} from 'shared-types'
|
|
9
12
|
|
|
10
13
|
export type {
|
|
@@ -13,8 +16,13 @@ export type {
|
|
|
13
16
|
THeaderScrollBehavior,
|
|
14
17
|
TLogOutButtonPlacement,
|
|
15
18
|
TSlotMenuVariant,
|
|
19
|
+
THeaderFooterApi,
|
|
20
|
+
THeaderMenuLocale,
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
/** Which header variant `<PktHeader>` renders. */
|
|
24
|
+
export type THeaderType = 'service' | 'global'
|
|
25
|
+
|
|
18
26
|
/** User object for header components */
|
|
19
27
|
export interface User {
|
|
20
28
|
name: string
|
|
@@ -108,4 +116,31 @@ export interface IPktHeader {
|
|
|
108
116
|
slotMenuVariant?: TSlotMenuVariant
|
|
109
117
|
/** Text for the slot menu button in tablet/mobile mode. Default: 'Meny' */
|
|
110
118
|
slotMenuText?: string
|
|
119
|
+
/** Which header variant to render. Default: 'service' */
|
|
120
|
+
type?: THeaderType
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Props for the global (kommune-wide) Header component.
|
|
125
|
+
*
|
|
126
|
+
* Extends the framework-agnostic {@link ISharedPktHeaderGlobal} base
|
|
127
|
+
* (in shared-types) with React-specific callbacks and DOM props.
|
|
128
|
+
*/
|
|
129
|
+
export interface IPktHeaderGlobal extends ISharedPktHeaderGlobal {
|
|
130
|
+
/** Additional CSS class name(s) to apply to the header */
|
|
131
|
+
className?: string
|
|
132
|
+
/** Set dark mode on the header */
|
|
133
|
+
'data-mode'?: 'dark'
|
|
134
|
+
/** Callback when the logo is clicked */
|
|
135
|
+
logoClick?: (event: MouseEvent) => void
|
|
136
|
+
/** Callback when the user submits search. When set, navigation is left to the consumer */
|
|
137
|
+
onSearch?: (query: string) => void
|
|
138
|
+
/** Callback when the user clicks "Endre organisasjon" */
|
|
139
|
+
changeRepresentation?: () => void
|
|
140
|
+
/** Callback when the user clicks logout */
|
|
141
|
+
logOut?: () => void
|
|
142
|
+
/** Fired when the payload has been fetched (or `data` was supplied) */
|
|
143
|
+
onDataLoaded?: (data: THeaderFooterApi) => void
|
|
144
|
+
/** Fired when the fetch fails */
|
|
145
|
+
onDataError?: (error: Error) => void
|
|
111
146
|
}
|
|
@@ -64,10 +64,9 @@ afterEach(() => {
|
|
|
64
64
|
|
|
65
65
|
describe('PktHeaderMenu rendering', () => {
|
|
66
66
|
test('renders services, sections and footer when given pre-fetched data', () => {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
67
|
+
// jsdom reports width 0 (mobile/accordion layout); force desktop.
|
|
68
|
+
const { container } = render(<PktHeaderMenu data={LOCAL_DATA} mobileBreakpoint={0} open />)
|
|
69
|
+
expect(container.querySelector('.pkt-header-menu__services-title')?.textContent).toContain('Tjenester og tilbud')
|
|
71
70
|
expect(container.querySelectorAll('.pkt-header-menu__service')).toHaveLength(3)
|
|
72
71
|
expect(container.querySelectorAll('.pkt-header-menu__section')).toHaveLength(2)
|
|
73
72
|
expect(container.querySelectorAll('.pkt-header-menu__footer-link')).toHaveLength(2)
|
|
@@ -81,7 +80,7 @@ describe('PktHeaderMenu rendering', () => {
|
|
|
81
80
|
|
|
82
81
|
test('maps ODS icon names on service links', async () => {
|
|
83
82
|
const { container } = render(<PktHeaderMenu data={LOCAL_DATA} open />)
|
|
84
|
-
const icons = container.querySelectorAll<HTMLElement>('.pkt-header-menu__service-icon')
|
|
83
|
+
const icons = container.querySelectorAll<HTMLElement>('.pkt-header-menu__service-icon')
|
|
85
84
|
await waitFor(() => expect(icons[0]).toHaveAttribute('name', '24h'))
|
|
86
85
|
expect(icons[1]).toHaveAttribute('name', 'shield-fire')
|
|
87
86
|
// Unmapped passes through
|
|
@@ -99,36 +98,26 @@ const icons = container.querySelectorAll<HTMLElement>('.pkt-header-menu__service
|
|
|
99
98
|
|
|
100
99
|
test('uses the locale-specific nav aria-label', () => {
|
|
101
100
|
const { container } = render(<PktHeaderMenu data={LOCAL_DATA} locale="nb-NO" open />)
|
|
102
|
-
expect(container.querySelector('nav')?.getAttribute('aria-label')).toBe(
|
|
103
|
-
'Oslo kommune hovedmeny',
|
|
104
|
-
)
|
|
101
|
+
expect(container.querySelector('nav')?.getAttribute('aria-label')).toBe('Oslo kommune hovedmeny')
|
|
105
102
|
})
|
|
106
103
|
|
|
107
104
|
test('switches content when locale changes', () => {
|
|
108
|
-
const { container, rerender } = render(
|
|
109
|
-
|
|
110
|
-
)
|
|
111
|
-
expect(container.querySelector('.pkt-header-menu__services-title')?.textContent).toContain(
|
|
112
|
-
'Tjenester og tilbud',
|
|
113
|
-
)
|
|
114
|
-
rerender(<PktHeaderMenu data={LOCAL_DATA} locale="en-GB" open />)
|
|
105
|
+
const { container, rerender } = render(<PktHeaderMenu data={LOCAL_DATA} locale="nb-NO" mobileBreakpoint={0} open />)
|
|
106
|
+
expect(container.querySelector('.pkt-header-menu__services-title')?.textContent).toContain('Tjenester og tilbud')
|
|
107
|
+
rerender(<PktHeaderMenu data={LOCAL_DATA} locale="en-GB" mobileBreakpoint={0} open />)
|
|
115
108
|
expect(container.querySelector('.pkt-header-menu__services-title')?.textContent).toContain(
|
|
116
109
|
'Services and information',
|
|
117
110
|
)
|
|
118
|
-
expect(container.querySelector('nav')?.getAttribute('aria-label')).toBe(
|
|
119
|
-
'Oslo kommune main menu',
|
|
120
|
-
)
|
|
111
|
+
expect(container.querySelector('nav')?.getAttribute('aria-label')).toBe('Oslo kommune main menu')
|
|
121
112
|
})
|
|
122
113
|
|
|
123
114
|
test('renders social links with derived aria-labels and icons', async () => {
|
|
124
115
|
const { container } = render(<PktHeaderMenu data={LOCAL_DATA} open />)
|
|
125
|
-
const social = container.querySelectorAll<HTMLElement>('.pkt-header-menu__social-link')
|
|
116
|
+
const social = container.querySelectorAll<HTMLElement>('.pkt-header-menu__social-link')
|
|
126
117
|
expect(social).toHaveLength(2)
|
|
127
118
|
expect(social[0]).toHaveAttribute('aria-label', 'Facebook')
|
|
128
119
|
expect(social[1]).toHaveAttribute('aria-label', 'LinkedIn')
|
|
129
|
-
await waitFor(() =>
|
|
130
|
-
expect(social[0].querySelector('.pkt-icon')).toHaveAttribute('name', 'facebook'),
|
|
131
|
-
)
|
|
120
|
+
await waitFor(() => expect(social[0].querySelector('.pkt-icon')).toHaveAttribute('name', 'facebook'))
|
|
132
121
|
expect(social[1].querySelector('.pkt-icon')).toHaveAttribute('name', 'linkedin')
|
|
133
122
|
})
|
|
134
123
|
|
|
@@ -152,34 +141,22 @@ describe('PktHeaderMenu fetching', () => {
|
|
|
152
141
|
})
|
|
153
142
|
|
|
154
143
|
test('fetches the default URL on mount when no data is supplied', async () => {
|
|
155
|
-
const { container } = render(<PktHeaderMenu open />)
|
|
144
|
+
const { container } = render(<PktHeaderMenu mobileBreakpoint={0} open />)
|
|
156
145
|
await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled())
|
|
157
|
-
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
158
|
-
'https://cdn.web.oslo.kommune.no/header-footer/header-footer.json',
|
|
159
|
-
expect.any(Object),
|
|
160
|
-
)
|
|
146
|
+
expect(globalThis.fetch).toHaveBeenCalledWith('https://cdn.web.oslo.kommune.no/header-footer/header-footer.json')
|
|
161
147
|
await waitFor(() =>
|
|
162
|
-
expect(container.querySelector('.pkt-header-menu__services-title')?.textContent).toContain(
|
|
163
|
-
'Tjenester og tilbud',
|
|
164
|
-
),
|
|
148
|
+
expect(container.querySelector('.pkt-header-menu__services-title')?.textContent).toContain('Tjenester og tilbud'),
|
|
165
149
|
)
|
|
166
150
|
})
|
|
167
151
|
|
|
168
152
|
test('honors a custom data-url', async () => {
|
|
169
153
|
render(<PktHeaderMenu dataUrl="https://custom.test/payload.json" open />)
|
|
170
|
-
await waitFor(() =>
|
|
171
|
-
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
172
|
-
'https://custom.test/payload.json',
|
|
173
|
-
expect.any(Object),
|
|
174
|
-
),
|
|
175
|
-
)
|
|
154
|
+
await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledWith('https://custom.test/payload.json'))
|
|
176
155
|
})
|
|
177
156
|
|
|
178
157
|
test('calls onDataError when the fetch fails', async () => {
|
|
179
158
|
vi.restoreAllMocks()
|
|
180
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
181
|
-
new Response('boom', { status: 500 }) as unknown as Response,
|
|
182
|
-
)
|
|
159
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('boom', { status: 500 }) as unknown as Response)
|
|
183
160
|
const onDataError = vi.fn()
|
|
184
161
|
const { container } = render(<PktHeaderMenu open onDataError={onDataError} />)
|
|
185
162
|
await waitFor(() => expect(onDataError).toHaveBeenCalledTimes(1))
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type HTMLAttributes,
|
|
5
|
+
type MutableRefObject,
|
|
6
|
+
type Ref,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react'
|
|
4
13
|
import {
|
|
5
14
|
DEFAULT_HEADER_FOOTER_URL,
|
|
6
15
|
type IHeaderMenuButton,
|
|
@@ -11,8 +20,9 @@ import {
|
|
|
11
20
|
type THeaderFooterApi,
|
|
12
21
|
type THeaderMenuLocale,
|
|
13
22
|
} from 'shared-types'
|
|
14
|
-
import { deriveSocialIcon, fetchHeaderFooterData, mapOdsIcon, selectLocaleData } from 'shared-utils/header-
|
|
23
|
+
import { deriveSocialIcon, fetchHeaderFooterData, mapOdsIcon, selectLocaleData } from 'shared-utils/header-footer'
|
|
15
24
|
|
|
25
|
+
import { useElementWidth } from '../../hooks/useElementWidth'
|
|
16
26
|
import { PktAccordion } from '../accordion/Accordion'
|
|
17
27
|
import { PktAccordionItem } from '../accordion/AccordionItem'
|
|
18
28
|
import { PktIcon } from '../icon/Icon'
|
|
@@ -33,8 +43,6 @@ export interface IPktHeaderMenu extends Omit<HTMLAttributes<HTMLElement>, 'onErr
|
|
|
33
43
|
onDataLoaded?: (data: THeaderFooterApi) => void
|
|
34
44
|
/** Fired when the fetch fails. */
|
|
35
45
|
onDataError?: (error: Error) => void
|
|
36
|
-
/** Optional id of the toggle button that controls this menu. */
|
|
37
|
-
ariaLabelledBy?: string
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
|
|
@@ -45,7 +53,7 @@ type LoadState = 'idle' | 'loading' | 'ready' | 'error'
|
|
|
45
53
|
* Fetches the live header/footer payload on mount and renders the
|
|
46
54
|
* `megamenu` slice for the current locale. Mirrors `pkt-header-menu`
|
|
47
55
|
* (Punkt Elements) — both implementations share types and data
|
|
48
|
-
* helpers from `shared-utils/header-
|
|
56
|
+
* helpers from `shared-utils/header-footer`.
|
|
49
57
|
*
|
|
50
58
|
* This component is purely presentational with respect to focus/scroll-lock;
|
|
51
59
|
* the parent header is expected to control `open` and own focus
|
|
@@ -57,18 +65,16 @@ export const PktHeaderMenu = ({
|
|
|
57
65
|
locale = 'nb-NO',
|
|
58
66
|
open = false,
|
|
59
67
|
mobileBreakpoint = 768,
|
|
60
|
-
ariaLabelledBy,
|
|
61
68
|
className,
|
|
62
69
|
onDataLoaded,
|
|
63
70
|
onDataError,
|
|
64
71
|
ref,
|
|
65
72
|
...rest
|
|
66
73
|
}: IPktHeaderMenu) => {
|
|
67
|
-
const [
|
|
74
|
+
const [fetchState, setFetchState] = useState<LoadState>('loading')
|
|
68
75
|
const [fetchedData, setFetchedData] = useState<THeaderFooterApi | undefined>(undefined)
|
|
69
|
-
const
|
|
76
|
+
const loadState: LoadState = data ? 'ready' : fetchState
|
|
70
77
|
|
|
71
|
-
// Stable refs for the latest callbacks so effect deps stay narrow.
|
|
72
78
|
const onDataLoadedRef = useRef(onDataLoaded)
|
|
73
79
|
const onDataErrorRef = useRef(onDataError)
|
|
74
80
|
useEffect(() => {
|
|
@@ -78,41 +84,37 @@ export const PktHeaderMenu = ({
|
|
|
78
84
|
onDataErrorRef.current = onDataError
|
|
79
85
|
}, [onDataError])
|
|
80
86
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
const rootRef = useRef<HTMLElement | null>(null)
|
|
88
|
+
const setRootRef = useCallback(
|
|
89
|
+
(element: HTMLElement | null) => {
|
|
90
|
+
rootRef.current = element
|
|
91
|
+
if (typeof ref === 'function') ref(element)
|
|
92
|
+
else if (ref) (ref as MutableRefObject<HTMLElement | null>).current = element
|
|
93
|
+
},
|
|
94
|
+
[ref],
|
|
95
|
+
)
|
|
96
|
+
const menuWidth = useElementWidth(rootRef)
|
|
97
|
+
const isMobile = menuWidth < mobileBreakpoint
|
|
90
98
|
|
|
91
|
-
// When the supplied pre-fetched data, fire the loaded callback
|
|
92
|
-
// and short-circuit the network call.
|
|
93
99
|
useEffect(() => {
|
|
94
|
-
if (data)
|
|
95
|
-
setLoadState('ready')
|
|
96
|
-
onDataLoadedRef.current?.(data)
|
|
97
|
-
}
|
|
100
|
+
if (data) onDataLoadedRef.current?.(data)
|
|
98
101
|
}, [data])
|
|
99
102
|
|
|
100
|
-
// Fetch when no `data` prop is supplied
|
|
103
|
+
// Fetch when no `data` prop is supplied
|
|
101
104
|
useEffect(() => {
|
|
102
105
|
if (data) return
|
|
103
106
|
const controller = new AbortController()
|
|
104
|
-
setLoadState('loading')
|
|
105
107
|
|
|
106
108
|
fetchHeaderFooterData<string>(dataUrl, controller.signal)
|
|
107
109
|
.then((payload) => {
|
|
108
110
|
if (controller.signal.aborted) return
|
|
109
111
|
setFetchedData(payload)
|
|
110
|
-
|
|
112
|
+
setFetchState('ready')
|
|
111
113
|
onDataLoadedRef.current?.(payload)
|
|
112
114
|
})
|
|
113
115
|
.catch((error: Error) => {
|
|
114
116
|
if (error.name === 'AbortError') return
|
|
115
|
-
|
|
117
|
+
setFetchState('error')
|
|
116
118
|
onDataErrorRef.current?.(error)
|
|
117
119
|
})
|
|
118
120
|
|
|
@@ -126,21 +128,17 @@ export const PktHeaderMenu = ({
|
|
|
126
128
|
|
|
127
129
|
if (loadState === 'loading') {
|
|
128
130
|
return (
|
|
129
|
-
<
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
</nav>
|
|
133
|
-
</section>
|
|
131
|
+
<nav {...rest} ref={setRootRef} className={hostClasses} aria-busy="true">
|
|
132
|
+
<p className="pkt-header-menu__loading">Laster meny…</p>
|
|
133
|
+
</nav>
|
|
134
134
|
)
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
if (loadState === 'error' || !localeData) {
|
|
138
138
|
return (
|
|
139
|
-
<
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
</nav>
|
|
143
|
-
</section>
|
|
139
|
+
<nav {...rest} ref={setRootRef} className={hostClasses} aria-hidden={!open}>
|
|
140
|
+
<p className="pkt-header-menu__error">Kunne ikke laste meny.</p>
|
|
141
|
+
</nav>
|
|
144
142
|
)
|
|
145
143
|
}
|
|
146
144
|
|
|
@@ -148,28 +146,26 @@ export const PktHeaderMenu = ({
|
|
|
148
146
|
const navAriaLabel = i18n?.navAriaLabel || 'Hovedmeny'
|
|
149
147
|
|
|
150
148
|
return (
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
</nav>
|
|
165
|
-
</section>
|
|
149
|
+
<nav {...rest} ref={setRootRef} className={hostClasses} aria-label={navAriaLabel}>
|
|
150
|
+
{isMobile ? (
|
|
151
|
+
<MobileAccordion services={megamenu.services} sections={megamenu.sections} />
|
|
152
|
+
) : (
|
|
153
|
+
<>
|
|
154
|
+
<Services services={megamenu.services} />
|
|
155
|
+
<Buttons buttons={megamenu.buttons} mobile={false} />
|
|
156
|
+
<Sections sections={megamenu.sections} />
|
|
157
|
+
</>
|
|
158
|
+
)}
|
|
159
|
+
<Buttons buttons={megamenu.buttons} mobile={true} />
|
|
160
|
+
<Footer links={megamenu.links} some={megamenu.some} />
|
|
161
|
+
</nav>
|
|
166
162
|
)
|
|
167
163
|
}
|
|
168
164
|
|
|
169
165
|
const ServicesList = ({ services }: { services: IHeaderMenuServices }) => (
|
|
170
166
|
<ul className="pkt-header-menu__services-list">
|
|
171
|
-
{services.links.map((link
|
|
172
|
-
<li className="pkt-header-menu__service" key={
|
|
167
|
+
{services.links.map((link) => (
|
|
168
|
+
<li className="pkt-header-menu__service" key={link.url}>
|
|
173
169
|
<a className="pkt-header-menu__service-link" href={link.url}>
|
|
174
170
|
<PktIcon className="pkt-header-menu__service-icon" name={mapOdsIcon(link.icon)} aria-hidden="true" />
|
|
175
171
|
<span className="pkt-header-menu__service-text">{link.text}</span>
|
|
@@ -192,7 +188,7 @@ const MobileAccordion = ({ services, sections }: { services: IHeaderMenuServices
|
|
|
192
188
|
</PktAccordionItem>
|
|
193
189
|
{sections.map((section, index) => (
|
|
194
190
|
<PktAccordionItem
|
|
195
|
-
key={
|
|
191
|
+
key={section.title}
|
|
196
192
|
className="pkt-header-menu__section"
|
|
197
193
|
skin="plus-minus"
|
|
198
194
|
id={`pkt-header-menu-section-${index}`}
|
|
@@ -217,12 +213,8 @@ const Buttons = ({ buttons, mobile }: { buttons: IHeaderMenuButton[] | undefined
|
|
|
217
213
|
const classes = ['pkt-header-menu__buttons', mobile && 'pkt-header-menu__buttons--mobile'].filter(Boolean).join(' ')
|
|
218
214
|
return (
|
|
219
215
|
<div className={classes}>
|
|
220
|
-
{buttons.map((button
|
|
221
|
-
<a
|
|
222
|
-
key={`button-${index}`}
|
|
223
|
-
className="pkt-btn pkt-btn--secondary pkt-btn--icon-right pkt-btn--small"
|
|
224
|
-
href={button.url}
|
|
225
|
-
>
|
|
216
|
+
{buttons.map((button) => (
|
|
217
|
+
<a key={button.url} className="pkt-btn pkt-btn--secondary pkt-btn--icon-right pkt-btn--small" href={button.url}>
|
|
226
218
|
<PktIcon
|
|
227
219
|
className="pkt-btn__icon"
|
|
228
220
|
name={button.iconName ? mapOdsIcon(button.iconName) : 'user'}
|
|
@@ -241,8 +233,8 @@ const Sections = ({ sections }: { sections: IHeaderMenuSection[] }) => {
|
|
|
241
233
|
return (
|
|
242
234
|
<div className="pkt-header-menu__sections">
|
|
243
235
|
<div className="pkt-header-menu__sections-inner">
|
|
244
|
-
{sections.map((section
|
|
245
|
-
<div className="pkt-header-menu__section" key={
|
|
236
|
+
{sections.map((section) => (
|
|
237
|
+
<div className="pkt-header-menu__section" key={section.title}>
|
|
246
238
|
<h2 className="pkt-header-menu__section-title">{section.title}</h2>
|
|
247
239
|
<SectionList links={section.links} />
|
|
248
240
|
</div>
|
|
@@ -254,8 +246,8 @@ const Sections = ({ sections }: { sections: IHeaderMenuSection[] }) => {
|
|
|
254
246
|
|
|
255
247
|
const SectionList = ({ links }: { links: IHeaderMenuLink[] }) => (
|
|
256
248
|
<ul className="pkt-header-menu__section-list">
|
|
257
|
-
{links.map((link
|
|
258
|
-
<li key={
|
|
249
|
+
{links.map((link) => (
|
|
250
|
+
<li key={link.url}>
|
|
259
251
|
<a className="pkt-header-menu__section-link" href={link.url}>
|
|
260
252
|
{link.text}
|
|
261
253
|
</a>
|
|
@@ -273,8 +265,8 @@ const Footer = ({ links, some }: { links: IHeaderMenuLink[]; some: IHeaderMenuLi
|
|
|
273
265
|
<div className="pkt-header-menu__footer">
|
|
274
266
|
{hasLinks && (
|
|
275
267
|
<ul className="pkt-header-menu__footer-list">
|
|
276
|
-
{links.map((link
|
|
277
|
-
<li key={
|
|
268
|
+
{links.map((link) => (
|
|
269
|
+
<li key={link.url}>
|
|
278
270
|
<a className="pkt-header-menu__footer-link" href={link.url}>
|
|
279
271
|
{link.text}
|
|
280
272
|
</a>
|
|
@@ -284,8 +276,8 @@ const Footer = ({ links, some }: { links: IHeaderMenuLink[]; some: IHeaderMenuLi
|
|
|
284
276
|
)}
|
|
285
277
|
{hasSome && (
|
|
286
278
|
<ul className="pkt-header-menu__footer-list pkt-header-menu__footer-list--social">
|
|
287
|
-
{some.map((entry
|
|
288
|
-
<SocialLink key={
|
|
279
|
+
{some.map((entry) => (
|
|
280
|
+
<SocialLink key={entry.url} entry={entry} />
|
|
289
281
|
))}
|
|
290
282
|
</ul>
|
|
291
283
|
)}
|
package/src/components/index.ts
CHANGED
|
@@ -13,8 +13,11 @@ export { PktDatepicker } from './datepicker/Datepicker'
|
|
|
13
13
|
export { PktFileUpload } from './fileupload/FileUpload'
|
|
14
14
|
export { ItemRenderers } from './fileupload/QueueDisplay'
|
|
15
15
|
export { PktFooter } from './footer/Footer'
|
|
16
|
+
export type { IPktFooter } from './footer/Footer'
|
|
16
17
|
export { PktFooterSimple } from './footerSimple/FooterSimple'
|
|
18
|
+
export type { IPktFooterSimple } from './footerSimple/FooterSimple'
|
|
17
19
|
export { PktHeader } from './header/Header'
|
|
20
|
+
export { PktHeaderGlobal } from './header/HeaderGlobal'
|
|
18
21
|
export { PktHeaderMenu } from './header-menu/HeaderMenu'
|
|
19
22
|
export { PktHeaderService } from './header/HeaderService'
|
|
20
23
|
export { PktHeading } from './heading/Heading'
|
|
@@ -32,6 +35,7 @@ export { PktRadioButton } from './radio/RadioButton'
|
|
|
32
35
|
export { PktSearchInput } from './searchinput/SearchInput'
|
|
33
36
|
export type { PktSearchInputSuggestion } from './searchinput/SearchInput'
|
|
34
37
|
export { PktSelect } from './select/Select'
|
|
38
|
+
export type { IPktSelectOption, TSelectOption } from './select/Select'
|
|
35
39
|
export { PktStep } from './stepper/Step'
|
|
36
40
|
export { PktStepper } from './stepper/Stepper'
|
|
37
41
|
export { PktTable } from './table/Table'
|