@oslokommune/punkt-react 16.26.5 → 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 (98) hide show
  1. package/CHANGELOG.md +93 -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/footer/Footer.accessibility.test.d.ts +0 -0
  8. package/dist/components/footer/Footer.d.ts +12 -34
  9. package/dist/components/footer/Footer.test.d.ts +1 -0
  10. package/dist/components/footer/FooterGlobal.d.ts +23 -0
  11. package/dist/components/footerSimple/FooterSimple.d.ts +19 -16
  12. package/dist/components/footerSimple/FooterSimple.test.d.ts +1 -0
  13. package/dist/components/header/Header.d.ts +15 -3
  14. package/dist/components/header/HeaderGlobal.accessibility.test.d.ts +0 -0
  15. package/dist/components/header/HeaderGlobal.d.ts +2 -0
  16. package/dist/components/header/HeaderGlobal.test.d.ts +0 -0
  17. package/dist/components/header/HeaderService.accessibility.test.d.ts +0 -0
  18. package/dist/components/header/types.d.ts +30 -2
  19. package/dist/components/header-menu/HeaderMenu.d.ts +2 -4
  20. package/dist/components/index.d.ts +4 -0
  21. package/dist/components/inputwrapper/InputWrapper.d.ts +1 -0
  22. package/dist/components/searchinput/SearchInput.d.ts +1 -0
  23. package/dist/components/select/Select.d.ts +4 -7
  24. package/dist/components/textarea/Textarea.d.ts +2 -0
  25. package/dist/components/textinput/Textinput.d.ts +1 -0
  26. package/dist/components/timepicker/types.d.ts +3 -0
  27. package/dist/components/timepicker/useTimepickerState.d.ts +2 -0
  28. package/dist/punkt-react.cjs +1 -1
  29. package/dist/punkt-react.js +3188 -2729
  30. package/dist/shared-types/footer.d.ts +95 -0
  31. package/dist/shared-types/{header-menu.d.ts → header-footer.d.ts} +51 -4
  32. package/dist/shared-types/header.d.ts +1 -1
  33. package/dist/shared-types/index.d.ts +6 -4
  34. package/dist/shared-types/searchinput.d.ts +2 -0
  35. package/dist/shared-types/select.d.ts +3 -2
  36. package/dist/shared-types/size.d.ts +4 -0
  37. package/dist/shared-types/timepicker.d.ts +0 -6
  38. package/dist/shared-utils/datepicker-utils.d.ts +5 -4
  39. package/dist/shared-utils/fileupload/focus-trap.d.ts +4 -10
  40. package/dist/shared-utils/focus-trap/index.d.ts +22 -0
  41. package/dist/shared-utils/{header-menu → header-footer}/example-data.d.ts +5 -4
  42. package/dist/shared-utils/header-footer/external-domain.d.ts +26 -0
  43. package/dist/shared-utils/{header-menu → header-footer}/fetch-header-footer-data.d.ts +6 -2
  44. package/dist/shared-utils/{header-menu → header-footer}/index.d.ts +2 -1
  45. package/dist/shared-utils/{header-menu → header-footer}/select-megamenu.d.ts +1 -1
  46. package/dist/shared-utils/scroll-lock/index.d.ts +24 -0
  47. package/package.json +4 -4
  48. package/src/components/accordion/Accordion.test.tsx +51 -0
  49. package/src/components/accordion/Accordion.tsx +3 -3
  50. package/src/components/accordion/AccordionItem.tsx +7 -14
  51. package/src/components/alert/Alert.test.tsx +45 -9
  52. package/src/components/alert/Alert.tsx +13 -3
  53. package/src/components/button/Button.tsx +2 -2
  54. package/src/components/combobox/Combobox.core.test.tsx +14 -0
  55. package/src/components/combobox/Combobox.tsx +1 -0
  56. package/src/components/combobox/ComboboxInput.tsx +2 -0
  57. package/src/components/combobox/types.ts +2 -0
  58. package/src/components/combobox/useComboboxState.ts +2 -0
  59. package/src/components/consent/Consent.test.tsx +8 -14
  60. package/src/components/consent/Consent.tsx +9 -19
  61. package/src/components/datepicker/Datepicker.core.test.tsx +11 -0
  62. package/src/components/datepicker/Datepicker.tsx +4 -1
  63. package/src/components/datepicker/DatepickerInputs.tsx +16 -1
  64. package/src/components/datepicker/types.ts +3 -0
  65. package/src/components/datepicker/useDatepickerState.ts +17 -9
  66. package/src/components/footer/Footer.accessibility.test.tsx +46 -0
  67. package/src/components/footer/Footer.test.tsx +192 -86
  68. package/src/components/footer/Footer.tsx +169 -159
  69. package/src/components/footer/FooterGlobal.tsx +181 -0
  70. package/src/components/footerSimple/FooterSimple.test.tsx +49 -46
  71. package/src/components/footerSimple/FooterSimple.tsx +117 -78
  72. package/src/components/header/Header.tsx +17 -4
  73. package/src/components/header/HeaderGlobal.accessibility.test.tsx +86 -0
  74. package/src/components/header/HeaderGlobal.test.tsx +254 -0
  75. package/src/components/header/HeaderGlobal.tsx +467 -0
  76. package/src/components/header/HeaderService.accessibility.test.tsx +70 -0
  77. package/src/components/header/HeaderService.test.tsx +29 -16
  78. package/src/components/header/HeaderService.tsx +24 -84
  79. package/src/components/header/types.ts +35 -0
  80. package/src/components/header-menu/HeaderMenu.test.tsx +16 -39
  81. package/src/components/header-menu/HeaderMenu.tsx +62 -70
  82. package/src/components/index.ts +4 -0
  83. package/src/components/inputwrapper/InputWrapper.tsx +6 -1
  84. package/src/components/modal/Modal.tsx +7 -11
  85. package/src/components/searchinput/SearchInput.test.tsx +10 -0
  86. package/src/components/searchinput/SearchInput.tsx +19 -7
  87. package/src/components/select/Select.test.tsx +18 -0
  88. package/src/components/select/Select.tsx +8 -4
  89. package/src/components/textarea/Textarea.test.tsx +10 -0
  90. package/src/components/textarea/Textarea.tsx +5 -1
  91. package/src/components/textinput/Textinput.test.tsx +28 -0
  92. package/src/components/textinput/Textinput.tsx +5 -2
  93. package/src/components/timepicker/Timepicker.test.tsx +10 -0
  94. package/src/components/timepicker/Timepicker.tsx +8 -7
  95. package/src/components/timepicker/types.ts +8 -5
  96. package/src/components/timepicker/useTimepickerState.ts +4 -0
  97. package/dist/shared-utils/modal-scroll-lock.d.ts +0 -10
  98. /package/dist/shared-utils/{header-menu → header-footer}/icon-map.d.ts +0 -0
