@oslokommune/punkt-elements 16.26.7 → 17.0.2

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 (240) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/accordion-CbXDxOho.cjs +47 -0
  3. package/dist/{accordion-CBdIk9gw.js → accordion-DEUHQ4JD.js} +45 -35
  4. package/dist/{alert-HHTGY0sp.js → alert-CbtVGrHD.js} +31 -17
  5. package/dist/{alert-B8dLsiTz.cjs → alert-CkUnbPdA.cjs} +5 -5
  6. package/dist/base-elements/input-element.d.ts +1 -0
  7. package/dist/button-Cod17rKo.js +115 -0
  8. package/dist/{button-B5tQpKeG.cjs → button-gvDXtBMC.cjs} +5 -2
  9. package/dist/{calendar-DIzGBdbh.cjs → calendar-BVXW8DqC.cjs} +1 -1
  10. package/dist/{calendar-CjlUA-Lb.js → calendar-CjC4qlh6.js} +1 -1
  11. package/dist/{checkbox-Bt_sC_90.cjs → checkbox-8uxbeU30.cjs} +1 -1
  12. package/dist/{checkbox-D03BC2Va.js → checkbox-DJDphhqz.js} +1 -1
  13. package/dist/{combobox-CoQBTWqL.js → combobox-Du5yr-xM.js} +16 -2
  14. package/dist/combobox-hKrbYLUe.cjs +136 -0
  15. package/dist/components/accordion/accordion-context.d.ts +7 -0
  16. package/dist/components/accordion/accordion.d.ts +2 -0
  17. package/dist/components/accordion/accordionitem.d.ts +1 -0
  18. package/dist/components/alert/alert.d.ts +5 -0
  19. package/dist/components/alert/alert.test.d.ts +1 -0
  20. package/dist/components/button/button.d.ts +4 -1
  21. package/dist/components/datepicker/datepicker-base.d.ts +2 -0
  22. package/dist/components/datepicker/datepicker-utils.d.ts +2 -2
  23. package/dist/components/footer/footer-base.d.ts +44 -0
  24. package/dist/components/footer/footer-simple.d.ts +20 -0
  25. package/dist/components/footer/footer.accessibility.test.d.ts +0 -0
  26. package/dist/components/footer/footer.d.ts +22 -0
  27. package/dist/components/footer/footer.test.d.ts +0 -0
  28. package/dist/components/footer/index.d.ts +3 -0
  29. package/dist/components/footer/types.d.ts +16 -0
  30. package/dist/components/header/header-chrome-controller.d.ts +44 -0
  31. package/dist/components/header/header-global.accessibility.test.d.ts +0 -0
  32. package/dist/components/header/header-global.d.ts +78 -0
  33. package/dist/components/header/header-global.test.d.ts +0 -0
  34. package/dist/components/header/header-service.accessibility.test.d.ts +0 -0
  35. package/dist/components/header/header-service.d.ts +6 -21
  36. package/dist/components/header/header-service.test.d.ts +1 -1
  37. package/dist/components/header/header.d.ts +11 -3
  38. package/dist/components/header/header.test.d.ts +0 -0
  39. package/dist/components/header/index.d.ts +2 -1
  40. package/dist/components/header/types.d.ts +28 -2
  41. package/dist/components/header-menu/header-menu.d.ts +4 -5
  42. package/dist/components/header-menu/types.d.ts +1 -1
  43. package/dist/components/index.d.ts +5 -3
  44. package/dist/components/input-wrapper/input-wrapper.d.ts +2 -0
  45. package/dist/components/input-wrapper/input-wrapper.test.d.ts +1 -0
  46. package/dist/components/searchinput/searchinput.d.ts +5 -0
  47. package/dist/components/select/index.d.ts +1 -1
  48. package/dist/components/select/select.d.ts +2 -2
  49. package/dist/components/select/select.test.d.ts +1 -0
  50. package/dist/components/textarea/textarea.test.d.ts +2 -0
  51. package/dist/components/textinput/textinput.test.d.ts +1 -0
  52. package/dist/components/timepicker/timepicker.d.ts +2 -0
  53. package/dist/components/timepicker/timepicker.test.d.ts +3 -0
  54. package/dist/consent--Nw43WFU.cjs +18 -0
  55. package/dist/{consent-D5sY6JK3.js → consent-B2hSTCGC.js} +14 -7
  56. package/dist/consume-BiOhrhEy.js +167 -0
  57. package/dist/consume-DKf5SmP9.cjs +1 -0
  58. package/dist/{converters-BKGBRBpS.js → converters-BBfsEJV5.js} +25 -25
  59. package/dist/{converters-9jHlXDyy.cjs → converters-Cd0fPEly.cjs} +1 -1
  60. package/dist/{datepicker-BE5Ll0W4.js → datepicker--Fq8q-J3.js} +30 -12
  61. package/dist/{datepicker-x4xM3KQz.cjs → datepicker-CEoBYTBU.cjs} +11 -7
  62. package/dist/docs/components/alert.d.ts +3 -0
  63. package/dist/docs/components/footer.d.ts +9 -0
  64. package/dist/docs/components/form-sizes.d.ts +10 -0
  65. package/dist/docs/components/header-global-full.d.ts +20 -0
  66. package/dist/docs/components/header-global.d.ts +14 -0
  67. package/dist/docs/components/searchinput.d.ts +6 -3
  68. package/dist/fetch-header-footer-data-Pc32etrk.js +88 -0
  69. package/dist/fetch-header-footer-data-xohWFmYC.cjs +1 -0
  70. package/dist/fileupload-CGKUJrlj.cjs +401 -0
  71. package/dist/{fileupload-2hf8YNhO.js → fileupload-D4gQN64T.js} +156 -176
  72. package/dist/focus-trap-DG3pEuyJ.js +60 -0
  73. package/dist/focus-trap-DSv4Ifqm.cjs +1 -0
  74. package/dist/footer-BIrJeRuZ.js +303 -0
  75. package/dist/footer-Ber-jqM1.cjs +114 -0
  76. package/dist/footer.d.ts +1 -0
  77. package/dist/header-BFk1an4z.cjs +465 -0
  78. package/dist/header-Deg_LGJo.js +1287 -0
  79. package/dist/header-menu-BHqK4QA6.cjs +126 -0
  80. package/dist/{header-menu-C8b5vd3n.js → header-menu-GFtJKM4j.js} +25 -97
  81. package/dist/{helptext-OfqH5npD.js → helptext-C_brWCIT.js} +1 -1
  82. package/dist/{helptext-eckq4JEX.cjs → helptext-Cq5rNaAC.cjs} +1 -1
  83. package/dist/{input-element-ZGOWsvKM.cjs → input-element-B0qLbEdF.cjs} +1 -1
  84. package/dist/{input-element-BFHpeoV8.js → input-element-C0RnXi-J.js} +5 -2
  85. package/dist/{input-wrapper-DwwE0JcO.js → input-wrapper-BweP6t3O.js} +12 -0
  86. package/dist/{input-wrapper-BRjQB4zM.cjs → input-wrapper-DPkkiG7S.cjs} +13 -11
  87. package/dist/{input-wrapper-DaA98B5l.js → input-wrapper-fWV5P4MN.js} +22 -15
  88. package/dist/{input-wrapper-LcZjpt0L.cjs → input-wrapper-wh3fWIhk.cjs} +1 -1
  89. package/dist/modal-CPnFteYS.cjs +35 -0
  90. package/dist/{modal-C6pScpdG.js → modal-xNK_wpHk.js} +11 -20
  91. package/dist/pkt-accordion.cjs +1 -1
  92. package/dist/pkt-accordion.js +1 -1
  93. package/dist/pkt-alert.cjs +1 -1
  94. package/dist/pkt-alert.js +1 -1
  95. package/dist/pkt-button.cjs +1 -1
  96. package/dist/pkt-button.js +1 -1
  97. package/dist/pkt-calendar.cjs +1 -1
  98. package/dist/pkt-calendar.js +1 -1
  99. package/dist/pkt-checkbox.cjs +1 -1
  100. package/dist/pkt-checkbox.js +1 -1
  101. package/dist/pkt-combobox.cjs +1 -1
  102. package/dist/pkt-combobox.js +1 -1
  103. package/dist/pkt-consent.cjs +1 -1
  104. package/dist/pkt-consent.js +1 -1
  105. package/dist/pkt-datepicker.cjs +1 -1
  106. package/dist/pkt-datepicker.js +1 -1
  107. package/dist/pkt-fileupload.cjs +1 -1
  108. package/dist/pkt-fileupload.js +1 -1
  109. package/dist/pkt-footer.cjs +1 -0
  110. package/dist/pkt-footer.js +2 -0
  111. package/dist/pkt-header-menu.cjs +1 -1
  112. package/dist/pkt-header-menu.js +1 -1
  113. package/dist/pkt-header.cjs +1 -1
  114. package/dist/pkt-header.js +2 -2
  115. package/dist/pkt-helptext.cjs +1 -1
  116. package/dist/pkt-helptext.js +1 -1
  117. package/dist/pkt-index.cjs +1 -1
  118. package/dist/pkt-index.js +27 -26
  119. package/dist/pkt-input-wrapper.cjs +1 -1
  120. package/dist/pkt-input-wrapper.js +1 -1
  121. package/dist/pkt-modal.cjs +1 -1
  122. package/dist/pkt-modal.js +1 -1
  123. package/dist/pkt-radiobutton.cjs +1 -1
  124. package/dist/pkt-radiobutton.js +1 -1
  125. package/dist/pkt-searchinput.cjs +1 -1
  126. package/dist/pkt-searchinput.js +1 -1
  127. package/dist/pkt-select.cjs +1 -1
  128. package/dist/pkt-select.js +1 -1
  129. package/dist/pkt-tabs.cjs +1 -1
  130. package/dist/pkt-tabs.js +1 -1
  131. package/dist/pkt-textarea.cjs +1 -1
  132. package/dist/pkt-textarea.js +1 -1
  133. package/dist/pkt-textinput.cjs +1 -1
  134. package/dist/pkt-textinput.js +1 -1
  135. package/dist/pkt-timepicker.cjs +1 -1
  136. package/dist/pkt-timepicker.js +1 -1
  137. package/dist/{radiobutton-DGO6H2Ki.js → radiobutton-C6htwDrT.js} +1 -1
  138. package/dist/{radiobutton-DY1WWepJ.cjs → radiobutton-DjWBoSHv.cjs} +1 -1
  139. package/dist/scroll-lock-BN8lOhV5.cjs +1 -0
  140. package/dist/scroll-lock-DAbNKAUy.js +18 -0
  141. package/dist/searchinput-CfUVfpqL.js +156 -0
  142. package/dist/searchinput-DGjeN1YI.cjs +60 -0
  143. package/dist/{select-D4_HQFkh.cjs → select-CZvAW7W2.cjs} +2 -1
  144. package/dist/{select-BwHUIlwQ.js → select-CsBXXbLP.js} +4 -3
  145. package/dist/setupTests.d.ts +0 -0
  146. package/dist/shared-types/footer.d.ts +95 -0
  147. package/dist/shared-types/{header-menu.d.ts → header-footer.d.ts} +51 -4
  148. package/dist/shared-types/header.d.ts +1 -1
  149. package/dist/shared-types/index.d.ts +6 -4
  150. package/dist/shared-types/searchinput.d.ts +2 -0
  151. package/dist/shared-types/select.d.ts +3 -2
  152. package/dist/shared-types/size.d.ts +4 -0
  153. package/dist/shared-types/timepicker.d.ts +0 -6
  154. package/dist/shared-utils/datepicker-utils.d.ts +5 -4
  155. package/dist/shared-utils/fileupload/focus-trap.d.ts +4 -10
  156. package/dist/shared-utils/focus-trap/index.d.ts +22 -0
  157. package/dist/shared-utils/{header-menu → header-footer}/example-data.d.ts +5 -4
  158. package/dist/shared-utils/header-footer/external-domain.d.ts +26 -0
  159. package/dist/shared-utils/{header-menu → header-footer}/fetch-header-footer-data.d.ts +6 -2
  160. package/dist/shared-utils/{header-menu → header-footer}/index.d.ts +2 -1
  161. package/dist/shared-utils/{header-menu → header-footer}/select-megamenu.d.ts +1 -1
  162. package/dist/shared-utils/scroll-lock/index.d.ts +24 -0
  163. package/dist/tabs-BqrKLCjd.js +214 -0
  164. package/dist/tabs-q62MK_-r.cjs +40 -0
  165. package/dist/{textarea-D6M7ZhtA.js → textarea-Cm392CEI.js} +5 -3
  166. package/dist/{textarea-Bt_Zs8gq.cjs → textarea-sTcZxOIB.cjs} +3 -2
  167. package/dist/{textinput-EDKjcUdg.cjs → textinput-DqJbTglM.cjs} +3 -2
  168. package/dist/{textinput-oU2EvFsi.js → textinput-TSPcBcih.js} +5 -3
  169. package/dist/{timepicker-CgOL2sd9.cjs → timepicker-CqFYMV8q.cjs} +8 -7
  170. package/dist/{timepicker-_G0V_HV3.js → timepicker-DWbgTDKq.js} +10 -9
  171. package/package.json +3 -3
  172. package/src/components/accordion/accordion-context.ts +8 -0
  173. package/src/components/accordion/accordion.test.ts +44 -0
  174. package/src/components/accordion/accordion.ts +13 -3
  175. package/src/components/accordion/accordionitem.ts +9 -1
  176. package/src/components/alert/alert.test.ts +51 -2
  177. package/src/components/alert/alert.ts +11 -5
  178. package/src/components/breadcrumbs/breadcrumbs.test.ts +11 -3
  179. package/src/components/button/button.test.ts +23 -0
  180. package/src/components/button/button.ts +28 -3
  181. package/src/components/combobox/combobox.core.test.ts +20 -0
  182. package/src/components/combobox/combobox.ts +2 -0
  183. package/src/components/consent/consent.test.ts +6 -3
  184. package/src/components/consent/consent.ts +15 -7
  185. package/src/components/datepicker/datepicker-base.ts +5 -1
  186. package/src/components/datepicker/datepicker-utils.test.ts +10 -0
  187. package/src/components/datepicker/datepicker.core.test.ts +17 -0
  188. package/src/components/datepicker/datepicker.ts +8 -0
  189. package/src/components/footer/footer-base.ts +238 -0
  190. package/src/components/footer/footer-simple.ts +71 -0
  191. package/src/components/footer/footer.accessibility.test.ts +66 -0
  192. package/src/components/footer/footer.test.ts +238 -0
  193. package/src/components/footer/footer.ts +81 -0
  194. package/src/components/footer/index.ts +4 -0
  195. package/src/components/footer/types.ts +21 -0
  196. package/src/components/header/header-chrome-controller.ts +123 -0
  197. package/src/components/header/header-global.accessibility.test.ts +110 -0
  198. package/src/components/header/header-global.test.ts +329 -0
  199. package/src/components/header/header-global.ts +648 -0
  200. package/src/components/header/header-service.accessibility.test.ts +79 -0
  201. package/src/components/header/header-service.test.ts +32 -12
  202. package/src/components/header/header-service.ts +38 -162
  203. package/src/components/header/header-user-menu.ts +0 -1
  204. package/src/components/header/header.test.ts +84 -0
  205. package/src/components/header/header.ts +52 -5
  206. package/src/components/header/index.ts +6 -0
  207. package/src/components/header/types.ts +38 -0
  208. package/src/components/header-menu/header-menu.test.ts +131 -10
  209. package/src/components/header-menu/header-menu.ts +23 -24
  210. package/src/components/header-menu/types.ts +1 -1
  211. package/src/components/index.ts +18 -2
  212. package/src/components/input-wrapper/input-wrapper.test.ts +45 -0
  213. package/src/components/input-wrapper/input-wrapper.ts +17 -9
  214. package/src/components/modal/modal.ts +8 -9
  215. package/src/components/searchinput/searchinput.test.ts +43 -0
  216. package/src/components/searchinput/searchinput.ts +29 -12
  217. package/src/components/select/index.ts +1 -1
  218. package/src/components/select/select.test.ts +28 -0
  219. package/src/components/select/select.ts +4 -3
  220. package/src/components/textarea/textarea.test.ts +16 -0
  221. package/src/components/textarea/textarea.ts +3 -1
  222. package/src/components/textinput/textinput.test.ts +28 -0
  223. package/src/components/textinput/textinput.ts +3 -1
  224. package/src/components/timepicker/timepicker.test.ts +26 -0
  225. package/src/components/timepicker/timepicker.ts +12 -6
  226. package/dist/accordion-FISAiAbX.cjs +0 -47
  227. package/dist/button-C_YzTSuU.js +0 -96
  228. package/dist/combobox-D6H9rWPV.cjs +0 -135
  229. package/dist/consent-X3wvhteO.cjs +0 -10
  230. package/dist/fileupload-BLVrslUX.cjs +0 -401
  231. package/dist/header-CFrsaq0c.cjs +0 -310
  232. package/dist/header-D_xvPR4R.js +0 -801
  233. package/dist/header-menu-DMdP_c6X.cjs +0 -126
  234. package/dist/modal-DKU7PnjJ.cjs +0 -35
  235. package/dist/searchinput-CyO5k4Q4.js +0 -148
  236. package/dist/searchinput-DG6YetL1.cjs +0 -60
  237. package/dist/shared-utils/modal-scroll-lock.d.ts +0 -10
  238. package/dist/tabs-CGo0ODPa.cjs +0 -40
  239. package/dist/tabs-ChdR0D1C.js +0 -379
  240. /package/dist/shared-utils/{header-menu → header-footer}/icon-map.d.ts +0 -0
