@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
@@ -1,35 +1,69 @@
1
1
  'use client'
2
2
 
3
- import { FC, type HTMLAttributes } from 'react'
3
+ import { FC, useEffect, useMemo, type HTMLAttributes } from 'react'
4
4
 
5
+ import {
6
+ DEFAULT_FOOTER_PERSONVERN_URL,
7
+ DEFAULT_FOOTER_TILGJENGELIGHET_URL,
8
+ type IPktFooterLink,
9
+ type IPktFooterSimpleProps,
10
+ } from 'shared-types'
11
+ import { getLinkDomainLabel } from 'shared-utils/header-footer'
5
12
  import { PktConsent } from '../consent/Consent'
6
13
  import { PktIcon } from '../icon/Icon'
14
+ import { FooterGlobal, useGlobalFooterData, type TFooterConsentConfig } from '../footer/FooterGlobal'
7
15
 
8
- export interface IPktFooterSimple extends HTMLAttributes<HTMLDivElement> {
9
- links?: Array<{
10
- href: string
11
- text: string
12
- external?: boolean
13
- openInNewTab?: boolean
14
- }>
16
+ export interface IPktFooterSimple
17
+ extends IPktFooterSimpleProps<string>, TFooterConsentConfig, Omit<HTMLAttributes<HTMLElement>, 'children'> {
18
+ onDataError?: (error: Error) => void
19
+ /**
20
+ * @deprecated The privacy link now comes from the global footer's payload.
21
+ * Only rendered (in the secondary footer) when explicitly set to a
22
+ * non-default value.
23
+ */
15
24
  personvernOgInfoLink?: string
25
+ /**
26
+ * @deprecated The accessibility link now comes from the global footer's
27
+ * payload. Only rendered (in the secondary footer) when explicitly set
28
+ * to a non-default value.
29
+ */
16
30
  tilgjengelighetLink?: string
17
- openLinksInNewTab?: boolean
18
- includeConsent?: boolean
19
- hotjarId?: string | null
20
- googleAnalyticsId?: string | null
21
- devMode?: boolean
22
- cookieDomain?: string | null
23
- cookieSecure?: string | null
24
- cookieExpiryDays?: string | null
25
- onToggleConsent?: (e: CustomEvent) => void
26
31
  }
27
32
 
33
+ const SimpleLink: FC<{ link: IPktFooterLink; openLinksInNewTab: boolean }> = ({ link, openLinksInNewTab }) => {
34
+ const newTab = link.openInNewTab || openLinksInNewTab
35
+ // external = vis toppdomenet i parentes bak lenketeksten
36
+ const domain = link.external ? getLinkDomainLabel(link.href) : undefined
37
+ return (
38
+ <a
39
+ className="pkt-footer-simple__link"
40
+ href={link.href}
41
+ target={newTab ? '_blank' : '_self'}
42
+ rel={newTab ? 'noopener noreferrer' : undefined}
43
+ >
44
+ <PktIcon className="pkt-footer-simple__link-icon" name="arrow-link" />
45
+ <span>
46
+ {link.text}
47
+ {domain && <span className="pkt-footer-simple__link-domain"> ({domain})</span>}
48
+ </span>
49
+ </a>
50
+ )
51
+ }
52
+
53
+ /**
54
+ * PktFooterSimple — kompakt footer: en sentrert rad med pil-lenker i det
55
+ * grå sekundær-båndet, stablet oppå den globale (blå) footeren. Uten
56
+ * `links` rendres kun den globale footeren.
57
+ */
28
58
  export const PktFooterSimple: FC<IPktFooterSimple> = ({
29
- links = [],
59
+ links,
60
+ personvernOgInfoLink,
61
+ tilgjengelighetLink,
62
+ skipGlobal = false,
63
+ data,
64
+ dataUrl,
65
+ locale = 'nb-NO',
30
66
  openLinksInNewTab = false,
31
- personvernOgInfoLink = 'https://www.oslo.kommune.no/personvern-og-informasjonskapsler/',
32
- tilgjengelighetLink = 'https://www.oslo.kommune.no/tilgjengelighet/',
33
67
  includeConsent = false,
34
68
  hotjarId = null,
35
69
  googleAnalyticsId = null,
@@ -37,68 +71,73 @@ export const PktFooterSimple: FC<IPktFooterSimple> = ({
37
71
  cookieDomain = null,
38
72
  cookieSecure = null,
39
73
  cookieExpiryDays = null,
40
- onToggleConsent = (e: CustomEvent) => {
41
- // eslint-disable-next-line no-console
42
- console.log(e.detail)
43
- },
74
+ onToggleConsent,
75
+ onDataError,
44
76
  className,
77
+ ...rest
45
78
  }) => {
79
+ useEffect(() => {
80
+ if (personvernOgInfoLink || tilgjengelighetLink) {
81
+ // eslint-disable-next-line no-console
82
+ console.warn(
83
+ 'PktFooterSimple: The "personvernOgInfoLink"/"tilgjengelighetLink" props are deprecated — the links now come from the global footer. Overridden values are rendered in the secondary footer.',
84
+ )
85
+ }
86
+ }, [personvernOgInfoLink, tilgjengelighetLink])
87
+
88
+ const globalFooter = useGlobalFooterData({ data, dataUrl, locale, skipGlobal, onDataError })
89
+
90
+ // Deprecated lenker rendres kun når de er eksplisitt overstyrt til noe
91
+ // annet enn default — ellers dekkes de av den globale footeren.
92
+ const effectiveLinks = useMemo(() => {
93
+ const legacyLinks: IPktFooterLink[] = []
94
+ if (personvernOgInfoLink && personvernOgInfoLink !== DEFAULT_FOOTER_PERSONVERN_URL) {
95
+ legacyLinks.push({ text: 'Personvern og informasjonskapsler', href: personvernOgInfoLink })
96
+ }
97
+ if (tilgjengelighetLink && tilgjengelighetLink !== DEFAULT_FOOTER_TILGJENGELIGHET_URL) {
98
+ legacyLinks.push({ text: 'Tilgjengelighet', href: tilgjengelighetLink })
99
+ }
100
+ return [...(links ?? []), ...legacyLinks]
101
+ }, [links, personvernOgInfoLink, tilgjengelighetLink])
102
+
103
+ const consent: TFooterConsentConfig = {
104
+ hotjarId,
105
+ googleAnalyticsId,
106
+ devMode,
107
+ cookieDomain,
108
+ cookieSecure,
109
+ cookieExpiryDays,
110
+ onToggleConsent,
111
+ }
112
+
113
+ // Med skipGlobal rendres consent-lenken i sekundær-båndet i stedet for i
114
+ // den globale footeren.
115
+ const consentInSecondary = skipGlobal && includeConsent
116
+ const hasSecondary = effectiveLinks.length > 0 || consentInSecondary
117
+
46
118
  const classNames = [className, 'pkt-footer-simple'].filter(Boolean).join(' ')
119
+
47
120
  return (
48
- <footer className={classNames} data-mode="dark">
49
- <div className="pkt-footer-simple__container">
50
- <ul className="pkt-footer-simple__list">
51
- {links.map((link, index) => (
52
- <li className="pkt-footer-simple__list-item" key={index}>
53
- <a
54
- className={`pkt-footer-simple__link ${link.external ? ' pkt-link--external' : ''}`}
55
- href={link.href}
56
- target={link.openInNewTab || openLinksInNewTab ? '_blank' : '_self'}
57
- rel={link.openInNewTab || openLinksInNewTab ? 'noopener noreferrer' : undefined}
58
- >
59
- <PktIcon className="pkt-footer-simple__link-icon" name="chevron-right" />
60
- {link.text}
61
- </a>
62
- </li>
63
- ))}
64
- <li className="pkt-footer-simple__list-item">
65
- <a
66
- className="pkt-footer-simple__link"
67
- href={personvernOgInfoLink}
68
- target={openLinksInNewTab ? '_blank' : '_self'}
69
- rel={openLinksInNewTab ? 'noopener noreferrer' : undefined}
70
- >
71
- <PktIcon className="pkt-footer-simple__link-icon" name="chevron-right"></PktIcon>
72
- Personvern og informasjonskapsler
73
- </a>
74
- </li>
75
- <li className="pkt-footer-simple__list-item">
76
- <a
77
- className="pkt-footer-simple__link"
78
- href={tilgjengelighetLink}
79
- target={openLinksInNewTab ? '_blank' : '_self'}
80
- rel={openLinksInNewTab ? 'noopener noreferrer' : undefined}
81
- >
82
- <PktIcon className="pkt-footer-simple__link-icon" name="chevron-right"></PktIcon>
83
- Tilgjengelighet
84
- </a>
85
- </li>
86
- {includeConsent && (
87
- <li className="pkt-footer-simple__list-item">
88
- <PktConsent
89
- triggerType="footerlink"
90
- hotjarId={hotjarId}
91
- googleAnalyticsId={googleAnalyticsId}
92
- devMode={devMode}
93
- cookieDomain={cookieDomain}
94
- cookieSecure={cookieSecure}
95
- cookieExpiryDays={cookieExpiryDays}
96
- onToggleConsent={onToggleConsent}
97
- />
98
- </li>
99
- )}
100
- </ul>
101
- </div>
121
+ <footer className={classNames} {...rest}>
122
+ {hasSecondary && (
123
+ <div className="pkt-footer__band pkt-footer__band--secondary">
124
+ <div className="pkt-footer__inner pkt-footer__inner--compact">
125
+ <ul className="pkt-footer-simple__list">
126
+ {effectiveLinks.map((link) => (
127
+ <li key={`${link.href}|${link.text}`}>
128
+ <SimpleLink link={link} openLinksInNewTab={openLinksInNewTab} />
129
+ </li>
130
+ ))}
131
+ {consentInSecondary && (
132
+ <li>
133
+ <PktConsent triggerType="footerlink" {...consent} />
134
+ </li>
135
+ )}
136
+ </ul>
137
+ </div>
138
+ </div>
139
+ )}
140
+ {globalFooter && <FooterGlobal footer={globalFooter} includeConsent={includeConsent} consent={consent} />}
102
141
  </footer>
103
142
  )
104
143
  }
@@ -2,11 +2,20 @@
2
2
 
3
3
  import { ForwardedRef, forwardRef } from 'react'
4
4
 
5
+ import { PktHeaderGlobal } from './HeaderGlobal'
5
6
  import { PktHeaderService } from './HeaderService'
6
- import { IPktHeader } from './types'
7
+ import { IPktHeader, IPktHeaderGlobal } from './types'
7
8
 
8
9
  export * from './types'
9
10
 
11
+ /**
12
+ * Props for `<PktHeader>`. The `type` prop selects the variant:
13
+ * the default `service` header (a single service's header) or the
14
+ * `global` (kommune-wide) header driven by the shared header/footer
15
+ * payload.
16
+ */
17
+ export type IPktHeaderProps = (IPktHeader & { type?: 'service' }) | (IPktHeaderGlobal & { type: 'global' })
18
+
10
19
  /**
11
20
  * PktHeader - Main header component for Oslo kommune services
12
21
  *
@@ -17,10 +26,14 @@ export * from './types'
17
26
  * - Responsive mobile menu
18
27
  * - Fixed positioning with scroll-to-hide
19
28
  *
20
- * TODO: Add `type` prop to switch between `service` and `global` header types
29
+ * The `type` prop switches between the `service` header (default) and
30
+ * the `global` (kommune-wide) header.
21
31
  */
22
- export const PktHeader = forwardRef((props: IPktHeader, ref: ForwardedRef<HTMLDivElement>) => {
23
- return <PktHeaderService {...props} ref={ref} />
32
+ export const PktHeader = forwardRef((props: IPktHeaderProps, ref: ForwardedRef<HTMLElement>) => {
33
+ if (props.type === 'global') {
34
+ return <PktHeaderGlobal {...props} ref={ref as ForwardedRef<HTMLDivElement>} />
35
+ }
36
+ return <PktHeaderService {...props} ref={ref as ForwardedRef<HTMLDivElement>} />
24
37
  })
25
38
 
26
39
  PktHeader.displayName = 'PktHeader'
@@ -0,0 +1,86 @@
1
+ import '@testing-library/jest-dom'
2
+
3
+ import { fireEvent, render } from '@testing-library/react'
4
+ import { axe, toHaveNoViolations } from 'jest-axe'
5
+ import { EXAMPLE_HEADER_FOOTER_DATA } from 'shared-utils/header-footer'
6
+ import { afterEach, describe, expect, test, vi } from 'vitest'
7
+
8
+ import { PktHeaderGlobal } from './HeaderGlobal'
9
+
10
+ expect.extend(toHaveNoViolations)
11
+
12
+ // jsdom reports offsetWidth as 0 (compact). Zero the breakpoints to force the
13
+ // full desktop layout; omit to exercise the compact/mobile layout.
14
+ const DESKTOP = { mobileBreakpoint: 0, tabletBreakpoint: 0 }
15
+
16
+ const renderGlobal = (props = {}) =>
17
+ render(<PktHeaderGlobal data={EXAMPLE_HEADER_FOOTER_DATA} position="relative" {...props} />)
18
+
19
+ afterEach(() => {
20
+ vi.unstubAllGlobals()
21
+ })
22
+
23
+ describe('PktHeaderGlobal – tilgjengelighet', () => {
24
+ test('ingen brudd i standardvisning (desktop)', async () => {
25
+ const { container } = renderGlobal(DESKTOP)
26
+ expect(await axe(container)).toHaveNoViolations()
27
+ })
28
+
29
+ test('ingen brudd i kompakt visning (mobil)', async () => {
30
+ const { container } = renderGlobal()
31
+ expect(await axe(container)).toHaveNoViolations()
32
+ })
33
+
34
+ test('ingen brudd med innlogget bruker', async () => {
35
+ const { container } = renderGlobal({
36
+ ...DESKTOP,
37
+ user: { name: 'Aksel Web Opdahl' },
38
+ logOutButtonPlacement: 'userMenu',
39
+ logOut: vi.fn(),
40
+ })
41
+ expect(await axe(container)).toHaveNoViolations()
42
+ })
43
+
44
+ test('ingen brudd med åpen megameny', async () => {
45
+ const { container } = renderGlobal(DESKTOP)
46
+ fireEvent.click(container.querySelector('#pkt-header-global-menu-toggle') as Element)
47
+ expect(await axe(container)).toHaveNoViolations()
48
+ })
49
+
50
+ test('ingen brudd med åpent søke-overlay', async () => {
51
+ const { container } = renderGlobal()
52
+ fireEvent.click(container.querySelector('#pkt-header-global-search-toggle') as Element)
53
+ expect(await axe(container)).toHaveNoViolations()
54
+ })
55
+
56
+ test('ingen brudd med åpen brukermeny', async () => {
57
+ const { container } = renderGlobal({
58
+ ...DESKTOP,
59
+ user: { name: 'Aksel Web Opdahl' },
60
+ logOutButtonPlacement: 'userMenu',
61
+ logOut: vi.fn(),
62
+ })
63
+ fireEvent.click(container.querySelector('.pkt-header-global__user-button') as Element)
64
+ expect(await axe(container)).toHaveNoViolations()
65
+ })
66
+
67
+ test('ingen brudd når menydata feiler', async () => {
68
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
69
+ const { container, findByRole } = render(<PktHeaderGlobal position="relative" {...DESKTOP} />)
70
+ const menuButton = await findByRole('button', { name: /Meny/ })
71
+ fireEvent.click(menuButton)
72
+ expect(await axe(container)).toHaveNoViolations()
73
+ })
74
+
75
+ test('menyknappen har aria-expanded og aria-controls', () => {
76
+ const { container } = renderGlobal(DESKTOP)
77
+ const toggle = container.querySelector('#pkt-header-global-menu-toggle') as HTMLElement
78
+ expect(toggle).toHaveAttribute('aria-controls', 'pkt-header-global-menu')
79
+ expect(toggle).toHaveAttribute('aria-expanded', 'false')
80
+
81
+ fireEvent.click(toggle)
82
+ expect(
83
+ container.querySelector('#pkt-header-global-menu-toggle'),
84
+ ).toHaveAttribute('aria-expanded', 'true')
85
+ })
86
+ })
@@ -0,0 +1,254 @@
1
+ import '@testing-library/jest-dom'
2
+
3
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { EXAMPLE_HEADER_FOOTER_DATA } from 'shared-utils/header-footer'
5
+ import { afterEach, describe, expect, test, vi } from 'vitest'
6
+
7
+ import { PktHeaderGlobal } from './HeaderGlobal'
8
+
9
+ const renderGlobal = (props = {}) =>
10
+ render(<PktHeaderGlobal data={EXAMPLE_HEADER_FOOTER_DATA} position="relative" {...props} />)
11
+
12
+ // jsdom reports offsetWidth as 0, which puts the header in compact mode.
13
+ // Force the desktop (full) layout by zeroing the breakpoints.
14
+ const DESKTOP = { mobileBreakpoint: 0, tabletBreakpoint: 0 }
15
+
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals()
18
+ })
19
+
20
+ describe('PktHeaderGlobal', () => {
21
+ test('viser søkefelt, kontaktlenke og menyknapp (desktop)', () => {
22
+ const { container } = renderGlobal(DESKTOP)
23
+
24
+ expect(container.querySelector('input[name="q"]')).toBeInTheDocument()
25
+
26
+ const contact = screen.getByRole('link', { name: 'Kontakt' })
27
+ expect(contact).toHaveAttribute('href', 'https://www.oslo.kommune.no/kontakt')
28
+
29
+ expect(screen.getByRole('button', { name: /Meny/ })).toBeInTheDocument()
30
+ })
31
+
32
+ test('søkefeltet er en GET-form mot søke-endepunktet (desktop)', () => {
33
+ const { container } = renderGlobal(DESKTOP)
34
+ const form = container.querySelector('form[role="search"]')
35
+ expect(form).toHaveAttribute('action', 'https://www.oslo.kommune.no/sokeresultat/')
36
+ expect(form).toHaveAttribute('method', 'get')
37
+ })
38
+
39
+ test('mobil: søkeknappen åpner et søkefelt-overlay', () => {
40
+ const { container } = renderGlobal()
41
+ // Compact layout: inline search is replaced by a round toggle.
42
+ expect(container.querySelector('.pkt-header-global__search-panel')).not.toBeInTheDocument()
43
+ const searchToggle = container.querySelector('#pkt-header-global-search-toggle')
44
+ expect(searchToggle).toBeInTheDocument()
45
+
46
+ fireEvent.click(searchToggle as Element)
47
+
48
+ const panel = container.querySelector('.pkt-header-global__search-panel')
49
+ expect(panel).toBeInTheDocument()
50
+ expect(panel?.querySelector('input[name="q"]')).toBeInTheDocument()
51
+ })
52
+
53
+ test('mobil: åpning av søk gir fokus til søkefeltet', () => {
54
+ const { container } = renderGlobal()
55
+ fireEvent.click(container.querySelector('#pkt-header-global-search-toggle') as Element)
56
+
57
+ const input = container.querySelector('#pkt-header-global-search-panel input') as HTMLInputElement
58
+ expect(input).toBeTruthy()
59
+ expect(document.activeElement).toBe(input)
60
+ })
61
+
62
+ test('mobil: Tab ut av søke-overlay lukker det og flytter fokus til søkeknappen', () => {
63
+ const { container } = renderGlobal()
64
+ const searchToggle = container.querySelector('#pkt-header-global-search-toggle') as HTMLElement
65
+ fireEvent.click(searchToggle)
66
+
67
+ const panel = container.querySelector('#pkt-header-global-search-panel') as HTMLElement
68
+ const focusables = panel.querySelectorAll<HTMLElement>('input, button')
69
+ const last = focusables[focusables.length - 1]
70
+ act(() => last.focus())
71
+
72
+ fireEvent.keyDown(document, { key: 'Tab' })
73
+
74
+ expect(container.querySelector('.pkt-header-global__search-panel')).not.toBeInTheDocument()
75
+ expect(searchToggle.contains(document.activeElement) || document.activeElement === searchToggle).toBe(true)
76
+ })
77
+
78
+ test('mobil: fokus utenfor søke-overlay lukker det', () => {
79
+ const { container } = renderGlobal()
80
+ fireEvent.click(container.querySelector('#pkt-header-global-search-toggle') as Element)
81
+ expect(container.querySelector('.pkt-header-global__search-panel')).toBeInTheDocument()
82
+
83
+ const menuToggle = container.querySelector('#pkt-header-global-menu-toggle') as HTMLElement
84
+ act(() => menuToggle.focus())
85
+ fireEvent.focusIn(menuToggle)
86
+
87
+ expect(container.querySelector('.pkt-header-global__search-panel')).not.toBeInTheDocument()
88
+ })
89
+
90
+ test('mobil: åpning av søk lukker megamenyen', () => {
91
+ const { container } = renderGlobal()
92
+ fireEvent.click(screen.getByRole('button', { name: /Meny/ }))
93
+ expect(screen.getByText('Tjenester og tilbud')).toBeInTheDocument()
94
+
95
+ fireEvent.click(container.querySelector('#pkt-header-global-search-toggle') as Element)
96
+ expect(screen.queryByText('Tjenester og tilbud')).not.toBeInTheDocument()
97
+ expect(container.querySelector('.pkt-header-global__search-panel')).toBeInTheDocument()
98
+ })
99
+
100
+ test('menyknappen åpner megamenyen', () => {
101
+ renderGlobal()
102
+ expect(screen.queryByText('Tjenester og tilbud')).not.toBeInTheDocument()
103
+
104
+ fireEvent.click(screen.getByRole('button', { name: /Meny/ }))
105
+
106
+ expect(screen.getByText('Tjenester og tilbud')).toBeInTheDocument()
107
+ })
108
+
109
+ test('tastatur: Tab fra menyknappen flytter fokus inn i megamenyen', () => {
110
+ const { container } = renderGlobal(DESKTOP)
111
+ const toggle = container.querySelector('#pkt-header-global-menu-toggle') as HTMLElement
112
+ fireEvent.click(toggle)
113
+ act(() => toggle.focus())
114
+ expect(document.activeElement).toBe(toggle)
115
+
116
+ fireEvent.keyDown(document, { key: 'Tab' })
117
+
118
+ const menu = container.querySelector('#pkt-header-global-menu') as HTMLElement
119
+ const firstFocusable = menu.querySelector('a[href], button') as HTMLElement
120
+ expect(document.activeElement).toBe(firstFocusable)
121
+ })
122
+
123
+ test('mobil: Tab fra menyknappen flytter fokus inn i megamenyen (accordion)', () => {
124
+ const original = window.matchMedia
125
+ // Force the mega menu into its mobile accordion layout.
126
+ window.matchMedia = vi.fn().mockReturnValue({
127
+ matches: true,
128
+ addEventListener: vi.fn(),
129
+ removeEventListener: vi.fn(),
130
+ addListener: vi.fn(),
131
+ removeListener: vi.fn(),
132
+ }) as unknown as typeof window.matchMedia
133
+ try {
134
+ const { container } = renderGlobal()
135
+ const toggle = container.querySelector('#pkt-header-global-menu-toggle') as HTMLElement
136
+ fireEvent.click(toggle)
137
+ act(() => toggle.focus())
138
+
139
+ fireEvent.keyDown(document, { key: 'Tab' })
140
+
141
+ const menu = container.querySelector('#pkt-header-global-menu') as HTMLElement
142
+ const firstSummary = menu.querySelector('summary') as HTMLElement
143
+ expect(firstSummary).toBeTruthy()
144
+ expect(document.activeElement).toBe(firstSummary)
145
+ } finally {
146
+ window.matchMedia = original
147
+ }
148
+ })
149
+
150
+ test('tastatur: Tab ut av megamenyen lukker den og flytter fokus til menyknappen', () => {
151
+ const { container } = renderGlobal(DESKTOP)
152
+ const toggle = container.querySelector('#pkt-header-global-menu-toggle') as HTMLElement
153
+ fireEvent.click(toggle)
154
+
155
+ const menu = container.querySelector('#pkt-header-global-menu') as HTMLElement
156
+ const focusables = menu.querySelectorAll<HTMLElement>('a[href], button')
157
+ const last = focusables[focusables.length - 1]
158
+ act(() => last.focus())
159
+
160
+ fireEvent.keyDown(document, { key: 'Tab' })
161
+
162
+ expect(container.querySelector('#pkt-header-global-menu')).not.toBeInTheDocument()
163
+ expect(toggle.contains(document.activeElement) || document.activeElement === toggle).toBe(true)
164
+ })
165
+
166
+ test('tastatur: fokus utenfor megamenyen lukker den', () => {
167
+ const { container } = renderGlobal(DESKTOP)
168
+ fireEvent.click(container.querySelector('#pkt-header-global-menu-toggle') as Element)
169
+ expect(screen.getByText('Tjenester og tilbud')).toBeInTheDocument()
170
+
171
+ const contact = container.querySelector('.pkt-header-global__contact') as HTMLElement
172
+ act(() => contact.focus())
173
+ fireEvent.focusIn(contact)
174
+
175
+ expect(screen.queryByText('Tjenester og tilbud')).not.toBeInTheDocument()
176
+ })
177
+
178
+ test('viser feilmelding i menyen når data ikke kan lastes', async () => {
179
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
180
+ render(<PktHeaderGlobal position="relative" {...DESKTOP} />)
181
+
182
+ const menuButton = await screen.findByRole('button', { name: /Meny/ })
183
+ await waitFor(() => expect(menuButton).not.toBeDisabled())
184
+
185
+ fireEvent.click(menuButton)
186
+ expect(screen.getByText('Kunne ikke laste meny.')).toBeInTheDocument()
187
+ })
188
+
189
+ test('brukerknappen åpner brukermenyen', () => {
190
+ renderGlobal({ user: { name: 'Aksel Web Opdahl' }, logOutButtonPlacement: 'userMenu', logOut: vi.fn() })
191
+
192
+ const userButton = screen.getByRole('button', { name: /Aksel Web Opdahl/ })
193
+ fireEvent.click(userButton)
194
+
195
+ expect(screen.getByText('Pålogget som')).toBeInTheDocument()
196
+ })
197
+
198
+ test('kaller onDataLoaded når data er forhåndshentet', () => {
199
+ const onDataLoaded = vi.fn()
200
+ renderGlobal({ onDataLoaded })
201
+ expect(onDataLoaded).toHaveBeenCalledWith(EXAMPLE_HEADER_FOOTER_DATA)
202
+ })
203
+
204
+ test('logo lenker til www.oslo.kommune.no når logoLink er satt', () => {
205
+ renderGlobal({ logoLink: 'https://www.oslo.kommune.no' })
206
+ const logoLink = screen.getByRole('link', { name: 'Til hovedsiden' })
207
+ expect(logoLink).toHaveAttribute('href', 'https://www.oslo.kommune.no')
208
+ })
209
+
210
+ test('skjuler kontaktlenken når bruker er innlogget', () => {
211
+ const { container } = renderGlobal({ ...DESKTOP, user: { name: 'Aksel Web Opdahl' } })
212
+ expect(container.querySelector('.pkt-header-global__contact')).not.toBeInTheDocument()
213
+ expect(container.querySelector('.pkt-header-global__user-button')).toBeInTheDocument()
214
+ })
215
+
216
+ test('megameny-overlegget forankres til headerens faktiske posisjon', () => {
217
+ const { container } = renderGlobal({ ...DESKTOP, position: 'relative' })
218
+ const header = container.querySelector('.pkt-header-global') as HTMLElement
219
+ // Simulate a header sitting 32px down the page (relative/sticky position).
220
+ header.getBoundingClientRect = () =>
221
+ ({ bottom: 120, top: 32, left: 16, right: 1296, width: 1280, height: 88 }) as DOMRect
222
+
223
+ fireEvent.click(container.querySelector('#pkt-header-global-menu-toggle') as Element)
224
+
225
+ const overlay = container.querySelector('#pkt-header-global-menu') as HTMLElement
226
+ // The CSS adds the design offset (104/88px) on top of this anchor.
227
+ expect(overlay.style.getPropertyValue('--pkt-header-global-top')).toBe('32px')
228
+ expect(overlay.style.getPropertyValue('--pkt-header-global-left')).toBe('16px')
229
+ expect(overlay.style.getPropertyValue('--pkt-header-global-width')).toBe('1280px')
230
+ })
231
+
232
+ test('position="sticky" gir sticky-klasse uten wrapper/spacer', () => {
233
+ const { container } = renderGlobal({ ...DESKTOP, position: 'sticky' })
234
+ const header = container.querySelector('.pkt-header-global')
235
+ expect(header).toHaveClass('pkt-header-global--sticky')
236
+ expect(header).not.toHaveClass('pkt-header-global--fixed')
237
+ expect(container.querySelector('.pkt-header-global-wrapper')).toBeNull()
238
+ expect(container.querySelector('.pkt-header-global-spacer')).toBeNull()
239
+ })
240
+
241
+ test('logoen lenker til www.oslo.kommune.no som standard', () => {
242
+ const { container } = renderGlobal(DESKTOP)
243
+ const logo = container.querySelector('.pkt-header-global__logo a')
244
+ expect(logo).toHaveAttribute('href', 'https://www.oslo.kommune.no')
245
+ })
246
+
247
+ test('logoClick kalles ved klikk på logoen', () => {
248
+ // preventDefault: jsdom kan ikke navigere
249
+ const onClick = vi.fn((e: React.MouseEvent) => e.preventDefault())
250
+ const { container } = renderGlobal({ ...DESKTOP, logoClick: onClick })
251
+ fireEvent.click(container.querySelector('.pkt-header-global__logo a') as Element)
252
+ expect(onClick).toHaveBeenCalledTimes(1)
253
+ })
254
+ })