@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,79 @@
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-service'
6
+ import type { PktHeaderService } from './header-service'
7
+
8
+ expect.extend(toHaveNoViolations)
9
+
10
+ beforeEach(() => {
11
+ Object.defineProperty(window, 'matchMedia', {
12
+ writable: true,
13
+ value: vi.fn().mockImplementation((query) => ({
14
+ matches: false,
15
+ media: query,
16
+ onchange: null,
17
+ addListener: vi.fn(),
18
+ removeListener: vi.fn(),
19
+ addEventListener: vi.fn(),
20
+ removeEventListener: vi.fn(),
21
+ dispatchEvent: vi.fn(),
22
+ })),
23
+ })
24
+ global.ResizeObserver = class ResizeObserver {
25
+ observe() {}
26
+ unobserve() {}
27
+ disconnect() {}
28
+ }
29
+ window.scrollTo = vi.fn()
30
+ })
31
+
32
+ afterEach(() => {
33
+ document.body.innerHTML = ''
34
+ vi.restoreAllMocks()
35
+ })
36
+
37
+ const mount = async (
38
+ props: Record<string, unknown> = {},
39
+ innerHTML = '',
40
+ ): Promise<PktHeaderService> => {
41
+ const el = document.createElement('pkt-header-service') as PktHeaderService
42
+ el.setAttribute('service-name', 'Min tjeneste')
43
+ el.position = 'relative'
44
+ Object.assign(el, props)
45
+ if (innerHTML) el.innerHTML = innerHTML
46
+ document.body.appendChild(el)
47
+ await customElements.whenDefined('pkt-header-service')
48
+ await el.updateComplete
49
+ await new Promise((resolve) => setTimeout(resolve, 0))
50
+ await el.updateComplete
51
+ return el
52
+ }
53
+
54
+ // offsetWidth is 0 in jsdom. Zero both breakpoints → desktop; zero only the
55
+ // mobile breakpoint (tablet stays positive) → tablet (menu collapses to a button).
56
+ const DESKTOP = { mobileBreakpoint: 0, tabletBreakpoint: 0 }
57
+ const TABLET = { mobileBreakpoint: 0, tabletBreakpoint: 1280 }
58
+
59
+ describe('pkt-header-service – tilgjengelighet', () => {
60
+ test('ingen brudd i standardvisning (desktop)', async () => {
61
+ const el = await mount(DESKTOP, '<a href="#en">Lenke én</a><a href="#to">Lenke to</a>')
62
+ expect(await axe(el)).toHaveNoViolations()
63
+ })
64
+
65
+ test('ingen brudd med søk og innlogget bruker', async () => {
66
+ const el = await mount({ ...DESKTOP, showSearch: true, user: { name: 'Aksel Web Opdahl' } })
67
+ expect(await axe(el)).toHaveNoViolations()
68
+ })
69
+
70
+ test('ingen brudd med åpen brukermeny', async () => {
71
+ const el = await mount({ ...DESKTOP, user: { name: 'Aksel Web Opdahl' }, openedMenu: 'user' })
72
+ expect(await axe(el)).toHaveNoViolations()
73
+ })
74
+
75
+ test('ingen brudd i nettbrettvisning med åpen meny', async () => {
76
+ const el = await mount({ ...TABLET, openedMenu: 'slot' }, '<a href="#en">Lenke én</a>')
77
+ expect(await axe(el)).toHaveNoViolations()
78
+ })
79
+ })
@@ -21,7 +21,7 @@ export interface HeaderServiceTestConfig extends BaseTestConfig {
21
21
  'logo-link'?: string
22
22
  compact?: boolean
23
23
  'hide-logo'?: boolean
24
- position?: 'fixed' | 'relative'
24
+ position?: 'fixed' | 'sticky' | 'relative'
25
25
  'scroll-behavior'?: 'hide' | 'none'
26
26
  'show-search'?: boolean
27
27
  'search-placeholder'?: string
@@ -109,31 +109,38 @@ describe('pkt-header-service', () => {
109
109
  it('renders logo as link when logo-link is provided', async () => {
110
110
  const { element } = await createHeaderServiceTest({
111
111
  'service-name': 'Svc',
112
- 'logo-link': 'https://oslo.kommune.no',
112
+ 'logo-link': 'https://www.oslo.kommune.no',
113
113
  })
114
114
  const logoLink = element.querySelector('.pkt-header-service__logo a')
115
115
  expect(logoLink).toBeTruthy()
116
- expect(logoLink?.getAttribute('href')).toBe('https://oslo.kommune.no')
116
+ expect(logoLink?.getAttribute('href')).toBe('https://www.oslo.kommune.no')
117
117
  })
118
118
 
119
- it('dispatches logo-click event when logo button is clicked', async () => {
119
+ it('sender logo-click-event når logoen klikkes', async () => {
120
120
  const { element } = await createHeaderServiceTest({ 'service-name': 'Svc' })
121
121
 
122
+ // preventDefault - jsdom kan ikke navigere
122
123
  const eventSpy = vi.fn()
123
- element.addEventListener('logo-click', eventSpy)
124
+ element.addEventListener('logo-click', (e: Event) => {
125
+ ;(e as CustomEvent<{ originalEvent: Event }>).detail.originalEvent.preventDefault()
126
+ eventSpy(e)
127
+ })
124
128
 
125
- const logoButton = element.querySelector('.pkt-header-service__logo button')
129
+ const logoLink = element.querySelector('.pkt-header-service__logo a')
130
+ expect(logoLink).toBeTruthy()
131
+ logoLink!.dispatchEvent(
132
+ new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }),
133
+ )
126
134
 
127
- if (logoButton) {
128
- logoButton.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }))
129
- expect(eventSpy).toHaveBeenCalled()
130
- }
135
+ expect(eventSpy).toHaveBeenCalledTimes(1)
136
+ expect(eventSpy.mock.calls[0][0].detail.originalEvent).toBeInstanceOf(MouseEvent)
131
137
  })