@@ -12,6 +12,9 @@ import type {
12
12
  TLogOutButtonPlacement,
13
13
  THeaderPosition,
14
14
  THeaderScrollBehavior,
15
+ THeaderFooterApi,
16
+ THeaderMenuLocale,
17
+ IPktHeaderGlobal as ISharedPktHeaderGlobal,
15
18
  } from 'shared-types'
16
19
 
17
20
  export type {
@@ -21,7 +24,15 @@ export type {
21
24
  TLogOutButtonPlacement,
22
25
  THeaderPosition,
23
26
  THeaderScrollBehavior,
27
+ THeaderFooterApi,
28
+ THeaderMenuLocale,
24
29
  }
30
+
31
+ /** Which header variant `<pkt-header>` renders. */
32
+ export type THeaderType = 'service' | 'global'
33
+
34
+ /** Which dropdown/overlay is open in the global header. */
35
+ export type TGlobalHeaderMenu = 'none' | 'menu' | 'user' | 'search'
25
36
  export { booleanishConverter }
26
37
 
27
38
  // Re-export with PktIconName typing
@@ -85,4 +96,31 @@ export interface IPktHeader {
85
96
  slotMenuVariant?: TSlotMenuVariant
86
97
  /** Text for the slot menu button. Default: 'Meny' */
87
98
  slotMenuText?: string
99
+ /** Which header variant to render. Default: 'service' */
100
+ type?: THeaderType
101
+ }
102
+
103
+ /**
104
+ * Interface for the global (kommune-wide) Header element props.
105
+ *
106
+ * Extends the framework-agnostic {@link ISharedPktHeaderGlobal} base
107
+ * (in shared-types), narrowing boolean fields to `Booleanish`, typing
108
+ * `userMenu` icons as `PktIconName`, and adding the Lit-only `hasLogOut`
109
+ * signal.
110
+ */
111
+ export interface IPktHeaderGlobal
112
+ extends Omit<
113
+ ISharedPktHeaderGlobal,
114
+ 'showSearch' | 'showContact' | 'canChangeRepresentation' | 'userMenu'
115
+ > {
116
+ /** Show the search field. Default: true */
117
+ showSearch?: Booleanish
118
+ /** Show the contact link (from the payload's `header.contact`). Default: true */
119
+ showContact?: Booleanish
120
+ /** Allow the user to change representation. */
121
+ canChangeRepresentation?: Booleanish
122
+ /** User menu items. */
123
+ userMenu?: UserMenuItem[]
124
+ /** Whether a logout handler is attached (required for the logout button to show). */
125
+ hasLogOut?: Booleanish
88
126
  }
@@ -8,12 +8,16 @@ import './header-menu'
8
8
  import { PktHeaderMenu } from './header-menu'
9
9
  import {
10
10
  deriveSocialIcon,
11
+ fetchHeaderFooterData,
12
+ getExternalDomainLabel,
13
+ getLinkDomainLabel,
11
14
  ICON_MAP_ODS_TO_PUNKT,
12
15
  mapOdsIcon,
16
+ resetHeaderFooterDataCache,
13
17
  selectLocaleData,
14
18
  selectMegamenu,
15
- } from 'shared-utils/header-menu'
16
- import type { THeaderFooterApi } from 'shared-types/header-menu'
19
+ } from 'shared-utils/header-footer'
20
+ import type { THeaderFooterApi } from 'shared-types/header-footer'
17
21
 
