@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
@@ -0,0 +1,4 @@
1
+ export { PktFooter } from './footer'
2
+ export { PktFooterSimple } from './footer-simple'
3
+
4
+ export type { IPktFooter, IPktFooterSimple, IPktFooterColumn, IPktFooterLink } from './types'
@@ -0,0 +1,21 @@
1
+ import type { Booleanish, IPktFooterBase, IPktFooterColumn, IPktFooterLink } from 'shared-types'
2
+
3
+ export type { IPktFooterColumn, IPktFooterLink }
4
+
5
+ /** Booleanish variant of the shared base props for Lit attribute handling. */
6
+ type TFooterBooleanishFields = 'skipGlobal' | 'openLinksInNewTab' | 'includeConsent' | 'devMode'
7
+
8
+ export interface IPktFooterElementBase extends Omit<IPktFooterBase, TFooterBooleanishFields> {
9
+ skipGlobal?: Booleanish
10
+ openLinksInNewTab?: Booleanish
11
+ includeConsent?: Booleanish
12
+ devMode?: Booleanish
13
+ }
14
+
15
+ export interface IPktFooter extends IPktFooterElementBase {
16
+ columns?: IPktFooterColumn[]
17
+ }
18
+
19
+ export interface IPktFooterSimple extends IPktFooterElementBase {
20
+ links?: IPktFooterLink[]
21
+ }
@@ -0,0 +1,123 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit'
2
+ import {
3
+ createScrollLockToken,
4
+ setScrollLock as applyScrollLock,
5
+ type ScrollLockToken,
6
+ } from 'shared-utils/scroll-lock'
7
+ import type { THeaderPosition, THeaderScrollBehavior } from './types'
8
+
9
+ export interface HeaderChromeOptions {
10
+ mobileBreakpoint: number
11
+ tabletBreakpoint: number
12
+ position: THeaderPosition
13
+ scrollBehavior: THeaderScrollBehavior
14
+ }
15
+
16
+ /**
17
+ * Shared header shell logic for `pkt-header-service` and `pkt-header-global`:
18
+ * responsive width measurement (ResizeObserver → `isMobile`/`isTablet`),
19
+ * scroll-to-hide (`isHidden`), body scroll-lock, and focus return when a menu
20
+ * closes.
21
+ *
22
+ * The menu state machine, click-outside handling and the scroll-lock
23
+ * *condition* stay in each element since they differ between the variants.
24
+ */
25
+ export class HeaderChromeController implements ReactiveController {
26
+ private host: ReactiveControllerHost
27
+ private getOptions: () => HeaderChromeOptions
28
+
29
+ isMobile = false
30
+ isTablet = false
31
+ isHidden = false
32
+ /** When true, scroll-to-hide is paused (e.g. while a menu/overlay is open). */
33
+ pauseScrollHide = false
34
+
35
+ private componentWidth = typeof window !== 'undefined' ? window.innerWidth : 0
36
+ private resizeObserver?: ResizeObserver
37
+ private lastScrollPosition = 0
38
+ private scrollLockToken: ScrollLockToken = createScrollLockToken()
39
+ private lastFocusedElement: HTMLElement | null = null
40
+ private shouldRestoreFocus = false
41
+
42
+ constructor(host: ReactiveControllerHost, getOptions: () => HeaderChromeOptions) {
43
+ this.host = host
44
+ this.getOptions = getOptions
45
+ host.addController(this)
46
+ }
47
+
48
+ hostConnected(): void {
49
+ window.addEventListener('scroll', this.handleScroll)
50
+ }
51
+
52
+ hostDisconnected(): void {
53
+ this.resizeObserver?.disconnect()
54
+ window.removeEventListener('scroll', this.handleScroll)
55
+ applyScrollLock(this.scrollLockToken, false)
56
+ }
57
+
58
+ /** Start measuring the header element's width. Call from `firstUpdated`. */
59
+ observe(element: HTMLElement | undefined): void {
60
+ if (!element) return
61
+ this.componentWidth = element.offsetWidth
62
+ this.refreshBreakpoints()
63
+ this.resizeObserver = new ResizeObserver((entries) => {
64
+ for (const entry of entries) {
65
+ this.componentWidth =
66
+ entry.borderBoxSize && entry.borderBoxSize.length > 0
67
+ ? entry.borderBoxSize[0].inlineSize
68
+ : entry.contentRect.width
69
+ this.refreshBreakpoints()
70
+ }
71
+ })
72
+ this.resizeObserver.observe(element)
73
+ }
74
+
75
+ /** Recompute `isMobile`/`isTablet` (e.g. when the breakpoints change). */
76
+ refreshBreakpoints(): void {
77
+ const { mobileBreakpoint, tabletBreakpoint } = this.getOptions()
78
+ const isMobile = this.componentWidth < mobileBreakpoint
79
+ const isTablet = this.componentWidth < tabletBreakpoint
80
+ if (isMobile !== this.isMobile || isTablet !== this.isTablet) {
81
+ this.isMobile = isMobile
82
+ this.isTablet = isTablet
83
+ this.host.requestUpdate()
84
+ }
85
+ }
86
+
87
+ private handleScroll = (): void => {
88
+ if (this.getOptions().scrollBehavior !== 'hide' || this.pauseScrollHide) return
89
+ const currentScrollPosition = window.pageYOffset || document.documentElement.scrollTop
90
+ if (currentScrollPosition < 0) return
91
+ if (Math.abs(currentScrollPosition - this.lastScrollPosition) < 60) return
92
+ const hidden = currentScrollPosition > this.lastScrollPosition
93
+ this.lastScrollPosition = currentScrollPosition
94
+ if (hidden !== this.isHidden) {
95
+ this.isHidden = hidden
96
+ this.host.requestUpdate()
97
+ }
98
+ }
99
+
100
+ setScrollLock(shouldLock: boolean): void {
101
+ applyScrollLock(this.scrollLockToken, shouldLock)
102
+ }
103
+
104
+ captureFocus(): void {
105
+ this.lastFocusedElement = document.activeElement as HTMLElement
106
+ }
107
+
108
+ flagRestoreFocus(): void {
109
+ this.shouldRestoreFocus = true
110
+ }
111
+
112
+ restoreFocus(): void {
113
+ if (
114
+ this.shouldRestoreFocus &&
115
+ this.lastFocusedElement &&
116
+ document.contains(this.lastFocusedElement)
117
+ ) {
118
+ this.lastFocusedElement.focus()
119
+ }
120
+ this.lastFocusedElement = null
121
+ this.shouldRestoreFocus = false
122
+ }
123
+ }
@@ -0,0 +1,110 @@
1
+ import '@testing-library/jest-dom'
2
+ import { axe, toHaveNoViolations } from 'jest-axe'
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
4
+
5
+ import './header-global'
6
+ import { PktHeaderGlobal } from './header-global'
7
+ import { EXAMPLE_HEADER_FOOTER_DATA } from 'shared-utils/header-footer'
8
+
9
+ expect.extend(toHaveNoViolations)
10
+
11
+ beforeEach(() => {
12
+ Object.defineProperty(window, 'matchMedia', {
13
+ writable: true,
14
+ value: vi.fn().mockImplementation((query) => ({
15
+ matches: false,
16
+ media: query,
17
+ onchange: null,
18
+ addListener: vi.fn(),
19
+ removeListener: vi.fn(),
20
+ addEventListener: vi.fn(),
21
+ removeEventListener: vi.fn(),
22
+ dispatchEvent: vi.fn(),
23
+ })),
24
+ })
25
+ global.ResizeObserver = class ResizeObserver {
26
+ observe() {}
27
+ unobserve() {}
28
+ disconnect() {}
29
+ }
30
+ window.scrollTo = vi.fn()
31
+ })
32
+
33
+ afterEach(() => {
34
+ document.body.innerHTML = ''
35
+ vi.restoreAllMocks()
36
+ vi.unstubAllGlobals()
37
+ })
38
+
39
+ // jsdom reports offsetWidth as 0 (compact). Zero the breakpoints to force the
40
+ // full desktop layout; omit to exercise the compact/mobile layout.
41
+ const DESKTOP = { mobileBreakpoint: 0, tabletBreakpoint: 0 }
42
+
43
+ const mount = async (props: Record<string, unknown> = {}): Promise<PktHeaderGlobal> => {
44
+ const el = document.createElement('pkt-header-global') as PktHeaderGlobal
45
+ el.data = EXAMPLE_HEADER_FOOTER_DATA
46
+ el.position = 'relative'
47
+ Object.assign(el, props)
48
+ document.body.appendChild(el)
49
+ await customElements.whenDefined('pkt-header-global')
50
+ await el.updateComplete
51
+ await new Promise((resolve) => setTimeout(resolve, 0))
52
+ await el.updateComplete
53
+ return el
54
+ }
55
+
56
+ const clickHost = async (el: PktHeaderGlobal, selector: string) => {
57
+ el.querySelector<HTMLElement>(selector)?.click()
58
+ await el.updateComplete
59
+ await new Promise((resolve) => setTimeout(resolve, 0))
60
+ await el.updateComplete
61
+ }
62
+
63
+ describe('pkt-header-global – tilgjengelighet', () => {
64
+ test('ingen brudd i standardvisning (desktop)', async () => {
65
+ const el = await mount(DESKTOP)
66
+ expect(await axe(el)).toHaveNoViolations()
67
+ })
68
+
69
+ test('ingen brudd i kompakt visning (mobil)', async () => {
70
+ const el = await mount()
71
+ expect(await axe(el)).toHaveNoViolations()
72
+ })
73
+
74
+ test('ingen brudd med innlogget bruker', async () => {
75
+ const el = await mount({ ...DESKTOP, user: { name: 'Aksel Web Opdahl' } })
76
+ expect(await axe(el)).toHaveNoViolations()
77
+ })
78
+
79
+ test('ingen brudd med åpen megameny', async () => {
80
+ const el = await mount(DESKTOP)
81
+ await clickHost(el, '#pkt-header-global-menu-toggle')
82
+ expect(await axe(el.parentElement as HTMLElement)).toHaveNoViolations()
83
+ })
84
+
85
+ test('ingen brudd med åpent søke-overlay', async () => {
86
+ const el = await mount()
87
+ await clickHost(el, '#pkt-header-global-search-toggle')
88
+ expect(await axe(el.parentElement as HTMLElement)).toHaveNoViolations()
89
+ })
90
+
91
+ test('ingen brudd med åpen brukermeny', async () => {
92
+ const el = await mount({ ...DESKTOP, user: { name: 'Aksel Web Opdahl' } })
93
+ await clickHost(el, '.pkt-header-global__user-button')
94
+ expect(await axe(el)).toHaveNoViolations()
95
+ })
96
+
97
+ test('ingen brudd når menydata feiler', async () => {
98
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
99
+ const el = document.createElement('pkt-header-global') as PktHeaderGlobal
100
+ el.position = 'relative'
101
+ Object.assign(el, DESKTOP)
102
+ document.body.appendChild(el)
103
+ await customElements.whenDefined('pkt-header-global')
104
+ await el.updateComplete
105
+ await new Promise((resolve) => setTimeout(resolve, 0))
106
+ await el.updateComplete
107
+ await clickHost(el, '#pkt-header-global-menu-toggle')
108
+ expect(await axe(el.parentElement as HTMLElement)).toHaveNoViolations()
109
+ })
110
+ })
@@ -0,0 +1,329 @@
1
+ import '@testing-library/jest-dom'
2
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
3
+
4
+ import './header-global'
5
+ import { PktHeaderGlobal } from './header-global'
6
+ import { EXAMPLE_HEADER_FOOTER_DATA } from 'shared-utils/header-footer'
7
+
8
+ beforeEach(() => {
9
+ Object.defineProperty(window, 'matchMedia', {
10
+ writable: true,
11
+ value: vi.fn().mockImplementation((query) => ({
12
+ matches: false,
13
+ media: query,
14
+ onchange: null,
15
+ addListener: vi.fn(),
16
+ removeListener: vi.fn(),
17
+ addEventListener: vi.fn(),
18
+ removeEventListener: vi.fn(),
19
+ dispatchEvent: vi.fn(),
20
+ })),
21
+ })
22
+ global.ResizeObserver = class ResizeObserver {
23
+ observe() {}
24
+ unobserve() {}
25
+ disconnect() {}
26
+ }
27
+ window.scrollTo = vi.fn()
28
+ })
29
+
30
+ const waitForReady = async () => {
31
+ await customElements.whenDefined('pkt-header-global')
32
+ }
33
+
34
+ const mount = async (props: Record<string, unknown> = {}): Promise<PktHeaderGlobal> => {
35
+ const el = document.createElement('pkt-header-global') as PktHeaderGlobal
36
+ el.data = EXAMPLE_HEADER_FOOTER_DATA
37
+ el.position = 'relative'
38
+ Object.assign(el, props)
39
+ document.body.appendChild(el)
40
+ await waitForReady()
41
+ await el.updateComplete
42
+ await new Promise((resolve) => setTimeout(resolve, 0))
43
+ await el.updateComplete
44
+ return el
45
+ }
46
+
47
+ const clickHost = async (el: PktHeaderGlobal, selector: string) => {
48
+ const host = el.querySelector<HTMLElement>(selector)
49
+ host?.click()
50
+ await el.updateComplete
51
+ await new Promise((resolve) => setTimeout(resolve, 0))
52
+ await el.updateComplete
53
+ }
54
+
55
+ afterEach(() => {
56
+ document.body.innerHTML = ''
57
+ vi.restoreAllMocks()
58
+ vi.unstubAllGlobals()
59
+ })
60
+
61
+ // jsdom reports offsetWidth as 0, which puts the header in compact mode.
62
+ // Force the desktop (full) layout by zeroing the breakpoints.
63
+ const DESKTOP = { mobileBreakpoint: 0, tabletBreakpoint: 0 }
64
+
65
+ describe('pkt-header-global', () => {
66
+ test('viser logo, søkefelt, kontaktlenke og menyknapp (desktop)', async () => {
67
+ const el = await mount(DESKTOP)
68
+
69
+ expect(el.querySelector('.pkt-header-global__logo pkt-icon')).toBeInTheDocument()
70
+
71
+ const searchInput = el.querySelector<HTMLInputElement>('input[name="q"]')
72
+ expect(searchInput).toBeInTheDocument()
73
+
74
+ const contact = el.querySelector<HTMLAnchorElement>('.pkt-header-global__contact')
75
+ expect(contact).toBeInTheDocument()
76
+ expect(contact).toHaveAttribute('href', 'https://www.oslo.kommune.no/kontakt')
77
+
78
+ expect(el.querySelector('#pkt-header-global-menu-toggle')).toBeInTheDocument()
79
+ })
80
+
81
+ test('skjuler kontaktlenken når bruker er innlogget', async () => {
82
+ const el = await mount({ ...DESKTOP, user: { name: 'Aksel Web Opdahl' } })
83
+ expect(el.querySelector('.pkt-header-global__contact')).not.toBeInTheDocument()
84
+ expect(el.querySelector('.pkt-header-global__user-button')).toBeInTheDocument()
85
+ })
86
+
87
+ test('megameny-overlegget forankres til headerens faktiske posisjon', async () => {
88
+ const el = await mount({ ...DESKTOP, position: 'relative' })
89
+ const header = el.querySelector('.pkt-header-global') as HTMLElement
90
+ // Simulate a header sitting 32px down the page (relative/sticky position).
91
+ header.getBoundingClientRect = () =>
92
+ ({ bottom: 120, top: 32, left: 16, right: 1296, width: 1280, height: 88 }) as DOMRect
93
+
94
+ await clickHost(el, '#pkt-header-global-menu-toggle')
95
+
96
+ const overlay = el.querySelector<HTMLElement>('#pkt-header-global-menu')
97
+ // The CSS adds the design offset (104/88px) on top of this anchor.
98
+ expect(overlay?.style.getPropertyValue('--pkt-header-global-top')).toBe('32px')
99
+ expect(overlay?.style.getPropertyValue('--pkt-header-global-left')).toBe('16px')
100
+ expect(overlay?.style.getPropertyValue('--pkt-header-global-width')).toBe('1280px')
101
+ })
102
+
103
+ test('position="sticky" gir sticky-klasse uten wrapper/spacer', async () => {
104
+ const el = await mount({ ...DESKTOP, position: 'sticky' })
105
+ const header = el.querySelector('.pkt-header-global')
106
+ expect(header?.classList.contains('pkt-header-global--sticky')).toBe(true)
107
+ expect(header?.classList.contains('pkt-header-global--fixed')).toBe(false)
108
+ expect(el.querySelector('.pkt-header-global-wrapper')).toBeNull()
109
+ expect(el.querySelector('.pkt-header-global-spacer')).toBeNull()
110
+ })
111
+
112
+ test('logoen lenker til www.oslo.kommune.no som standard', async () => {
113
+ const el = await mount(DESKTOP)
114
+ const logo = el.querySelector('.pkt-header-global__logo a')
115
+ expect(logo?.getAttribute('href')).toBe('https://www.oslo.kommune.no')
116
+ })
117
+
118
+ test('sender logo-click-event når logoen klikkes', async () => {
119
+ const el = await mount(DESKTOP)
120
+ // preventDefault - jsdom kan ikke navigere
121
+ const eventSpy = vi.fn()
122
+ el.addEventListener('logo-click', (e: Event) => {
123
+ ;(e as CustomEvent<{ originalEvent: Event }>).detail.originalEvent.preventDefault()
124
+ eventSpy(e)
125
+ })
126
+
127
+ el.querySelector<HTMLElement>('.pkt-header-global__logo a')?.dispatchEvent(
128
+ new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }),
129
+ )
130
+
131
+ expect(eventSpy).toHaveBeenCalledTimes(1)
132
+ expect(eventSpy.mock.calls[0][0].detail.originalEvent).toBeInstanceOf(MouseEvent)
133
+ })
134
+
135
+ test('søkefeltet er en GET-form mot søke-endepunktet (desktop)', async () => {
136
+ const el = await mount(DESKTOP)
137
+ const form = el.querySelector<HTMLFormElement>('form[role="search"]')
138
+ expect(form).toBeInTheDocument()
139
+ expect(form).toHaveAttribute('action', 'https://www.oslo.kommune.no/sokeresultat/')
140
+ expect(form).toHaveAttribute('method', 'get')
141
+ })
142
+
143
+ test('mobil: søkeknappen åpner et søkefelt-overlay', async () => {
144
+ // Default breakpoints + jsdom offsetWidth 0 → compact layout.
145
+ const el = await mount()
146
+ // Inline search is replaced by a round toggle; no search field yet.
147
+ expect(el.querySelector('.pkt-header-global__search-panel')).not.toBeInTheDocument()
148
+ expect(el.querySelector('#pkt-header-global-search-toggle')).toBeInTheDocument()
149
+
150
+ await clickHost(el, '#pkt-header-global-search-toggle')
151
+
152
+ const panel = el.querySelector('.pkt-header-global__search-panel')
153
+ expect(panel).toBeInTheDocument()
154
+ expect(panel?.querySelector('input[name="q"]')).toBeInTheDocument()
155
+ })
156
+
157
+ test('mobil: åpning av søk gir fokus til søkefeltet', async () => {
158
+ const el = await mount()
159
+ await clickHost(el, '#pkt-header-global-search-toggle')
160
+ // Focus is moved on the next animation frame.
161
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)))
162
+
163
+ const input = el.querySelector<HTMLInputElement>('#pkt-header-global-search-panel input')
164
+ expect(input).toBeTruthy()
165
+ expect(document.activeElement).toBe(input)
166
+ })
167
+
168
+ test('mobil: Tab ut av søke-overlay lukker det og flytter fokus til søkeknappen', async () => {
169
+ const el = await mount()
170
+ await clickHost(el, '#pkt-header-global-search-toggle')
171
+
172
+ const panel = el.querySelector<HTMLElement>('#pkt-header-global-search-panel')
173
+ const focusables = panel?.querySelectorAll<HTMLElement>('input, button')
174
+ const last = focusables?.[focusables.length - 1]
175
+ last?.focus()
176
+
177
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
178
+ await el.updateComplete
179
+
180
+ expect(el.querySelector('.pkt-header-global__search-panel')).not.toBeInTheDocument()
181
+ })
182
+
183
+ test('mobil: fokus utenfor søke-overlay lukker det', async () => {
184
+ const el = await mount()
185
+ await clickHost(el, '#pkt-header-global-search-toggle')
186
+ expect(el.querySelector('.pkt-header-global__search-panel')).toBeInTheDocument()
187
+
188
+ const menuToggle = el.querySelector<HTMLElement>('#pkt-header-global-menu-toggle button')
189
+ menuToggle?.focus()
190
+ menuToggle?.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
191
+ await el.updateComplete
192
+
193
+ expect(el.querySelector('.pkt-header-global__search-panel')).not.toBeInTheDocument()
194
+ })
195
+
196
+ test('mobil: åpning av søk lukker megamenyen', async () => {
197
+ const el = await mount()
198
+ await clickHost(el, '#pkt-header-global-menu-toggle')
199
+ expect(el.querySelector('.pkt-header-global__menu')).toBeInTheDocument()
200
+
201
+ await clickHost(el, '#pkt-header-global-search-toggle')
202
+ expect(el.querySelector('.pkt-header-global__menu')).not.toBeInTheDocument()
203
+ expect(el.querySelector('.pkt-header-global__search-panel')).toBeInTheDocument()
204
+ })
205
+
206
+ test('menyknappen åpner megamenyen', async () => {
207
+ const el = await mount()
208
+ expect(el.querySelector('.pkt-header-global__menu')).not.toBeInTheDocument()
209
+ const innerToggle = el.querySelector('#pkt-header-global-menu-toggle button')
210
+ expect(innerToggle).toHaveAttribute('aria-expanded', 'false')
211
+
212
+ await clickHost(el, '#pkt-header-global-menu-toggle')
213
+
214
+ const menu = el.querySelector('.pkt-header-global__menu pkt-header-menu')
215
+ expect(menu).toBeInTheDocument()
216
+ expect(menu).toHaveAttribute('open')
217
+ expect(innerToggle).toHaveAttribute('aria-expanded', 'true')
218
+ })
219
+
220
+ test('tastatur: Tab fra menyknappen flytter fokus inn i megamenyen', async () => {
221
+ const el = await mount(DESKTOP)
222
+ await clickHost(el, '#pkt-header-global-menu-toggle')
223
+
224
+ const toggleButton = el.querySelector<HTMLElement>('#pkt-header-global-menu-toggle button')
225
+ toggleButton?.focus()
226
+
227
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
228
+
229
+ const menu = el.querySelector<HTMLElement>('.pkt-header-global__menu')
230
+ const firstFocusable = menu?.querySelector<HTMLElement>('a[href], button')
231
+ expect(firstFocusable).toBeTruthy()
232
+ expect(document.activeElement).toBe(firstFocusable)
233
+ })
234
+
235
+ test('mobil: Tab fra menyknappen flytter fokus inn i megamenyen (accordion)', async () => {
236
+ // Force the mega menu into its mobile accordion layout.
237
+ ;(window.matchMedia as unknown) = vi.fn().mockImplementation((query) => ({
238
+ matches: true,
239
+ media: query,
240
+ onchange: null,
241
+ addListener: vi.fn(),
242
+ removeListener: vi.fn(),
243
+ addEventListener: vi.fn(),
244
+ removeEventListener: vi.fn(),
245
+ dispatchEvent: vi.fn(),
246
+ }))
247
+ const el = await mount()
248
+ await clickHost(el, '#pkt-header-global-menu-toggle')
249
+
250
+ const toggleButton = el.querySelector<HTMLElement>('#pkt-header-global-menu-toggle button')
251
+ toggleButton?.focus()
252
+
253
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
254
+
255
+ const menu = el.querySelector<HTMLElement>('.pkt-header-global__menu')
256
+ const firstSummary = menu?.querySelector('summary')
257
+ expect(firstSummary).toBeTruthy()
258
+ expect(document.activeElement).toBe(firstSummary)
259
+ })
260
+
261
+ test('tastatur: Tab ut av megamenyen lukker den og flytter fokus til menyknappen', async () => {
262
+ const el = await mount(DESKTOP)
263
+ await clickHost(el, '#pkt-header-global-menu-toggle')
264
+
265
+ const menu = el.querySelector<HTMLElement>('.pkt-header-global__menu')
266
+ const focusables = menu?.querySelectorAll<HTMLElement>('a[href], button')
267
+ const last = focusables?.[focusables.length - 1]
268
+ last?.focus()
269
+
270
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
271
+ await el.updateComplete
272
+
273
+ expect(el.querySelector('.pkt-header-global__menu')).not.toBeInTheDocument()
274
+ const toggleButton = el.querySelector<HTMLElement>('#pkt-header-global-menu-toggle button')
275
+ expect(document.activeElement).toBe(toggleButton)
276
+ })
277
+
278
+ test('tastatur: fokus utenfor megamenyen lukker den', async () => {
279
+ const el = await mount(DESKTOP)
280
+ await clickHost(el, '#pkt-header-global-menu-toggle')
281
+ expect(el.querySelector('.pkt-header-global__menu')).toBeInTheDocument()
282
+
283
+ const contact = el.querySelector<HTMLElement>('.pkt-header-global__contact')
284
+ contact?.focus()
285
+ contact?.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
286
+ await el.updateComplete
287
+
288
+ expect(el.querySelector('.pkt-header-global__menu')).not.toBeInTheDocument()
289
+ })
290
+
291
+ test('brukerknappen åpner brukermenyen', async () => {
292
+ const el = await mount({ user: { name: 'Aksel Web Opdahl' } })
293
+ expect(el.querySelector('pkt-header-user-menu')).not.toBeInTheDocument()
294
+
295
+ await clickHost(el, '.pkt-header-global__user-button')
296
+
297
+ expect(el.querySelector('pkt-header-user-menu')).toBeInTheDocument()
298
+ })
299
+
300
+ test('viser feilmelding i menyen når data ikke kan lastes', async () => {
301
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
302
+ const el = document.createElement('pkt-header-global') as PktHeaderGlobal
303
+ el.position = 'relative'
304
+ Object.assign(el, DESKTOP)
305
+ document.body.appendChild(el)
306
+ await waitForReady()
307
+ await el.updateComplete
308
+ await new Promise((resolve) => setTimeout(resolve, 0))
309
+ await el.updateComplete
310
+
311
+ await clickHost(el, '#pkt-header-global-menu-toggle')
312
+
313
+ const error = el.querySelector('.pkt-header-menu__error')
314
+ expect(error).toBeInTheDocument()
315
+ expect(error?.textContent).toContain('Kunne ikke laste meny.')
316
+ })
317
+
318
+ test('sender data-loaded når data er forhåndshentet', async () => {
319
+ const onLoaded = vi.fn()
320
+ const el = document.createElement('pkt-header-global') as PktHeaderGlobal
321
+ el.addEventListener('data-loaded', onLoaded)
322
+ el.data = EXAMPLE_HEADER_FOOTER_DATA
323
+ el.position = 'relative'
324
+ document.body.appendChild(el)
325
+ await waitForReady()
326
+ await el.updateComplete
327
+ expect(onLoaded).toHaveBeenCalled()
328
+ })
329
+ })