132
138
 
133
- it('renders logo without link when logo-link is not provided', async () => {
139
+ it('lenker logoen til www.oslo.kommune.no som standard når logo-link ikke er oppgitt', async () => {
134
140
  const { element } = await createHeaderServiceTest({ 'service-name': 'Svc' })
135
141
  const logoLink = element.querySelector('.pkt-header-service__logo a')
136
- expect(logoLink).toBeNull()
142
+ expect(logoLink).toBeTruthy()
143
+ expect(logoLink?.getAttribute('href')).toBe('https://www.oslo.kommune.no')
137
144
  })
138
145
  })
139
146
 
@@ -187,6 +194,19 @@ describe('pkt-header-service', () => {
187
194
  expect(header?.classList.contains('pkt-header-service--fixed')).toBe(false)
188
195
  })
189
196
 
197
+ it('applies sticky class when position="sticky", without wrapper/spacer', async () => {
198
+ const { element } = await createHeaderServiceTest({
199
+ 'service-name': 'Svc',
200
+ position: 'sticky',
201
+ })
202
+ const header = element.querySelector('.pkt-header-service')
203
+ expect(header?.classList.contains('pkt-header-service--sticky')).toBe(true)
204
+ expect(header?.classList.contains('pkt-header-service--fixed')).toBe(false)
205
+ // Sticky stays in the document flow — no fixed-position wrapper or spacer.
206
+ expect(element.querySelector('.pkt-header-service-wrapper')).toBeNull()
207
+ expect(element.querySelector('.pkt-header-service-spacer')).toBeNull()
208
+ })
209
+
190
210
  it('applies scroll-to-hide class by default (scroll-behavior="hide")', async () => {
191
211
  const { element } = await createHeaderServiceTest({ 'service-name': 'Svc' })
192
212
  const header = element.querySelector('.pkt-header-service')
@@ -18,6 +18,8 @@ import {
18
18
  booleanishConverter,
19
19
  } from './types'
20
20
  import { formatLastLoggedIn } from './header-utils'
21
+ import { HeaderChromeController } from './header-chrome-controller'
22
+ import { DEFAULT_HEADER_LOGO_LINK } from 'shared-types'
21
23
 
22
24
  import '@/components/button'
23
25
  import '@/components/icon'
@@ -35,7 +37,7 @@ if (typeof window !== 'undefined') {
35
37
  export class PktHeaderService extends PktElementWithSlot<IPktHeader> implements IPktHeader {
36
38
  @property({ type: String, attribute: 'service-name' }) serviceName?: string
37
39
  @property({ type: String, attribute: 'service-link' }) serviceLink?: string
38
- @property({ type: String, attribute: 'logo-link' }) logoLink?: string
40
+ @property({ type: String, attribute: 'logo-link' }) logoLink: string = DEFAULT_HEADER_LOGO_LINK
39
41
  @property({ type: String, attribute: 'logo-path' }) logoPath: string | undefined =
40
42
  typeof window !== 'undefined' ? window.pktLogoPath : defaultLogoPath
41
43
  @property({ type: String, attribute: 'search-placeholder' }) searchPlaceholder = 'Søk'
@@ -70,11 +72,7 @@ export class PktHeaderService extends PktElementWithSlot<IPktHeader> implements
70
72
  @property({ type: Array, attribute: 'user-menu' }) userMenu?: UserMenuItem[]
71
73
  @property({ type: Object }) representing?: Representing
72
74
 
73
- @state() private isMobile = false
74
- @state() private isTablet = false
75
75
  @state() private openMenu: THeaderMenu = 'none'
76
- @state() private isHidden = false
77
- @state() private componentWidth = typeof window !== 'undefined' ? window.innerWidth : 0
78
76
  @state() private alignSlotRight = false
79
77
  @state() private alignSearchRight = false
80
78
 
@@ -85,26 +83,26 @@ export class PktHeaderService extends PktElementWithSlot<IPktHeader> implements
85
83
  private slotContentRef: Ref<HTMLElement> = createRef()
86
84
  private searchMenuRef: Ref<HTMLElement> = createRef()
87
85
 
88
- private resizeObserver?: ResizeObserver
89
- private lastScrollPosition = 0
90
- private savedScrollY = 0
91
- private lastFocusedElement: HTMLElement | null = null
92
- private shouldRestoreFocus = false
86
+ // Shared shell logic (breakpoints, scroll-to-hide, scroll lock, focus return).
87
+ private chrome = new HeaderChromeController(this, () => ({
88
+ mobileBreakpoint: this.mobileBreakpoint,
89
+ tabletBreakpoint: this.tabletBreakpoint,
90
+ position: this.position,
91
+ scrollBehavior: this.scrollBehavior,
92
+ }))
93
93
 
94
- connectedCallback() {
95
- super.connectedCallback()
96
- this.setupScrollListener()
94
+ private get isMobile(): boolean {
95
+ return this.chrome.isMobile
97
96
  }
98
-
99
- disconnectedCallback() {
100
- super.disconnectedCallback()
101
- this.resizeObserver?.disconnect()
102
- window.removeEventListener('scroll', this.handleScroll)
103
- this.unlockScroll()
97
+ private get isTablet(): boolean {
98
+ return this.chrome.isTablet
99
+ }
100
+ private get isHidden(): boolean {
101
+ return this.chrome.isHidden
104
102
  }
105
103
 
106
104
  firstUpdated() {
107
- this.setupResizeObserver()
105
+ this.chrome.observe(this.headerRef.value)
108
106
  }
109
107
 
110
108
  updated(changedProperties: PropertyValues) {
@@ -115,8 +113,7 @@ export class PktHeaderService extends PktElementWithSlot<IPktHeader> implements
115
113
  }
116
114
 
117
115
  if (changedProperties.has('mobileBreakpoint') || changedProperties.has('tabletBreakpoint')) {
118
- this.updateIsMobile()
119
- this.updateIsTablet()
116
+ this.chrome.refreshBreakpoints()
120
117
  }
121
118
 
122
119
  if (changedProperties.has('openMenu')) {
@@ -136,106 +133,15 @@ export class PktHeaderService extends PktElementWithSlot<IPktHeader> implements
136
133
  } else if (this.openMenu === 'none' && previousOpenMenu !== 'none') {
137
134
  document.removeEventListener('mousedown', this.handleClickOutside)
138
135
  document.removeEventListener('keydown', this.handleEscapeKey)
139
- this.restoreFocus()
136
+ this.chrome.restoreFocus()
140
137
  }
141
138
  }
142
139
 
143
- if (changedProperties.has('openMenu') || changedProperties.has('isMobile')) {
144
- this.updateScrollLock()
145
- }
146
- }
147
-
148
- private setupResizeObserver() {
149
- const headerElement = this.headerRef.value
150
- if (!headerElement) return
151
-
152
- this.componentWidth = headerElement.offsetWidth
153
- this.updateIsMobile()
154
- this.updateIsTablet()
155
-
156
- this.resizeObserver = new ResizeObserver((entries) => {
157
- for (const entry of entries) {
158
- if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
159
- this.componentWidth = entry.borderBoxSize[0].inlineSize
160
- } else {
161
- this.componentWidth = entry.contentRect.width
162
- }
163
- this.updateIsMobile()
164
- this.updateIsTablet()
165
- }
166
- })
167
- this.resizeObserver.observe(headerElement)
168
- }
169
-
170
- private updateIsMobile() {
171
- this.isMobile = this.componentWidth < this.mobileBreakpoint
172
- }
173
-
174
- private updateIsTablet() {
175
- this.isTablet = this.componentWidth < this.tabletBreakpoint
176
- }
177
-
178
- private updateScrollLock() {
179
- const shouldLock = this.position === 'fixed' && this.isMobile && this.openMenu !== 'none'
180
- if (shouldLock) {
181
- this.lockScroll()
182
- } else {
183
- this.unlockScroll()
184
- }
185
- }
186
-
187
- private lockScroll() {
188
- const body = document.body
189
- const docEl = document.documentElement
190
-
191
- this.savedScrollY = window.scrollY || window.pageYOffset
192
-
193
- const scrollBarWidth = window.innerWidth - docEl.clientWidth
194
- if (scrollBarWidth > 0) {
195
- body.style.paddingRight = `${scrollBarWidth}px`
196
- }
197
-
198
- body.style.position = 'fixed'
199
- body.style.top = `-${this.savedScrollY}px`
200
- body.style.left = '0'
201
- body.style.right = '0'
202
- body.style.width = '100%'
203
- body.style.overflow = 'hidden'
204
-
205
- docEl.classList.add('is-scroll-locked')
206
- }
207
-
208
- private unlockScroll() {
209
- const body = document.body
210
- const docEl = document.documentElement
211
-
212
- if (!docEl.classList.contains('is-scroll-locked')) return
213
-
214
- body.style.removeProperty('position')
215
- body.style.removeProperty('top')
216
- body.style.removeProperty('left')
217
- body.style.removeProperty('right')
218
- body.style.removeProperty('width')
219
- body.style.removeProperty('overflow')
220
- body.style.removeProperty('padding-right')
221
- docEl.classList.remove('is-scroll-locked')
222
-
223
- window.scrollTo({ top: this.savedScrollY })
224
- }
225
-
226
- private setupScrollListener() {
227
- window.addEventListener('scroll', this.handleScroll)
228
- }
229
-
230
- private handleScroll = () => {
231
- if (!this.shouldHideOnScroll) return
232
-
233
- const currentScrollPosition = window.pageYOffset || document.documentElement.scrollTop
234
- if (currentScrollPosition < 0) return
235
- if (Math.abs(currentScrollPosition - this.lastScrollPosition) < 60) return
236
-
237
- this.isHidden = currentScrollPosition > this.lastScrollPosition
238
- this.lastScrollPosition = currentScrollPosition
140
+ // Lock background scroll while a menu is open on mobile (recomputed every
141
+ // update so it also reacts to breakpoint changes from the controller).
142
+ this.chrome.setScrollLock(
143
+ (this.isFixed || this.isSticky) && this.isMobile && this.openMenu !== 'none',
144
+ )
239
145
  }
240
146
 
241
147
  private handleClickOutside = (event: MouseEvent) => {
@@ -307,23 +213,11 @@ export class PktHeaderService extends PktElementWithSlot<IPktHeader> implements
307
213
  private handleEscapeKey = (event: KeyboardEvent) => {
308
214
  if (event.key === 'Escape' && this.openMenu !== 'none') {
309
215
  event.preventDefault()
310
- this.shouldRestoreFocus = true
216
+ this.chrome.flagRestoreFocus()
311
217
  this.openMenu = 'none'
312
218
  }
313
219
  }
314
220
 
315
- private restoreFocus() {
316
- if (
317
- this.shouldRestoreFocus &&
318
- this.lastFocusedElement &&
319
- document.contains(this.lastFocusedElement)
320
- ) {
321
- this.lastFocusedElement.focus()
322
- }
323
- this.lastFocusedElement = null
324
- this.shouldRestoreFocus = false
325
- }
326
-
327
221
  private checkDropdownAlignment(mode: 'slot' | 'search') {
328
222
  const containerRef = mode === 'slot' ? this.slotContainerRef : this.searchContainerRef
329
223
  const dropdownRef = mode === 'slot' ? this.slotContentRef : this.searchMenuRef
@@ -344,8 +238,7 @@ export class PktHeaderService extends PktElementWithSlot<IPktHeader> implements
344
238
  if (this.openMenu !== 'none') {
345
239
  this.openMenu = 'none'
346
240
  } else {
347
- this.lastFocusedElement = document.activeElement as HTMLElement
348
-
241
+ this.chrome.captureFocus()
349
242
  this.openMenu = mode
350
243
  }
351
244
  }
@@ -418,6 +311,10 @@ export class PktHeaderService extends PktElementWithSlot<IPktHeader> implements
418
311
  return this.position === 'fixed'
419
312
  }
420
313
 
314
+ private get isSticky(): boolean {
315
+ return this.position === 'sticky'
316
+ }
317
+
421
318
  private get shouldHideOnScroll(): boolean {
422
319
  return this.scrollBehavior === 'hide'
423
320
  }
@@ -443,34 +340,12 @@ export class PktHeaderService extends PktElementWithSlot<IPktHeader> implements
443
340
  <pkt-icon name="oslologo" aria-hidden="true" path=${this.logoPath}></pkt-icon>
444
341
  `
445
342
 
446
- // If logoLink is a non-empty string, render as link
447
- if (this.logoLink && typeof this.logoLink === 'string') {
448
- return html`
449
- <span class="pkt-header-service__logo">
450
- <a href=${this.logoLink} aria-label="Tilbake til forside" @click=${this.handleLogoClick}>
451
- ${logoIcon}
452
- </a>
453
- </span>
454
- `
455
- }
456
-
457
- // If logo-link attribute is present but empty, render as clickable button
458
- if (this.hasAttribute('logo-link')) {
459
- return html`
460
- <span class="pkt-header-service__logo">
461
- <button
462
- aria-label="Tilbake til forside"
463
- class="pkt-link-button pkt-link pkt-header-service__logo-link"
464
- @click=${this.handleLogoClick}
465
- >
466
- ${logoIcon}
467
- </button>
468
- </span>
469
- `
470
- }
471
-
472
343
  return html`
473
- <span class="pkt-header-service__logo" @click=${this.handleLogoClick}>${logoIcon}</span>
344
+ <span class="pkt-header-service__logo">
345
+ <a href=${this.logoLink} aria-label="Tilbake til forside" @click=${this.handleLogoClick}>
346
+ ${logoIcon}
347
+ </a>
348
+ </span>
474
349
  `
475
350
  }
476
351
 
@@ -556,7 +431,7 @@ export class PktHeaderService extends PktElementWithSlot<IPktHeader> implements
556
431
  <div
557
432
  class=${slotContentClasses}
558
433
  id="mobile-slot-menu"
559
- role=${this.isTablet ? 'menu' : nothing}
434
+ role=${this.isTablet ? 'group' : nothing}
560
435
  aria-label=${this.isTablet ? 'Meny' : nothing}
561
436
  @click=${this.handleSlotContentClick}
562
437
  ${ref(this.slotContentRef)}
@@ -704,6 +579,7 @@ export class PktHeaderService extends PktElementWithSlot<IPktHeader> implements
704
579
  'pkt-header-service--mobile': this.isMobile,
705
580
  'pkt-header-service--tablet': this.isTablet,
706
581
  'pkt-header-service--fixed': this.isFixed,
582
+ 'pkt-header-service--sticky': this.isSticky,
707
583
  'pkt-header-service--scroll-to-hide': this.shouldHideOnScroll,
708
584
  'pkt-header-service--hidden': this.isHidden,
709
585
  })
@@ -80,7 +80,6 @@ export class PktHeaderUserMenu
80
80
  <pkt-link
81
81
  icon-name=${item.iconName || nothing}
82
82
  href=${item.href}
83
- aria-hidden="true"
84
83
  class="pkt-user-menu__link ${className || ''}"
85
84
  >
86
85
  ${item.title}
@@ -0,0 +1,84 @@
1
+ import '@testing-library/jest-dom'
2
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
3
+
4
+ import './header'
5
+ import type { PktHeader } from './header'
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
+ afterEach(() => {
31
+ document.body.innerHTML = ''
32
+ vi.restoreAllMocks()
33
+ })
34
+
35
+ // Zero breakpoints via attributes (so the wrapper forwards them) → desktop layout
36
+ // with an inline search field.
37
+ const mountGlobal = async (props: Record<string, unknown> = {}): Promise<PktHeader> => {
38
+ const el = document.createElement('pkt-header') as PktHeader
39
+ el.type = 'global'
40
+ el.position = 'relative'
41
+ el.setAttribute('mobile-breakpoint', '0')
42
+ el.setAttribute('tablet-breakpoint', '0')
43
+ el.data = EXAMPLE_HEADER_FOOTER_DATA
44
+ Object.assign(el, props)
45
+ document.body.appendChild(el)
46
+ await customElements.whenDefined('pkt-header')
47
+ await el.updateComplete
48
+ await new Promise((resolve) => setTimeout(resolve, 0))
49
+ const inner = el.querySelector('pkt-header-global') as HTMLElement & { updateComplete: Promise<unknown> }
50
+ await inner?.updateComplete
51
+ await new Promise((resolve) => setTimeout(resolve, 0))
52
+ await inner?.updateComplete
53
+ return el
54
+ }
55
+
56
+ describe('pkt-header (wrapper) – global', () => {
57
+ test('søkefeltet henter placeholder og spørringsparametere fra API-dataene', async () => {
58
+ const el = await mountGlobal()
59
+ const input = el.querySelector<HTMLInputElement>('input[type="search"]')
60
+ const form = el.querySelector<HTMLFormElement>('form[role="search"]')
61
+
62
+ // Placeholder comes from the API payload, not the wrapper's service default «Søk».
63
+ expect(input).toHaveAttribute('placeholder', 'Søk i hele nettstedet')
64
+ expect(input).toHaveAttribute('name', 'q')
65
+ expect(form).toHaveAttribute('action', 'https://www.oslo.kommune.no/sokeresultat/')
66
+ expect(form).toHaveAttribute('method', 'get')
67
+ })
68
+
69
+ test('viser søk som standard (showSearch er av i service, på i global)', async () => {
70
+ const el = await mountGlobal()
71
+ expect(el.querySelector('input[type="search"]')).toBeInTheDocument()
72
+ })
73
+
74
+ test('eksplisitt search-placeholder overstyrer API-verdien', async () => {
75
+ const el = await mountGlobal()
76
+ el.setAttribute('search-placeholder', 'Egendefinert')
77
+ await el.updateComplete
78
+ const inner = el.querySelector('pkt-header-global') as HTMLElement & {
79
+ updateComplete: Promise<unknown>
80
+ }
81
+ await inner.updateComplete
82
+ expect(el.querySelector('input[type="search"]')).toHaveAttribute('placeholder', 'Egendefinert')
83
+ })
84
+ })
@@ -12,12 +12,16 @@ import {
12
12
  THeaderPosition,
13
13
  THeaderScrollBehavior,
14
14
  TSlotMenuVariant,
15
+ THeaderType,
16
+ THeaderFooterApi,
17
+ THeaderMenuLocale,
15
18
  IPktHeader,
16
19
  Booleanish,
17
20
  booleanishConverter,
18
21
  } from './types'
19
22
 
20
23
  import './header-service'
24
+ import './header-global'
21
25
 
22
26
  /**
23
27
  * PktHeader - Main header component for Oslo kommune services
@@ -29,9 +33,13 @@ import './header-service'
29
33
  * - Responsive mobile menu
30
34
  * - Fixed positioning with scroll-to-hide
31
35
  *
32
- * TODO: Add `type` prop to switch between `service` and `global` header types
36
+ * The `type` prop switches between the `service` header (a customizable
37
+ * header for a single service, the default) and the `global`
38
+ * (kommune-wide) header driven by the shared header/footer payload.
33
39
  */
34
40
  export class PktHeader extends PktElementWithSlot<IPktHeader> implements IPktHeader {
41
+ @property({ type: String }) type: THeaderType = 'service'
42
+
35
43
  @property({ type: String, attribute: 'service-name' }) serviceName?: string
36
44
  @property({ type: String, attribute: 'service-link' }) serviceLink?: string
37
45
  @property({ type: String, attribute: 'logo-link' }) logoLink?: string
@@ -54,7 +62,7 @@ export class PktHeader extends PktElementWithSlot<IPktHeader> implements IPktHea
54
62
  hideLogo: Booleanish = false
55
63
  @property({ type: Boolean, converter: booleanishConverter }) compact: Booleanish = false
56
64
  @property({ type: Boolean, attribute: 'show-search', converter: booleanishConverter })
57
- showSearch: Booleanish = false
65
+ showSearch?: Booleanish
58
66
  @property({
59
67
  type: Boolean,
60
68
  attribute: 'can-change-representation',
@@ -68,6 +76,13 @@ export class PktHeader extends PktElementWithSlot<IPktHeader> implements IPktHea
68
76
  @property({ type: Array, attribute: 'user-menu' }) userMenu?: UserMenuItem[]
69
77
  @property({ type: Object }) representing?: Representing
70
78
 
79
+ // Global header props (type="global")
80
+ @property({ type: String, attribute: 'data-url' }) dataUrl?: string
81
+ @property({ type: Object, attribute: false }) data?: THeaderFooterApi
82
+ @property({ type: String }) locale?: THeaderMenuLocale
83
+ @property({ type: Boolean, attribute: 'show-contact', converter: booleanishConverter })
84
+ showContact: Booleanish = true
85
+
71
86
  // Deprecated props - emit warnings
72
87
  @property({ type: Array, attribute: 'user-menu-footer' }) userMenuFooter?: UserMenuItem[]
73
88
  @property({ type: Array, attribute: 'user-options' }) userOptions?: UserMenuItem[]
@@ -79,11 +94,9 @@ export class PktHeader extends PktElementWithSlot<IPktHeader> implements IPktHea
79
94
 
80
95
  private emitDeprecationWarnings() {
81
96
  if (this.userMenuFooter !== undefined) {
82
- // eslint-disable-next-line no-console -- Deprecation warnings are intentional for library consumers
83
97
  console.warn('[PktHeader] userMenuFooter is deprecated. Use userMenu instead.')
84
98
  }
85
99
  if (this.userOptions !== undefined) {
86
- // eslint-disable-next-line no-console -- Deprecation warnings are intentional for library consumers
87
100
  console.warn('[PktHeader] userOptions is deprecated. Use userMenu instead.')
88
101
  }
89
102
  }
@@ -102,7 +115,41 @@ export class PktHeader extends PktElementWithSlot<IPktHeader> implements IPktHea
102
115
  return this.userMenu
103
116
  }
104
117
 
118
+ private renderGlobal() {
119
+ return html`
120
+ <pkt-header-global
121
+ data-url=${ifDefined(this.dataUrl)}
122
+ locale=${ifDefined(this.locale)}
123
+ logo-link=${ifDefined(this.logoLink)}
124
+ logo-path=${ifDefined(this.logoPath)}
125
+ search-placeholder=${ifDefined(
126
+ this.hasAttribute('search-placeholder') ? this.searchPlaceholder : undefined,
127
+ )}
128
+ mobile-breakpoint=${ifDefined(
129
+ this.hasAttribute('mobile-breakpoint') ? this.mobileBreakpoint : undefined,
130
+ )}
131
+ tablet-breakpoint=${ifDefined(
132
+ this.hasAttribute('tablet-breakpoint') ? this.tabletBreakpoint : undefined,
133
+ )}
134
+ log-out-button-placement=${this.logOutButtonPlacement}
135
+ position=${this.position}
136
+ scroll-behavior=${this.scrollBehavior}
137
+ .data=${this.data}
138
+ .showSearch=${this.showSearch ?? true}
139
+ .showContact=${this.showContact}
140
+ .canChangeRepresentation=${this.canChangeRepresentation}
141
+ .hasLogOut=${this.hasLogOut}
142
+ .user=${this.user}
143
+ .userMenu=${this.effectiveUserMenu}
144
+ .representing=${this.representing}
145
+ ></pkt-header-global>
146
+ `
147
+ }
148
+
105
149
  render() {
150
+ if (this.type === 'global') {
151
+ return this.renderGlobal()
152
+ }
106
153
  return html`
107
154
  <pkt-header-service
108
155
  service-name=${ifDefined(this.serviceName)}
@@ -121,7 +168,7 @@ export class PktHeader extends PktElementWithSlot<IPktHeader> implements IPktHea
121
168
  slot-menu-text=${this.slotMenuText}
122
169
  .hideLogo=${this.hideLogo}
123
170
  .compact=${this.compact}
124
- .showSearch=${this.showSearch}
171
+ .showSearch=${this.showSearch ?? false}
125
172
  .canChangeRepresentation=${this.canChangeRepresentation}
126
173
  .hasLogOut=${this.hasLogOut}
127
174
  .user=${this.user}
@@ -1,5 +1,6 @@
1
1
  export { PktHeader } from './header'
2
2
  export { PktHeaderService } from './header-service'
3
+ export { PktHeaderGlobal } from './header-global'
3
4
  export { PktHeaderUserMenu } from './header-user-menu'
4
5
 
5
6
  export type {
@@ -12,7 +13,12 @@ export type {
12
13
  THeaderPosition,
13
14
  THeaderScrollBehavior,
14
15
  TSlotMenuVariant,
16
+ THeaderType,
17
+ TGlobalHeaderMenu,
18
+ THeaderFooterApi,
19
+ THeaderMenuLocale,
15
20
  IPktHeader,
21
+ IPktHeaderGlobal,
16
22
  } from './types'
17
23
 
18
24
  export { formatLastLoggedIn } from './header-utils'