@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
@@ -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 { useElementWidth } from '../../hooks/useElementWidth'
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
- useScrollLock(isFixed && isMobile && openMenu !== 'none')
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
- shouldRestoreFocusRef.current = true
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
- lastFocusedElementRef.current = document.activeElement as HTMLElement
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={setRefs}
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 ? 'menu' : undefined}
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
- const { container } = render(<PktHeaderMenu data={LOCAL_DATA} open />)
68
- expect(container.querySelector('.pkt-header-menu__services-title')?.textContent).toContain(
69
- 'Tjenester og tilbud',
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
- <PktHeaderMenu data={LOCAL_DATA} locale="nb-NO" open />,
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 { type HTMLAttributes, type Ref, useEffect, useMemo, useRef, useState } from 'react'
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-menu'
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-menu`.
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 [loadState, setLoadState] = useState<LoadState>(data ? 'ready' : 'idle')
74
+ const [fetchState, setFetchState] = useState<LoadState>('loading')
68
75
  const [fetchedData, setFetchedData] = useState<THeaderFooterApi | undefined>(undefined)
69
- const [isMobile, setIsMobile] = useState(false)
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
- // Track viewport width via matchMedia.
82
- useEffect(() => {
83
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
84
- const query = window.matchMedia(`(max-width: ${mobileBreakpoint - 1}px)`)
85
- setIsMobile(query.matches)
86
- const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches)
87
- query.addEventListener('change', handler)
88
- return () => query.removeEventListener('change', handler)
89
- }, [mobileBreakpoint])
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
- setLoadState('ready')
112
+ setFetchState('ready')
111
113
  onDataLoadedRef.current?.(payload)
112
114
  })
113
115
  .catch((error: Error) => {
114
116
  if (error.name === 'AbortError') return
115
- setLoadState('error')
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
- <section {...rest} ref={ref} className={hostClasses}>
130
- <nav aria-busy="true">
131
- <p className="pkt-header-menu__loading">Laster meny…</p>
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
- <section {...rest} ref={ref} className={hostClasses}>
140
- <nav aria-hidden={!open}>
141
- <p className="pkt-header-menu__error">Kunne ikke laste meny.</p>
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
- <section {...rest} ref={ref} className={hostClasses} aria-labelledby={ariaLabelledBy || undefined}>
152
- <nav aria-label={navAriaLabel}>
153
- {isMobile ? (
154
- <MobileAccordion services={megamenu.services} sections={megamenu.sections} />
155
- ) : (
156
- <>
157
- <Services services={megamenu.services} />
158
- <Buttons buttons={megamenu.buttons} mobile={false} />
159
- <Sections sections={megamenu.sections} />
160
- </>
161
- )}
162
- <Buttons buttons={megamenu.buttons} mobile={true} />
163
- <Footer links={megamenu.links} some={megamenu.some} />
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, index) => (
172
- <li className="pkt-header-menu__service" key={`service-${index}`}>
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={`section-${index}`}
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, index) => (
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, index) => (
245
- <div className="pkt-header-menu__section" key={`section-${index}`}>
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, index) => (
258
- <li key={`section-link-${index}`}>
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, index) => (
277
- <li key={`footer-link-${index}`}>
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, index) => (
288
- <SocialLink key={`some-${index}`} entry={entry} />
279
+ {some.map((entry) => (
280
+ <SocialLink key={entry.url} entry={entry} />
289
281
  ))}
290
282
  </ul>
291
283
  )}
@@ -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'