@@ -1,55 +1,71 @@
1
1
  'use client'
2
2
 
3
- import { FC, Fragment, 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 IPktFooterColumn,
9
+ type IPktFooterLink,
10
+ type IPktFooterProps,
11
+ } from 'shared-types'
12
+ import { getLinkDomainLabel } from 'shared-utils/header-footer'
5
13
  import { PktConsent } from '../consent/Consent'
6
14
  import { PktIcon } from '../icon/Icon'
15
+ import { FooterGlobal, useGlobalFooterData, type TFooterConsentConfig } from './FooterGlobal'
7
16
 
8
- interface Link {
9
- href: string
10
- text: string
11
- external?: boolean
12
- openInNewTab?: boolean
17
+ export interface IPktFooter
18
+ extends IPktFooterProps<string>, TFooterConsentConfig, Omit<HTMLAttributes<HTMLElement>, 'children'> {
19
+ /** Kalles når henting fra endepunktet feiler (den globale footeren skjules stille). */
20
+ onDataError?: (error: Error) => void
13
21
  }
14
22
 
15
- interface Column {
16
- title: string
17
- links?: Link[]
18
- text?: string
19
- }
20
-
21
- interface SocialLink {
22
- href: string
23
- iconName?: string
24
- language?: string
25
- openInNewTab?: boolean
23
+ const CustomLink: FC<{ link: IPktFooterLink; openLinksInNewTab: boolean }> = ({ link, openLinksInNewTab }) => {
24
+ const newTab = link.openInNewTab || openLinksInNewTab
25
+ // external = vis toppdomenet i parentes bak lenketeksten
26
+ const domain = link.external ? getLinkDomainLabel(link.href) : undefined
27
+ return (
28
+ <li>
29
+ <a href={link.href} target={newTab ? '_blank' : '_self'} rel={newTab ? 'noopener noreferrer' : undefined}>
30
+ {link.text}
31
+ {domain && <span className="pkt-footer__link-domain"> ({domain})</span>}
32
+ </a>
33
+ </li>
34
+ )
26
35
  }
27
36
 
28
- export interface IPktFooter extends HTMLAttributes<HTMLDivElement> {
29
- columnOne: Column
30
- columnTwo: Column
31
- socialLinks?: SocialLink[]
32
- personvernOgInfoLink?: string
33
- tilgjengelighetLink?: string
34
- openLinksInNewTab?: boolean
35
- includeConsent?: boolean
36
- hotjarId?: string | null
37
- googleAnalyticsId?: string | null
38
- devMode?: boolean
39
- cookieDomain?: string | null
40
- cookieSecure?: string | null
41
- cookieExpiryDays?: string | null
42
- onToggleConsent?: (e: CustomEvent) => void
43
- }
37
+ const Column: FC<{ column: IPktFooterColumn; openLinksInNewTab: boolean }> = ({ column, openLinksInNewTab }) => (
38
+ <section>
39
+ <h2>{column.title}</h2>
40
+ {column.text && <p>{column.text}</p>}
41
+ {!!column.links?.length && (
42
+ <ul className="pkt-footer__link-list">
43
+ {column.links.map((link) => (
44
+ <CustomLink key={`${link.href}|${link.text}`} link={link} openLinksInNewTab={openLinksInNewTab} />
45
+ ))}
46
+ </ul>
47
+ )}
48
+ </section>
49
+ )
44
50
 
51
+ /**
52
+ * PktFooter — footer med valgfritt custom innhold (grått sekundær-bånd)
53
+ * stablet oppå den globale (blå) footeren. Uten custom innhold rendres kun
54
+ * den globale. Den globale henter innhold fra Oslo kommunes felles
55
+ * header/footer-endepunkt (samme som PktHeader type="global").
56
+ */
45
57
  export const PktFooter: FC<IPktFooter> = ({
58
+ columns,
46
59
  columnOne,
47
60
  columnTwo,
61
+ personvernOgInfoLink,
62
+ tilgjengelighetLink,
48
63
  socialLinks,
49
- className,
64
+ skipGlobal = false,
65
+ data,
66
+ dataUrl,
67
+ locale = 'nb-NO',
50
68
  openLinksInNewTab = false,
51
- personvernOgInfoLink = 'https://www.oslo.kommune.no/personvern-og-informasjonskapsler/',
52
- tilgjengelighetLink = 'https://www.oslo.kommune.no/tilgjengelighet/',
53
69
  includeConsent = false,
54
70
  hotjarId = null,
55
71
  googleAnalyticsId = null,
@@ -57,135 +73,129 @@ export const PktFooter: FC<IPktFooter> = ({
57
73
  cookieDomain = null,
58
74
  cookieSecure = null,
59
75
  cookieExpiryDays = null,
60
- onToggleConsent = (e: CustomEvent) => {
61
- // eslint-disable-next-line no-console
62
- console.log(e.detail)
63
- },
76
+ onToggleConsent,
77
+ onDataError,
78
+ className,
79
+ ...rest
64
80
  }) => {
81
+ useEffect(() => {
82
+ if (columnOne || columnTwo) {
83
+ // eslint-disable-next-line no-console
84
+ console.warn(
85
+ 'PktFooter: The "columnOne"/"columnTwo" props are deprecated and will be removed in a future version. Please use "columns" instead.',
86
+ )
87
+ }
88
+ }, [columnOne, columnTwo])
89
+
90
+ useEffect(() => {
91
+ if (personvernOgInfoLink || tilgjengelighetLink) {
92
+ // eslint-disable-next-line no-console
93
+ console.warn(
94
+ 'PktFooter: The "personvernOgInfoLink"/"tilgjengelighetLink" props are deprecated — the links now come from the global footer. Overridden values are rendered in the secondary footer.',
95
+ )
96
+ }
97
+ }, [personvernOgInfoLink, tilgjengelighetLink])
98
+
99
+ useEffect(() => {
100
+ if (socialLinks) {
101
+ // eslint-disable-next-line no-console
102
+ console.warn(
103
+ 'PktFooter: The "socialLinks" prop is deprecated and will be removed in a future version — the social media links now come from the global footer.',
104
+ )
105
+ }
106
+ }, [socialLinks])
107
+
108
+ const globalFooter = useGlobalFooterData({ data, dataUrl, locale, skipGlobal, onDataError })
109
+
110
+ // Deprecated «Om nettstedet»-lenker rendres kun når de er eksplisitt
111
+ // overstyrt til noe annet enn default — ellers dekkes de av den globale
112
+ // footeren.
113
+ const effectiveColumns = useMemo(() => {
114
+ const base = columns?.length ? columns : ([columnOne, columnTwo].filter(Boolean) as IPktFooterColumn[])
115
+ const legacyLinks: IPktFooterLink[] = []
116
+ if (personvernOgInfoLink && personvernOgInfoLink !== DEFAULT_FOOTER_PERSONVERN_URL) {
117
+ legacyLinks.push({ text: 'Personvern og informasjonskapsler', href: personvernOgInfoLink })
118
+ }
119
+ if (tilgjengelighetLink && tilgjengelighetLink !== DEFAULT_FOOTER_TILGJENGELIGHET_URL) {
120
+ legacyLinks.push({ text: 'Tilgjengelighet', href: tilgjengelighetLink })
121
+ }
122
+ if (!legacyLinks.length) return base
123
+ return [...base, { title: 'Om nettstedet', links: legacyLinks }]
124
+ }, [columns, columnOne, columnTwo, personvernOgInfoLink, tilgjengelighetLink])
125
+
126
+ const consent: TFooterConsentConfig = {
127
+ hotjarId,
128
+ googleAnalyticsId,
129
+ devMode,
130
+ cookieDomain,
131
+ cookieSecure,
132
+ cookieExpiryDays,
133
+ onToggleConsent,
134
+ }
135
+
136
+ // Med skipGlobal rendres consent-lenken i sekundær-båndet i stedet for i
137
+ // den globale footeren.
138
+ const consentInSecondary = skipGlobal && includeConsent
139
+ const languageLinks = socialLinks?.filter((link) => link.language) ?? []
140
+ const iconLinks = socialLinks?.filter((link) => link.iconName) ?? []
141
+ const hasSecondary = effectiveColumns.length > 0 || consentInSecondary || !!socialLinks?.length
142
+
65
143
  const classNames = [className, 'pkt-footer'].filter(Boolean).join(' ')
66
144
 
67
145
  return (
68
- <footer className={classNames} data-mode="dark">
69
- <div className="pkt-footer__container">
70
- <div className="pkt-grid pkt-grid--rowgap-size-32 pkt-grid--gap-size-16">
71
- <div className="pkt-footer__column pkt-cell pkt-cell--span12 pkt-cell--span4-laptop-up">
72
- <h2 className="pkt-footer__title">{columnOne.title}</h2>
73
- <ul className="pkt-footer__list">
74
- {columnOne.text && <li className="pkt-footer__text">{columnOne.text}</li>}
75
- {columnOne.links?.map((link, index) => (
76
- <li className="pkt-footer__list-item" key={`links-${index}`}>
77
- <a
78
- className={`pkt-footer__link ${link.external ? 'pkt-link--external' : ''}`}
79
- href={link.href}
80
- target={link.openInNewTab || openLinksInNewTab ? '_blank' : '_self'}
81
- rel={link.openInNewTab || openLinksInNewTab ? 'noopener noreferrer' : undefined}
82
- >
83
- <PktIcon className="pkt-footer__link-icon" name="chevron-right" />
84
- {link.text}
85
- </a>
86
- </li>
87
- ))}
88
- </ul>
89
- </div>
90
- <div className="pkt-footer__column pkt-cell pkt-cell--span12 pkt-cell--span4-laptop-up">
91
- <h2 className="pkt-footer__title">{columnTwo.title}</h2>
92
- <ul className="pkt-footer__list">
93
- {columnTwo.text && <li className="pkt-footer__text">{columnTwo.text}</li>}
94
- {columnTwo.links?.map((link, index) => (
95
- <li className="pkt-footer__list-item" key={`links-${index}`}>
96
- <a
97
- className={`pkt-footer__link ${link.external ? 'pkt-link--external' : ''}`}
98
- href={link.href}
99
- target={link.openInNewTab || openLinksInNewTab ? '_blank' : '_self'}
100
- rel={link.openInNewTab || openLinksInNewTab ? 'noopener noreferrer' : undefined}
101
- >
102
- <PktIcon className="pkt-footer__link-icon" name="chevron-right" />
103
- {link.text}
104
- </a>
105
- </li>
106
- ))}
107
- </ul>
108
- </div>
109
- <div className="pkt-footer__column pkt-cell pkt-cell--span12 pkt-cell--span4-laptop-up">
110
- <h2 className="pkt-footer__title">Om nettstedet</h2>
111
- <ul className="pkt-footer__list">
112
- <li className="pkt-footer__list-item">
113
- <a
114
- className="pkt-footer__link"
115
- href={personvernOgInfoLink}
116
- target={openLinksInNewTab ? '_blank' : '_self'}
117
- rel={openLinksInNewTab ? 'noopener noreferrer' : undefined}
118
- >
119
- <PktIcon className="pkt-footer__link-icon" name="chevron-right" />
120
- Personvern og informasjonskapsler
121
- </a>
122
- </li>
123
- <li className="pkt-footer__list-item">
124
- <a
125
- className="pkt-footer__link"
126
- href={tilgjengelighetLink}
127
- target={openLinksInNewTab ? '_blank' : '_self'}
128
- rel={openLinksInNewTab ? 'noopener noreferrer' : undefined}
129
- >
130
- <PktIcon className="pkt-footer__link-icon" name="chevron-right" />
131
- Tilgjengelighet
132
- </a>
133
- </li>
134
- {includeConsent && (
135
- <li className="pkt-footer__list-item">
136
- <PktConsent
137
- triggerType="footerlink"
138
- hotjarId={hotjarId}
139
- googleAnalyticsId={googleAnalyticsId}
140
- devMode={devMode}
141
- cookieDomain={cookieDomain}
142
- cookieSecure={cookieSecure}
143
- cookieExpiryDays={cookieExpiryDays}
144
- onToggleConsent={onToggleConsent}
145
- />
146
- </li>
147
- )}
148
- </ul>
149
- </div>
150
- </div>
151
- {socialLinks && (
152
- <div className="pkt-footer__social" aria-label="standard lenker">
153
- <div className="pkt-footer__social-languages">
154
- {socialLinks
155
- .filter((link) => link.language)
156
- .map((link, index) => (
157
- <div key={`sociallinks-language-${index}`} className="pkt-footer__social-language">
158
- <a
159
- href={link.href}
160
- aria-label={`til ${link.language} versjon av nettsiden`}
161
- target={link.openInNewTab || openLinksInNewTab ? '_blank' : '_self'}
162
- rel={link.openInNewTab || openLinksInNewTab ? 'noopener noreferrer' : undefined}
163
- >
164
- {link.language}
165
- </a>
166
- </div>
146
+ <footer className={classNames} {...rest}>
147
+ {hasSecondary && (
148
+ <div className="pkt-footer__band pkt-footer__band--secondary">
149
+ <div className="pkt-footer__inner">
150
+ {effectiveColumns.length > 0 && (
151
+ <div className="pkt-footer__sections">
152
+ {effectiveColumns.map((column) => (
153
+ <Column key={column.title} column={column} openLinksInNewTab={openLinksInNewTab} />
167
154
  ))}
168
- </div>
169
- <div className="pkt-footer__social-icons">
170
- {socialLinks
171
- .filter((link) => link.iconName)
172
- .map((link, index) => (
173
- <Fragment key={`sociallinks-${index}`}>
174
- <a
175
- href={link.href}
176
- aria-label={`til ${link.iconName}`}
177
- className="pkt-footer__social-icon-link"
178
- target={link.openInNewTab || openLinksInNewTab ? '_blank' : '_self'}
179
- rel={link.openInNewTab || openLinksInNewTab ? 'noopener noreferrer' : undefined}
180
- >
181
- <PktIcon className="pkt-footer__social-icon" name={link.iconName} />
182
- </a>
183
- </Fragment>
184
- ))}
185
- </div>
155
+ </div>
156
+ )}
157
+ {consentInSecondary && (
158
+ <div className="pkt-footer__consent">
159
+ <PktConsent triggerType="footerlink" {...consent} />
160
+ </div>
161
+ )}
162
+ {!!socialLinks?.length && (
163
+ <div className="pkt-footer__bottom">
164
+ <ul className="pkt-footer__bottom-links">
165
+ {languageLinks.map((link) => (
166
+ <li key={`${link.href}|${link.language}`}>
167
+ <a
168
+ href={link.href}
169
+ aria-label={`til ${link.language} versjon av nettsiden`}
170
+ target={link.openInNewTab || openLinksInNewTab ? '_blank' : '_self'}
171
+ rel={link.openInNewTab || openLinksInNewTab ? 'noopener noreferrer' : undefined}
172
+ >
173
+ {link.language}
174
+ </a>
175
+ </li>
176
+ ))}
177
+ </ul>
178
+ <ul className="pkt-footer__some">
179
+ {iconLinks.map((link) => (
180
+ <li key={`${link.href}|${link.iconName}`}>
181
+ <a
182
+ className="pkt-footer__some-link"
183
+ href={link.href}
184
+ aria-label={`til ${link.iconName}`}
185
+ target={link.openInNewTab || openLinksInNewTab ? '_blank' : '_self'}
186
+ rel={link.openInNewTab || openLinksInNewTab ? 'noopener noreferrer' : undefined}
187
+ >
188
+ <PktIcon className="pkt-footer__some-icon" name={link.iconName} />
189
+ </a>
190
+ </li>
191
+ ))}
192
+ </ul>
193
+ </div>
194
+ )}
186
195
  </div>
187
- )}
188
- </div>
196
+ </div>
197
+ )}
198
+ {globalFooter && <FooterGlobal footer={globalFooter} includeConsent={includeConsent} consent={consent} />}
189
199
  </footer>
190
200
  )
191
201
  }
@@ -0,0 +1,181 @@
1
+ 'use client'
2
+
3
+ import { FC, useEffect, useMemo, useRef, useState } from 'react'
4
+
5
+ import {
6
+ deriveSocialIcon,
7
+ fetchHeaderFooterData,
8
+ getExternalDomainLabel,
9
+ selectLocaleData,
10
+ } from 'shared-utils/header-footer'
11
+ import type {
12
+ IHeaderFooter,
13
+ IHeaderMenuLink,
14
+ IHeaderMenuSection,
15
+ THeaderFooterApi,
16
+ THeaderMenuLocale,
17
+ } from 'shared-types'
18
+ import { PktConsent, type IPktConsent } from '../consent/Consent'
19
+ import { PktIcon } from '../icon/Icon'
20
+
21
+ /** The payload marks the cookie-settings entry with this pseudo-URL. */
22
+ const CONSENT_TRIGGER_URL = '#cb-trigger'
23
+
24
+ export type TFooterConsentConfig = Pick<
25
+ IPktConsent,
26
+ | 'hotjarId'
27
+ | 'googleAnalyticsId'
28
+ | 'devMode'
29
+ | 'cookieDomain'
30
+ | 'cookieSecure'
31
+ | 'cookieExpiryDays'
32
+ | 'onToggleConsent'
33
+ >
34
+
35
+ interface IUseGlobalFooterDataOptions {
36
+ data?: THeaderFooterApi<string>
37
+ dataUrl?: string
38
+ locale?: THeaderMenuLocale
39
+ skipGlobal?: boolean
40
+ onDataError?: (error: Error) => void
41
+ }
42
+
43
+ export function useGlobalFooterData({
44
+ data,
45
+ dataUrl,
46
+ locale = 'nb-NO',
47
+ skipGlobal = false,
48
+ onDataError,
49
+ }: IUseGlobalFooterDataOptions): IHeaderFooter | undefined {
50
+ const [fetchedData, setFetchedData] = useState<THeaderFooterApi<string>>()
51
+ const onDataErrorRef = useRef(onDataError)
52
+ onDataErrorRef.current = onDataError
53
+
54
+ useEffect(() => {
55
+ if (data || skipGlobal) return
56
+ const controller = new AbortController()
57
+ fetchHeaderFooterData<string>(dataUrl, controller.signal)
58
+ .then((payload) => {
59
+ if (!controller.signal.aborted) setFetchedData(payload)
60
+ })
61
+ .catch((error: Error) => {
62
+ if (error.name === 'AbortError') return
63
+ // eslint-disable-next-line no-console -- Error on fetching data
64
+ console.warn('Failed to fetch header/footer data:', error)
65
+ onDataErrorRef.current?.(error)
66
+ })
67
+ return () => controller.abort()
68
+ }, [data, dataUrl, skipGlobal])
69
+
70
+ const effectiveData = data ?? fetchedData
71
+ return useMemo(() => {
72
+ if (skipGlobal) return undefined
73
+ return selectLocaleData(effectiveData, locale)?.footer
74
+ }, [effectiveData, locale, skipGlobal])
75
+ }
76
+
77
+ interface ISectionLinkProps {
78
+ link: IHeaderMenuLink
79
+ includeConsent: boolean
80
+ consent: TFooterConsentConfig
81
+ }
82
+
83
+ const SectionLink: FC<ISectionLinkProps> = ({ link, includeConsent, consent }) => {
84
+ if (link.url === CONSENT_TRIGGER_URL) {
85
+ // Punkt's own consent dialog replaces the CDN cookie-banner trigger.
86
+ if (!includeConsent) return null
87
+ return (
88
+ <li>
89
+ <PktConsent triggerType="footerlink" triggerText={link.text} {...consent} />
90
+ </li>
91
+ )
92
+ }
93
+ const domain = getExternalDomainLabel(link.url)
94
+ return (
95
+ <li>
96
+ <a href={link.url}>
97
+ {link.text}
98
+ {domain && <span className="pkt-footer__link-domain"> ({domain})</span>}
99
+ </a>
100
+ </li>
101
+ )
102
+ }
103
+
104
+ const Section: FC<{ section: IHeaderMenuSection } & Omit<ISectionLinkProps, 'link'>> = ({
105
+ section,
106
+ includeConsent,
107
+ consent,
108
+ }) => (
109
+ <section>
110
+ <h2>{section.title}</h2>
111
+ <ul className="pkt-footer__link-list">
112
+ {section.links.map((link) => (
113
+ <SectionLink key={`${link.url}|${link.text}`} link={link} includeConsent={includeConsent} consent={consent} />
114
+ ))}
115
+ </ul>
116
+ </section>
117
+ )
118
+
119
+ const SomeLink: FC<{ entry: IHeaderMenuLink }> = ({ entry }) => {
120
+ const icon = deriveSocialIcon(entry.url, entry.text)
121
+ if (!icon) {
122
+ return (
123
+ <li>
124
+ <a href={entry.url}>{entry.text}</a>
125
+ </li>
126
+ )
127
+ }
128
+ return (
129
+ <li>
130
+ <a className="pkt-footer__some-link" href={entry.url} aria-label={entry.text}>
131
+ <PktIcon className="pkt-footer__some-icon" name={icon} />
132
+ </a>
133
+ </li>
134
+ )
135
+ }
136
+
137
+ interface IFooterGlobalProps {
138
+ footer: IHeaderFooter
139
+ includeConsent?: boolean
140
+ consent?: TFooterConsentConfig
141
+ }
142
+
143
+ /**
144
+ * The global (blue) footer band, fed by the shared header/footer payload.
145
+ * Internal — rendered by PktFooter / PktFooterSimple, not exported.
146
+ */
147
+ export const FooterGlobal: FC<IFooterGlobalProps> = ({ footer, includeConsent = false, consent = {} }) => {
148
+ const hasLinks = footer.links?.length > 0
149
+ const hasSome = footer.some?.length > 0
150
+ return (
151
+ <div className="pkt-footer__band pkt-footer__band--global">
152
+ <div className="pkt-footer__inner">
153
+ <div className="pkt-footer__sections">
154
+ {footer.sections.map((section) => (
155
+ <Section key={section.title} section={section} includeConsent={includeConsent} consent={consent} />
156
+ ))}
157
+ {(hasLinks || hasSome) && (
158
+ <div className="pkt-footer__bottom">
159
+ {hasLinks && (
160
+ <ul className="pkt-footer__bottom-links">
161
+ {footer.links.map((link) => (
162
+ <li key={`${link.url}|${link.text}`}>
163
+ <a href={link.url}>{link.text}</a>
164
+ </li>
165
+ ))}
166
+ </ul>
167
+ )}
168
+ {hasSome && (
169
+ <ul className="pkt-footer__some">
170
+ {footer.some.map((entry) => (
171
+ <SomeLink key={`${entry.url}|${entry.text}`} entry={entry} />
172
+ ))}
173
+ </ul>
174
+ )}
175
+ </div>
176
+ )}
177
+ </div>
178
+ </div>
179
+ </div>
180
+ )
181
+ }
@@ -1,55 +1,58 @@
1
- // Test med axe https://www.npmjs.com/package/jest-axe
2
- import '@testing-library/jest-dom'
1
+ import { render, screen } from '@testing-library/react'
2
+ import { afterEach, describe, expect, test, vi } from 'vitest'
3
3
 
4
- import { axe, toHaveNoViolations } from 'jest-axe'
5
-
6
- import { render } from '../../utils/test-utils'
4
+ import { EXAMPLE_HEADER_FOOTER_DATA } from 'shared-utils/header-footer'
5
+ import type { IPktFooterLink } from 'shared-types'
7
6
  import { PktFooterSimple } from './FooterSimple'
8
7
 
9
- expect.extend(toHaveNoViolations)
8
+ const LINKS: IPktFooterLink[] = [
9
+ { text: 'Lenke 1', href: 'https://example.test/lenke-1' },
10
+ { text: 'Lenke 2', href: 'https://example.test/lenke-2', external: true },
11
+ ]
12
+
13
+ afterEach(() => {
14
+ vi.restoreAllMocks()
15
+ })
10
16
 
11
17
  describe('PktFooterSimple', () => {
12
- const links = [
13
- { href: 'https://www.example.com', text: 'Example Link 1' },
14
- { href: 'https://www.example.com', text: 'Example Link 2' },
15
- { href: 'https://www.example.com', text: 'Example Link 3' },
16
- ]
17
-
18
- describe('basic rendering', () => {
19
- it('renders a list of links', () => {
20
- const { getByText } = render(<PktFooterSimple links={links} />)
21
- links.forEach((link) => {
22
- expect(getByText(link.text)).toBeInTheDocument()
23
- expect(getByText(link.text).getAttribute('href')).toEqual(link.href)
24
- })
25
- })
26
-
27
- it('renders an external link with an external class', () => {
28
- const links = [{ href: 'https://www.example.com', text: 'Example Link', external: true }]
29
- const { getByText } = render(<PktFooterSimple links={links} />)
30
- expect(getByText(links[0].text)).toHaveClass('pkt-link--external')
31
- })
32
-
33
- it('renders a privacy link', () => {
34
- const { getByText } = render(<PktFooterSimple />)
35
- expect(getByText('Personvern og informasjonskapsler')).toBeInTheDocument()
36
- expect(getByText('Personvern og informasjonskapsler').getAttribute('href')).toEqual(
37
- 'https://www.oslo.kommune.no/personvern-og-informasjonskapsler/',
38
- )
39
- })
40
-
41
- it('renders an accessibility link', () => {
42
- const { getByText } = render(<PktFooterSimple />)
43
- expect(getByText('Tilgjengelighet')).toBeInTheDocument()
44
- expect(getByText('Tilgjengelighet').getAttribute('href')).toEqual('https://www.oslo.kommune.no/tilgjengelighet/')
45
- })
18
+ test('rendrer kompakt lenkerad med pil-ikoner + global footer', () => {
19
+ const { container } = render(<PktFooterSimple links={LINKS} data={EXAMPLE_HEADER_FOOTER_DATA} />)
20
+ const links = [...container.querySelectorAll('.pkt-footer-simple__link')]
21
+ expect(links).toHaveLength(2)
22
+ expect(links[0].querySelector('.pkt-footer-simple__link-icon')).not.toBeNull()
23
+ expect(container.querySelector('.pkt-footer__band--global')).not.toBeNull()
24
+ })
25
+
26
+ test('rendrer kun global footer uten links', () => {
27
+ const { container } = render(<PktFooterSimple data={EXAMPLE_HEADER_FOOTER_DATA} />)
28
+ expect(container.querySelector('.pkt-footer-simple .pkt-footer__band--secondary')).toBeNull()
29
+ expect(container.querySelector('.pkt-footer__band--global')).not.toBeNull()
30
+ })
31
+
32
+ test('eksterne lenker viser toppdomenet i parentes', () => {
33
+ render(<PktFooterSimple links={LINKS} data={EXAMPLE_HEADER_FOOTER_DATA} />)
34
+ const external = screen.getByRole('link', { name: /Lenke 2/ })
35
+ expect(external.querySelector('.pkt-footer-simple__link-domain')?.textContent?.trim()).toBe('(example.test)')
36
+ })
37
+
38
+ test('skipGlobal dropper global footer og henter ikke data', () => {
39
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('should not run'))
40
+ const { container } = render(<PktFooterSimple links={LINKS} skipGlobal />)
41
+ expect(container.querySelector('.pkt-footer__band--global')).toBeNull()
42
+ expect(container.querySelector('.pkt-footer-simple .pkt-footer__band--secondary')).not.toBeNull()
43
+ expect(fetchSpy).not.toHaveBeenCalled()
44
+ })
45
+
46
+ test('consent rendres som listepunkt ved skipGlobal + includeConsent', () => {
47
+ const { container } = render(<PktFooterSimple links={LINKS} skipGlobal includeConsent />)
48
+ const consent = container.querySelector('.pkt-footer-simple__list a.pkt-footer__link')
49
+ expect(consent).not.toBeNull()
46
50
  })
47
51
 
48
- describe('accessibility', () => {
49
- it('renders with no wcag errors with axe', async () => {
50
- const { container } = render(<PktFooterSimple links={links} />)
51
- const results = await axe(container)
52
- expect(results).toHaveNoViolations()
53
- })
52
+ test('openLinksInNewTab åpner lenker i ny fane', () => {
53
+ render(<PktFooterSimple links={LINKS} data={EXAMPLE_HEADER_FOOTER_DATA} openLinksInNewTab />)
54
+ const link = screen.getByRole('link', { name: 'Lenke 1' })
55
+ expect(link).toHaveAttribute('target', '_blank')
56
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer')
54
57
  })
55
58
  })