18
22
  const LOCAL_DATA: THeaderFooterApi = {
19
23
  'nb-NO': {
@@ -130,9 +134,9 @@ describe('shared-utils: icon-map', () => {
130
134
  })
131
135
 
132
136
  test('deriveSocialIcon ignores localized text when URL resolves', () => {
133
- expect(
134
- deriveSocialIcon('https://www.facebook.com/Oslo/', 'Følg oss på Facebook'),
135
- ).toBe('facebook')
137
+ expect(deriveSocialIcon('https://www.facebook.com/Oslo/', 'Følg oss på Facebook')).toBe(
138
+ 'facebook',
139
+ )
136
140
  })
137
141
 
138
142
  test('deriveSocialIcon falls back to text when the URL is unknown or invalid', () => {
@@ -170,6 +174,127 @@ describe('shared-utils: select-megamenu', () => {
170
174
  })
171
175
  })
172
176
 
177
+ describe('shared-utils: external-domain', () => {
178
+ test('returns the top domain for hosts outside oslo.kommune.no', () => {
179
+ expect(
180
+ getExternalDomainLabel(
181
+ 'https://candidate.webcruiter.com/nb-no/home/companyadverts?companylock=2180#search',
182
+ ),
183
+ ).toBe('webcruiter.com')
184
+ expect(getExternalDomainLabel('https://www.facebook.com/Oslo/')).toBe('facebook.com')
185
+ })
186
+
187
+ test('handles two-part TLDs', () => {
188
+ expect(getExternalDomainLabel('https://news.bbc.co.uk/article')).toBe('bbc.co.uk')
189
+ })
190
+
191
+ test('returns undefined for oslo.kommune.no and subdomains', () => {
192
+ expect(getExternalDomainLabel('https://www.oslo.kommune.no/kontakt/')).toBeUndefined()
193
+ expect(getExternalDomainLabel('https://oslo.kommune.no/')).toBeUndefined()
194
+ expect(getExternalDomainLabel('https://tjenester.oslo.kommune.no/booking')).toBeUndefined()
195
+ })
196
+
197
+ test('returns undefined for relative URLs, fragments, and non-http schemes', () => {
198
+ expect(getExternalDomainLabel('/jobb-i-oslo-kommune/')).toBeUndefined()
199
+ expect(getExternalDomainLabel('#cb-trigger')).toBeUndefined()
200
+ expect(getExternalDomainLabel('mailto:postmottak@oslo.kommune.no')).toBeUndefined()
201
+ expect(getExternalDomainLabel('tel:+4721802180')).toBeUndefined()
202
+ expect(getExternalDomainLabel('')).toBeUndefined()
203
+ expect(getExternalDomainLabel(undefined)).toBeUndefined()
204
+ })
205
+
206
+ test('honors a custom internal host', () => {
207
+ expect(
208
+ getExternalDomainLabel('https://intranett.example.no/side', 'example.no'),
209
+ ).toBeUndefined()
210
+ expect(getExternalDomainLabel('https://www.oslo.kommune.no/', 'example.no')).toBe('oslo.kommune.no')
211
+ })
212
+
213
+ test('getLinkDomainLabel skips the internal-host check (explicit external flag)', () => {
214
+ expect(getLinkDomainLabel('https://www.oslo.kommune.no/kontakt/')).toBe('oslo.kommune.no')
215
+ expect(getLinkDomainLabel('https://example.test/side')).toBe('example.test')
216
+ expect(getLinkDomainLabel('/relativ')).toBeUndefined()
217
+ expect(getLinkDomainLabel('mailto:foo@example.test')).toBeUndefined()
218
+ })
219
+ })
220
+
221
+ describe('shared-utils: fetchHeaderFooterData dedup', () => {
222
+ const PAYLOAD_URL = 'https://dedup.test/payload.json'
223
+
224
+ const mockFetchOk = () =>
225
+ vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
226
+ Promise.resolve(
227
+ new Response(JSON.stringify(LOCAL_DATA), {
228
+ status: 200,
229
+ headers: { 'Content-Type': 'application/json' },
230
+ }),
231
+ ),
232
+ )
233
+
234
+ beforeEach(() => {
235
+ resetHeaderFooterDataCache()
236
+ })
237
+
238
+ test('concurrent calls for the same URL share one request', async () => {
239
+ const fetchSpy = mockFetchOk()
240
+ const [a, b] = await Promise.all([
241
+ fetchHeaderFooterData(PAYLOAD_URL),
242
+ fetchHeaderFooterData(PAYLOAD_URL),
243
+ ])
244
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
245
+ expect(a).toEqual(LOCAL_DATA)
246
+ expect(b).toEqual(LOCAL_DATA)
247
+ })
248
+
249
+ test('successful payloads stay cached for later calls', async () => {
250
+ const fetchSpy = mockFetchOk()
251
+ await fetchHeaderFooterData(PAYLOAD_URL)
252
+ await fetchHeaderFooterData(PAYLOAD_URL)
253
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
254
+ })
255
+
256
+ test('different URLs fetch independently', async () => {
257
+ const fetchSpy = mockFetchOk()
258
+ await Promise.all([
259
+ fetchHeaderFooterData(PAYLOAD_URL),
260
+ fetchHeaderFooterData('https://dedup.test/other.json'),
261
+ ])
262
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
263
+ })
264
+
265
+ test('failed fetches are evicted so a retry hits the network', async () => {
266
+ const fetchSpy = vi
267
+ .spyOn(globalThis, 'fetch')
268
+ .mockResolvedValueOnce(new Response('boom', { status: 500 }))
269
+ .mockResolvedValueOnce(
270
+ new Response(JSON.stringify(LOCAL_DATA), {
271
+ status: 200,
272
+ headers: { 'Content-Type': 'application/json' },
273
+ }),
274
+ )
275
+ await expect(fetchHeaderFooterData(PAYLOAD_URL)).rejects.toThrow('HTTP 500')
276
+ await expect(fetchHeaderFooterData(PAYLOAD_URL)).resolves.toEqual(LOCAL_DATA)
277
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
278
+ })
279
+
280
+ test('aborting one caller does not cancel the shared request', async () => {
281
+ mockFetchOk()
282
+ const controller = new AbortController()
283
+ const aborted = fetchHeaderFooterData(PAYLOAD_URL, controller.signal)
284
+ const kept = fetchHeaderFooterData(PAYLOAD_URL)
285
+ controller.abort()
286
+ await expect(aborted).rejects.toThrow()
287
+ await expect(kept).resolves.toEqual(LOCAL_DATA)
288
+ })
289
+
290
+ test('rejects immediately when the signal is already aborted', async () => {
291
+ mockFetchOk()
292
+ const controller = new AbortController()
293
+ controller.abort()
294
+ await expect(fetchHeaderFooterData(PAYLOAD_URL, controller.signal)).rejects.toThrow()
295
+ })
296
+ })
297
+
173
298
  describe('PktHeaderMenu rendering', () => {
174
299
  test('renders services, sections, and footer when given pre-fetched data', async () => {
175
300
  const el = await mountWithData()
@@ -268,7 +393,6 @@ describe('PktHeaderMenu fetching', () => {
268
393
  await el.updateComplete
269
394
  expect(globalThis.fetch).toHaveBeenCalledWith(
270
395
  'https://cdn.web.oslo.kommune.no/header-footer/header-footer.json',
271
- expect.any(Object),
272
396
  )
273
397
  expect(el.querySelector('.pkt-header-menu__services-title')?.textContent).toContain(
274
398
  'Tjenester og tilbud',
@@ -281,10 +405,7 @@ describe('PktHeaderMenu fetching', () => {
281
405
  document.body.appendChild(el)
282
406
  await waitForReady()
283
407
  await new Promise((resolve) => setTimeout(resolve, 0))
284
- expect(globalThis.fetch).toHaveBeenCalledWith(
285
- 'https://custom.test/payload.json',
286
- expect.any(Object),
287
- )
408
+ expect(globalThis.fetch).toHaveBeenCalledWith('https://custom.test/payload.json')
288
409
  })
289
410
 
290
411
  test('emits data-error when the fetch fails', async () => {
@@ -9,7 +9,7 @@ import {
9
9
  fetchHeaderFooterData,
10
10
  mapOdsIcon,
11
11
  selectLocaleData,
12
- } from 'shared-utils/header-menu'
12
+ } from 'shared-utils/header-footer'
13
13
  import type {
14
14
  IHeaderFooterLocaleData,
15
15
  IHeaderMenuButton,
@@ -39,9 +39,6 @@ export class PktHeaderMenu extends PktElement<IPktHeaderMenu> implements IPktHea
39
39
  @property({ type: Boolean, reflect: true, converter: booleanishConverter })
40
40
  open: Booleanish = false
41
41
 
42
- @property({ type: String, attribute: 'aria-labelledby', reflect: true })
43
- ariaLabelledBy: string = ''
44
-
45
42
  @property({ type: Number, attribute: 'mobile-breakpoint' })
46
43
  mobileBreakpoint: number = 768
47
44
 
@@ -50,13 +47,14 @@ export class PktHeaderMenu extends PktElement<IPktHeaderMenu> implements IPktHea
50
47
  @state() private isMobile = false
51
48
 
52
49
  private abortController?: AbortController
53
- private mediaQuery?: MediaQueryList
50
+ private menuWidth = 0
51
+ private resizeObserver?: ResizeObserver
54
52
 
55
53
  override connectedCallback(): void {
56
54
  super.connectedCallback()
57
55
  this.classList.add('pkt-header-menu')
58
56
  this.updateOpenClass()
59
- this.setupMediaQuery()
57
+ this.observeWidth()
60
58
  if (!this.data) {
61
59
  void this.loadData()
62
60
  } else {
@@ -78,7 +76,8 @@ export class PktHeaderMenu extends PktElement<IPktHeaderMenu> implements IPktHea
78
76
  override disconnectedCallback(): void {
79
77
  super.disconnectedCallback()
80
78
  this.abortController?.abort()
81
- this.teardownMediaQuery()
79
+ this.resizeObserver?.disconnect()
80
+ this.resizeObserver = undefined
82
81
  }
83
82
 
84
83
  override updated(changedProperties: PropertyValues): void {
@@ -92,12 +91,9 @@ export class PktHeaderMenu extends PktElement<IPktHeaderMenu> implements IPktHea
92
91
  changedProperties.has('mobileBreakpoint') &&
93
92
  changedProperties.get('mobileBreakpoint') !== undefined
94
93
  ) {
95
- this.setupMediaQuery()
94
+ this.refreshIsMobile()
96
95
  }
97
96
 
98
- // Re-fetch only when dataUrl genuinely changes after first load.
99
- // The initial fetch is kicked off from connectedCallback, so we
100
- // skip the noop "field default" change that fires on first update.
101
97
  if (
102
98
  changedProperties.has('dataUrl') &&
103
99
  changedProperties.get('dataUrl') !== undefined &&
@@ -118,21 +114,24 @@ export class PktHeaderMenu extends PktElement<IPktHeaderMenu> implements IPktHea
118
114
  }
119
115
  }
120
116
 
121
- private setupMediaQuery() {
122
- this.teardownMediaQuery()
123
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
124
- this.mediaQuery = window.matchMedia(`(max-width: ${this.mobileBreakpoint - 1}px)`)
125
- this.isMobile = this.mediaQuery.matches
126
- this.mediaQuery.addEventListener('change', this.handleMediaChange)
127
- }
128
-
129
- private teardownMediaQuery() {
130
- this.mediaQuery?.removeEventListener('change', this.handleMediaChange)
131
- this.mediaQuery = undefined
117
+ private observeWidth() {
118
+ if (typeof ResizeObserver === 'undefined') return
119
+ this.menuWidth = this.offsetWidth
120
+ this.refreshIsMobile()
121
+ this.resizeObserver = new ResizeObserver((entries) => {
122
+ for (const entry of entries) {
123
+ this.menuWidth =
124
+ entry.borderBoxSize && entry.borderBoxSize.length > 0
125
+ ? entry.borderBoxSize[0].inlineSize
126
+ : entry.contentRect.width
127
+ this.refreshIsMobile()
128
+ }
129
+ })
130
+ this.resizeObserver.observe(this)
132
131
  }
133
132
 
134
- private handleMediaChange = (event: MediaQueryListEvent) => {
135
- this.isMobile = event.matches
133
+ private refreshIsMobile() {
134
+ this.isMobile = this.menuWidth < this.mobileBreakpoint
136
135
  }
137
136
 
138
137
  private async loadData() {
@@ -10,7 +10,7 @@ import type {
10
10
  IPktHeaderMenu as ISharedPktHeaderMenu,
11
11
  THeaderFooterApi,
12
12
  THeaderMenuLocale,
13
- } from 'shared-types/header-menu'
13
+ } from 'shared-types/header-footer'
14
14
  import type { Booleanish } from 'shared-types'
15
15
 
16
16
  export type {
@@ -12,7 +12,13 @@ export { PktCheckbox } from '@/components/checkbox'
12
12
  export { PktFileUpload } from '@/components/fileupload'
13
13
  export { PktDateTags } from '@/components/datepicker/date-tags.js'
14
14
  export { PktDatepicker } from '@/components/datepicker/datepicker.js'
15
- export { PktHeader, PktHeaderService, PktHeaderUserMenu } from '@/components/header'
15
+ export { PktFooter, PktFooterSimple } from '@/components/footer'
16
+ export {
17
+ PktHeader,
18
+ PktHeaderService,
19
+ PktHeaderGlobal,
20
+ PktHeaderUserMenu,
21
+ } from '@/components/header'
16
22
  export { PktHeaderMenu } from '@/components/header-menu'
17
23
  export { PktHelptext } from '@/components/helptext'
18
24
  export { PktHeading } from '@/components/heading'
@@ -58,7 +64,10 @@ export type {
58
64
  THeaderPosition,
59
65
  THeaderScrollBehavior,
60
66
  TSlotMenuVariant,
67
+ THeaderType,
68
+ TGlobalHeaderMenu,
61
69
  IPktHeader,
70
+ IPktHeaderGlobal,
62
71
  } from '@/components/header'
63
72
 
64
73
  export type { IPktHeaderMenu } from '@/components/header-menu'
@@ -84,7 +93,7 @@ export type { TTagSkin, TTagType } from '@/components/tag'
84
93
 
85
94
  export type { IPktTabs, IPktTabItem, TSkin as TTabItemSkin } from '@/components/tabs'
86
95
 
87
- export type { TSelectOption } from '@/components/select'
96
+ export type { IPktSelectOption, TSelectOption } from '@/components/select'
88
97
 
89
98
  export type { IPktBackLink } from '@/components/backlink'
90
99
 
@@ -93,6 +102,13 @@ export type { IPktBreadcrumbs, IBreadcrumbItem } from '@/components/breadcrumbs'
93
102
  export type { IPktLoader, TPktLoaderVariant } from '@/components/loader'
94
103
 
95
104
  export type { IPktConsent } from '@/components/consent'
105
+
106
+ export type {
107
+ IPktFooter,
108
+ IPktFooterSimple,
109
+ IPktFooterColumn,
110
+ IPktFooterLink,
111
+ } from '@/components/footer'
96
112
  export type {
97
113
  FileItem,
98
114
  IPktFileUpload,
@@ -31,6 +31,7 @@ export interface InputWrapperTestConfig extends BaseTestConfig {
31
31
  requiredText?: string
32
32
  tagText?: string
33
33
  role?: string
34
+ size?: 'small' | 'medium' | 'xsmall'
34
35
  }
35
36
 
36
37
  // Use shared framework
@@ -280,6 +281,27 @@ describe('PktInputWrapper', () => {
280
281
  const errorMessage = wrapper.querySelector('.pkt-alert--error')
281
282
  expect(errorMessage).toBeInTheDocument()
282
283
  expect(errorMessage?.textContent).toContain('This field is required')
284
+ expect(errorMessage).toHaveClass('pkt-alert--small')
285
+ })
286
+
287
+ test('uses xsmall error alert when wrapper size is small or xsmall', async () => {
288
+ const { wrapper: smallWrapper } = await createInputWrapperTest({
289
+ label: 'Test Label',
290
+ hasError: true,
291
+ errorMessage: 'Required',
292
+ })
293
+ smallWrapper.size = 'small'
294
+ await smallWrapper.updateComplete
295
+ expect(smallWrapper.querySelector('.pkt-alert--error')).toHaveClass('pkt-alert--xsmall')
296
+
297
+ const { wrapper: xsmallWrapper } = await createInputWrapperTest({
298
+ label: 'Test Label',
299
+ hasError: true,
300
+ errorMessage: 'Required',
301
+ })
302
+ xsmallWrapper.size = 'xsmall'
303
+ await xsmallWrapper.updateComplete
304
+ expect(xsmallWrapper.querySelector('.pkt-alert--error')).toHaveClass('pkt-alert--xsmall')
283
305
  })
284
306
 
285
307
  test('does not display error message when hasError is false', async () => {
@@ -303,6 +325,29 @@ describe('PktInputWrapper', () => {
303
325
  const inputWrapper = wrapper.querySelector('.pkt-inputwrapper')
304
326
  expect(inputWrapper?.classList.contains('pkt-inputwrapper--error')).toBe(true)
305
327
  })
328
+
329
+ test('applies small size modifier class', async () => {
330
+ const { wrapper } = await createInputWrapperTest({
331
+ label: 'Test Label',
332
+ })
333
+ wrapper.size = 'small'
334
+ await wrapper.updateComplete
335
+
336
+ const inputWrapper = wrapper.querySelector('.pkt-inputwrapper')
337
+ expect(inputWrapper?.classList.contains('pkt-inputwrapper--small')).toBe(true)
338
+ })
339
+
340
+ test('applies xsmall size modifier class', async () => {
341
+ const { wrapper } = await createInputWrapperTest({
342
+ label: 'Test Label',
343
+ })
344
+ wrapper.size = 'xsmall'
345
+ await wrapper.updateComplete
346
+
347
+ const inputWrapper = wrapper.querySelector('.pkt-inputwrapper')
348
+ expect(inputWrapper?.classList.contains('pkt-inputwrapper--xsmall')).toBe(true)
349
+ expect(inputWrapper?.classList.contains('pkt-inputwrapper--small')).toBe(false)
350
+ })
306
351
  })
307
352
 
308
353
  describe('Fieldset functionality', () => {
@@ -15,6 +15,7 @@ import '@/components/icon'
15
15
  import '@/components/alert'
16
16
 
17
17
  type TCounterPosition = 'top' | 'bottom'
18
+ type TInputWrapperSize = 'small' | 'medium' | 'xsmall'
18
19
  type Props = ElementProps<
19
20
  PktInputWrapper,
20
21
  | 'forId'
@@ -69,6 +70,8 @@ export class PktInputWrapper extends PktElementWithSlot<Props> {
69
70
  @property({ type: String, reflect: true }) role: string | null = 'group'
70
71
  @property({ type: Boolean, reflect: true, converter: booleanishConverter })
71
72
  useWrapper: Booleanish = specs.props.useWrapper.default
73
+ @property({ type: String, attribute: 'input-wrapper-size' })
74
+ size: TInputWrapperSize = 'medium'
72
75
 
73
76
  protected updated(changedProperties: PropertyValues): void {
74
77
  super.updated(changedProperties)
@@ -86,6 +89,8 @@ export class PktInputWrapper extends PktElementWithSlot<Props> {
86
89
  'pkt-inputwrapper--error': this.hasError,
87
90
  'pkt-inputwrapper--disabled': this.disabled,
88
91
  'pkt-inputwrapper--inline': this.inline,
92
+ 'pkt-inputwrapper--small': this.size === 'small',
93
+ 'pkt-inputwrapper--xsmall': this.size === 'xsmall',
89
94
  }
90
95
 
91
96
  const tagClasses = 'pkt-tag pkt-tag--small pkt-tag--thin-text'
@@ -172,15 +177,18 @@ export class PktInputWrapper extends PktElementWithSlot<Props> {
172
177
 
173
178
  const errorElement = () => {
174
179
  if (this.hasError && this.errorMessage) {
175
- return html`<pkt-alert
176
- skin="error"
177
- compact
178
- id=${`${this.forId}-error`}
179
- aria-live="assertive"
180
- aria-atomic="true"
181
- >
182
- ${unsafeHTML(this.errorMessage)}
183
- </pkt-alert>`
180
+ const errorAlertSize = this.size === 'medium' ? 'small' : 'xsmall'
181
+ return html`<div class="pkt-inputwrapper__alert-wrapper">
182
+ <pkt-alert
183
+ skin="error"
184
+ .size=${errorAlertSize}
185
+ id=${`${this.forId}-error`}
186
+ aria-live="assertive"
187
+ aria-atomic="true"
188
+ >
189
+ ${unsafeHTML(this.errorMessage)}
190
+ </pkt-alert>
191
+ </div>`
184
192
  } else {
185
193
  return nothing
186
194
  }
@@ -7,11 +7,10 @@ import { ref } from 'lit/directives/ref.js'
7
7
  import { Ref, createRef } from 'lit/directives/ref.js'
8
8
  import { TPktSize } from '@/types/size'
9
9
  import {
10
- acquireModalScrollLock,
11
- createModalScrollLockToken,
12
- releaseModalScrollLock,
13
- type ModalScrollLockToken,
14
- } from 'shared-utils/modal-scroll-lock'
10
+ createScrollLockToken,
11
+ setScrollLock,
12
+ type ScrollLockToken,
13
+ } from 'shared-utils/scroll-lock'
15
14
  import specs from 'componentSpecs/modal.json'
16
15
  import '@/components/icon'
17
16
 
@@ -46,7 +45,7 @@ export class PktModal extends PktElementWithSlot implements IPktModal {
46
45
 
47
46
  dialogRef: Ref<HTMLDialogElement> = createRef()
48
47
 
49
- private _scrollLockToken: ModalScrollLockToken = createModalScrollLockToken()
48
+ private _scrollLockToken: ScrollLockToken = createScrollLockToken()
50
49
 
51
50
  @state() _isOpen: boolean = false
52
51
 
@@ -66,7 +65,7 @@ export class PktModal extends PktElementWithSlot implements IPktModal {
66
65
  document.removeEventListener('keydown', this.handleKeyDown)
67
66
  document.removeEventListener('click', this.handleBackdropClick)
68
67
  // Rydd opp scroll-lock hvis elementet fjernes fra DOM mens det er åpent
69
- releaseModalScrollLock(this._scrollLockToken)
68
+ setScrollLock(this._scrollLockToken, false)
70
69
  }
71
70
 
72
71
  protected updated(changedProperties: PropertyValues): void {
@@ -122,7 +121,7 @@ export class PktModal extends PktElementWithSlot implements IPktModal {
122
121
  this._isOpen = false
123
122
  this.open = false
124
123
 
125
- releaseModalScrollLock(this._scrollLockToken)
124
+ setScrollLock(this._scrollLockToken, false)
126
125
 
127
126
  // Scroll to the opener element if it's not in the viewport
128
127
  const openerElement = document.activeElement as HTMLElement
@@ -143,7 +142,7 @@ export class PktModal extends PktElementWithSlot implements IPktModal {
143
142
  this.open = true
144
143
  this.dialogRef.value?.showModal()
145
144
 
146
- acquireModalScrollLock(this._scrollLockToken)
145
+ setScrollLock(this._scrollLockToken, true)
147
146
  this.dispatchEvent(
148
147
  new CustomEvent('showModal', { detail: { origin: event }, bubbles: true, composed: true }),
149
148
  )
@@ -13,6 +13,7 @@ interface SearchInputTestConfig extends BaseTestConfig {
13
13
  id?: string
14
14
  name?: string
15
15
  appearance?: 'local' | 'local-with-button' | 'global'
16
+ inputSize?: 'small' | 'medium' | 'xsmall'
16
17
  label?: string
17
18
  placeholder?: string
18
19
  disabled?: boolean
@@ -61,6 +62,20 @@ describe('PktSearchInput', () => {
61
62
  })
62
63
  })
63
64
 
65
+ describe('aria-label', () => {
66
+ test('flytter host aria-label til input og fjerner den fra verten', async () => {
67
+ const el = document.createElement('pkt-searchinput') as CustomElementFor<'pkt-searchinput'>
68
+ el.id = 'aria-search'
69
+ el.setAttribute('aria-label', 'Søk i hele nettstedet')
70
+ document.body.appendChild(el)
71
+ await el.updateComplete
72
+
73
+ expect(el.hasAttribute('aria-label')).toBe(false)
74
+ const input = el.querySelector('input')
75
+ expect(input).toHaveAttribute('aria-label', 'Søk i hele nettstedet')
76
+ })
77
+ })
78
+
64
79
  describe('label', () => {
65
80
  test('renders label when label exists', async () => {
66
81
  const { searchinput } = await createSearchInputTest({ label: 'Test Label' })
@@ -134,6 +149,34 @@ describe('PktSearchInput', () => {
134
149
  expect(lwb.querySelector('.pkt-searchinput__field')).toBeTruthy()
135
150
  expect(lwb.querySelector('.pkt-input__container')).toBeNull()
136
151
  })
152
+
153
+ test('applies inputSize classes on wrapper, input, button and container', async () => {
154
+ const { searchinput } = await createSearchInputTest({
155
+ appearance: 'local-with-button',
156
+ })
157
+ searchinput.inputSize = 'small'
158
+ await searchinput.updateComplete
159
+
160
+ const wrapper = searchinput.querySelector('.pkt-searchinput')
161
+ expect(wrapper?.classList.contains('pkt-searchinput--small')).toBe(true)
162
+
163
+ const input = searchinput.querySelector('input')
164
+ expect(input?.classList.contains('pkt-input--small')).toBe(true)
165
+
166
+ const button = searchinput.querySelector('.pkt-searchinput__button')
167
+ expect(button?.classList.contains('pkt-btn--small')).toBe(true)
168
+ })
169
+
170
+ test('local inputSize applies container modifier', async () => {
171
+ const { searchinput } = await createSearchInputTest({
172
+ appearance: 'local',
173
+ })
174
+ searchinput.inputSize = 'xsmall'
175
+ await searchinput.updateComplete
176
+
177
+ const container = searchinput.querySelector('.pkt-input__container')
178
+ expect(container?.classList.contains('pkt-input__container--xsmall')).toBe(true)
179
+ })
137
180
  })
138
181
 
139
182
  describe('pkt-search', () => {