@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,238 @@
1
+ import { html, nothing, type PropertyValues, type TemplateResult } from 'lit'
2
+ import { property, state } from 'lit/decorators.js'
3
+ import { ifDefined } from 'lit/directives/if-defined.js'
4
+ import {
5
+ deriveSocialIcon,
6
+ fetchHeaderFooterData,
7
+ getExternalDomainLabel,
8
+ getLinkDomainLabel,
9
+ selectLocaleData,
10
+ } from 'shared-utils/header-footer'
11
+ import {
12
+ type Booleanish,
13
+ booleanishConverter,
14
+ type IHeaderFooter,
15
+ type IHeaderMenuLink,
16
+ type IHeaderMenuSection,
17
+ type IPktFooterLink,
18
+ type THeaderFooterApi,
19
+ type THeaderMenuLocale,
20
+ } from 'shared-types'
21
+ import { PktElement } from '@/base-elements/element'
22
+
23
+ import '@/components/consent'
24
+ import '@/components/icon'
25
+
26
+ const CONSENT_TRIGGER_URL = '#cb-trigger'
27
+
28
+ /**
29
+ * Shared base for `pkt-footer` and `pkt-footer-simple`.
30
+ *
31
+ * - Fetching and caching global footer data
32
+ * - Rendering the global footer band (blue)
33
+ * - Rendering the consent dialog (if `includeConsent` is set)
34
+ */
35
+
36
+ export abstract class PktFooterBaseElement<T = {}> extends PktElement<T> {
37
+ @property({ type: String, attribute: 'data-url' }) dataUrl?: string
38
+ @property({ type: Object, attribute: false }) data?: THeaderFooterApi
39
+ @property({ type: String }) locale: THeaderMenuLocale = 'nb-NO'
40
+
41
+ @property({ type: Boolean, attribute: 'skip-global', converter: booleanishConverter })
42
+ skipGlobal: Booleanish = false
43
+ @property({ type: Boolean, attribute: 'open-links-in-new-tab', converter: booleanishConverter })
44
+ openLinksInNewTab: Booleanish = false
45
+
46
+ @property({ type: Boolean, attribute: 'include-consent', converter: booleanishConverter })
47
+ includeConsent: Booleanish = false
48
+ @property({ type: String, attribute: 'hotjar-id' }) hotjarId: string | null = null
49
+ @property({ type: String, attribute: 'google-analytics-id' }) googleAnalyticsId: string | null =
50
+ null
51
+ @property({ type: Boolean, attribute: 'dev-mode', converter: booleanishConverter })
52
+ devMode: Booleanish = false
53
+ @property({ type: String, attribute: 'cookie-domain' }) cookieDomain: string | null = null
54
+ @property({ type: String, attribute: 'cookie-secure' }) cookieSecure: string | null = null
55
+ @property({ type: String, attribute: 'cookie-expiry-days' }) cookieExpiryDays: string | null =
56
+ null
57
+
58
+ @state() private fetchedData?: THeaderFooterApi
59
+
60
+ private abortController?: AbortController
61
+
62
+ connectedCallback() {
63
+ super.connectedCallback()
64
+ if (!this.data && !this.skipGlobal) {
65
+ void this.loadData()
66
+ }
67
+ }
68
+
69
+ disconnectedCallback() {
70
+ super.disconnectedCallback()
71
+ this.abortController?.abort()
72
+ }
73
+
74
+ updated(changedProperties: PropertyValues) {
75
+ super.updated(changedProperties)
76
+ if (
77
+ changedProperties.has('dataUrl') &&
78
+ changedProperties.get('dataUrl') !== undefined &&
79
+ !this.data
80
+ ) {
81
+ void this.loadData()
82
+ }
83
+ }
84
+
85
+ private async loadData() {
86
+ this.abortController?.abort()
87
+ this.abortController = new AbortController()
88
+ try {
89
+ this.fetchedData = await fetchHeaderFooterData<string>(
90
+ this.dataUrl,
91
+ this.abortController.signal,
92
+ )
93
+ } catch (error) {
94
+ if ((error as Error).name === 'AbortError') return
95
+ console.warn('Failed to fetch header/footer data:', error)
96
+ this.dispatchEvent(
97
+ new CustomEvent('data-error', { detail: { error }, bubbles: true, composed: true }),
98
+ )
99
+ }
100
+ }
101
+
102
+ private get globalFooterData(): IHeaderFooter | undefined {
103
+ return selectLocaleData(this.data ?? this.fetchedData, this.locale)?.footer
104
+ }
105
+
106
+ protected linkTarget(link: IPktFooterLink): { target: string; rel?: string } {
107
+ const newTab = link.openInNewTab || this.openLinksInNewTab
108
+ return newTab ? { target: '_blank', rel: 'noopener noreferrer' } : { target: '_self' }
109
+ }
110
+
111
+ /** "(topdomain.tld)" display for external links */
112
+ protected renderLinkDomain(link: IPktFooterLink, className = 'pkt-footer__link-domain') {
113
+ if (!link.external) return nothing
114
+ const domain = getLinkDomainLabel(link.href)
115
+ if (!domain) return nothing
116
+ return html` <span class=${className}>(${domain})</span>`
117
+ }
118
+
119
+ protected renderCustomLink(link: IPktFooterLink): TemplateResult {
120
+ const { target, rel } = this.linkTarget(link)
121
+ return html`
122
+ <a href=${link.href} target=${target} rel=${ifDefined(rel)}
123
+ >${link.text}${this.renderLinkDomain(link)}</a
124
+ >
125
+ `
126
+ }
127
+
128
+ protected renderConsent(): TemplateResult | typeof nothing {
129
+ if (!this.includeConsent) return nothing
130
+ return html`
131
+ <pkt-consent
132
+ .triggerType=${'footerlink'}
133
+ .hotjarId=${this.hotjarId}
134
+ .googleAnalyticsId=${this.googleAnalyticsId}
135
+ .devMode=${Boolean(this.devMode)}
136
+ .cookieDomain=${this.cookieDomain}
137
+ .cookieSecure=${this.cookieSecure}
138
+ .cookieExpiryDays=${this.cookieExpiryDays}
139
+ ></pkt-consent>
140
+ `
141
+ }
142
+
143
+ private renderGlobalSectionLink(link: IHeaderMenuLink): TemplateResult | typeof nothing {
144
+ if (link.url === CONSENT_TRIGGER_URL) {
145
+ // Punkt's own consent dialog replaces the CDN cookie-banner trigger.
146
+ if (!this.includeConsent) return nothing
147
+ return html`
148
+ <li>
149
+ <pkt-consent
150
+ .triggerType=${'footerlink'}
151
+ .triggerText=${link.text}
152
+ .hotjarId=${this.hotjarId}
153
+ .googleAnalyticsId=${this.googleAnalyticsId}
154
+ .devMode=${Boolean(this.devMode)}
155
+ .cookieDomain=${this.cookieDomain}
156
+ .cookieSecure=${this.cookieSecure}
157
+ .cookieExpiryDays=${this.cookieExpiryDays}
158
+ ></pkt-consent>
159
+ </li>
160
+ `
161
+ }
162
+ const domain = getExternalDomainLabel(link.url)
163
+ return html`
164
+ <li>
165
+ <a href=${link.url}
166
+ >${link.text}${domain
167
+ ? html` <span class="pkt-footer__link-domain">(${domain})</span>`
168
+ : nothing}</a
169
+ >
170
+ </li>
171
+ `
172
+ }
173
+
174
+ private renderGlobalSection(section: IHeaderMenuSection): TemplateResult {
175
+ return html`
176
+ <section>
177
+ <h2>${section.title}</h2>
178
+ <ul class="pkt-footer__link-list">
179
+ ${section.links.map((link) => this.renderGlobalSectionLink(link))}
180
+ </ul>
181
+ </section>
182
+ `
183
+ }
184
+
185
+ private renderSomeLink(entry: IHeaderMenuLink): TemplateResult {
186
+ const icon = deriveSocialIcon(entry.url, entry.text)
187
+ if (!icon) {
188
+ return html`<li><a href=${entry.url}>${entry.text}</a></li>`
189
+ }
190
+ return html`
191
+ <li>
192
+ <a class="pkt-footer__some-link" href=${entry.url} aria-label=${entry.text}>
193
+ <pkt-icon class="pkt-footer__some-icon" name=${icon}></pkt-icon>
194
+ </a>
195
+ </li>
196
+ `
197
+ }
198
+
199
+ private renderGlobalBottom(footer: IHeaderFooter): TemplateResult | typeof nothing {
200
+ const hasLinks = footer.links?.length > 0
201
+ const hasSome = footer.some?.length > 0
202
+ if (!hasLinks && !hasSome) return nothing
203
+ return html`
204
+ <div class="pkt-footer__bottom">
205
+ ${hasLinks
206
+ ? html`
207
+ <ul class="pkt-footer__bottom-links">
208
+ ${footer.links.map((link) => html`<li><a href=${link.url}>${link.text}</a></li>`)}
209
+ </ul>
210
+ `
211
+ : nothing}
212
+ ${hasSome
213
+ ? html`
214
+ <ul class="pkt-footer__some">
215
+ ${footer.some.map((entry) => this.renderSomeLink(entry))}
216
+ </ul>
217
+ `
218
+ : nothing}
219
+ </div>
220
+ `
221
+ }
222
+
223
+ protected renderGlobalFooter(): TemplateResult | typeof nothing {
224
+ if (this.skipGlobal) return nothing
225
+ const footer = this.globalFooterData
226
+ if (!footer) return nothing
227
+ return html`
228
+ <div class="pkt-footer__band pkt-footer__band--global">
229
+ <div class="pkt-footer__inner">
230
+ <div class="pkt-footer__sections">
231
+ ${footer.sections.map((section) => this.renderGlobalSection(section))}
232
+ ${this.renderGlobalBottom(footer)}
233
+ </div>
234
+ </div>
235
+ </div>
236
+ `
237
+ }
238
+ }
@@ -0,0 +1,71 @@
1
+ import { html, nothing, type TemplateResult } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+ import { ifDefined } from 'lit/directives/if-defined.js'
4
+ import type { IPktFooterLink } from 'shared-types'
5
+ import { PktFooterBaseElement } from './footer-base'
6
+ import type { IPktFooterSimple } from './types'
7
+
8
+ import '@/components/icon'
9
+
10
+ /**
11
+ * PktFooterSimple — kompakt footer: en sentrert rad med pil-lenker i det
12
+ * grå sekundær-båndet, stablet oppå den globale (blå) footeren. Uten
13
+ * `links` rendres kun den globale footeren.
14
+ */
15
+ export class PktFooterSimple
16
+ extends PktFooterBaseElement<IPktFooterSimple>
17
+ implements IPktFooterSimple
18
+ {
19
+ @property({ type: Array }) links?: IPktFooterLink[]
20
+
21
+ connectedCallback() {
22
+ super.connectedCallback()
23
+ this.classList.add('pkt-footer-simple-host')
24
+ }
25
+
26
+ private renderLink(link: IPktFooterLink): TemplateResult {
27
+ const { target, rel } = this.linkTarget(link)
28
+ return html`
29
+ <a class="pkt-footer-simple__link" href=${link.href} target=${target} rel=${ifDefined(rel)}>
30
+ <pkt-icon class="pkt-footer-simple__link-icon" name="arrow-link"></pkt-icon>
31
+ <span>${link.text}${this.renderLinkDomain(link, 'pkt-footer-simple__link-domain')}</span>
32
+ </a>
33
+ `
34
+ }
35
+
36
+ render() {
37
+ const hasCustomContent = !!this.links?.length
38
+ // Med skipGlobal rendres consent-lenken i sekundær-båndet i stedet
39
+ // for i den globale footeren.
40
+ const consentInSecondary = this.skipGlobal && this.includeConsent
41
+ return html`
42
+ <footer class="pkt-footer-simple">
43
+ ${hasCustomContent || consentInSecondary
44
+ ? html`
45
+ <div class="pkt-footer__band pkt-footer__band--secondary">
46
+ <div class="pkt-footer__inner pkt-footer__inner--compact">
47
+ <ul class="pkt-footer-simple__list">
48
+ ${this.links?.map((link) => html`<li>${this.renderLink(link)}</li>`)}
49
+ ${consentInSecondary ? html`<li>${this.renderConsent()}</li>` : nothing}
50
+ </ul>
51
+ </div>
52
+ </div>
53
+ `
54
+ : nothing}
55
+ ${this.renderGlobalFooter()}
56
+ </footer>
57
+ `
58
+ }
59
+ }
60
+
61
+ declare global {
62
+ interface HTMLElementTagNameMap {
63
+ 'pkt-footer-simple': PktFooterSimple
64
+ }
65
+ }
66
+
67
+ try {
68
+ customElement('pkt-footer-simple')(PktFooterSimple)
69
+ } catch (e) {
70
+ console.warn('Forsøker å definere <pkt-footer-simple>, men den er allerede definert')
71
+ }
@@ -0,0 +1,66 @@
1
+ import '@testing-library/jest-dom'
2
+ import { axe, toHaveNoViolations } from 'jest-axe'
3
+ import { afterEach, describe, expect, test, vi } from 'vitest'
4
+
5
+ import './footer'
6
+ import './footer-simple'
7
+ import { PktFooter } from './footer'
8
+ import { PktFooterSimple } from './footer-simple'
9
+ import { EXAMPLE_HEADER_FOOTER_DATA } from 'shared-utils/header-footer'
10
+ import type { IPktFooterColumn, IPktFooterLink } from 'shared-types'
11
+
12
+ expect.extend(toHaveNoViolations)
13
+
14
+ const COLUMNS: IPktFooterColumn[] = [
15
+ {
16
+ title: 'Overskrift 1',
17
+ text: 'Beskrivende tekst om løsningen.',
18
+ links: [{ text: 'Lenke 1', href: 'https://example.test/lenke-1' }],
19
+ },
20
+ {
21
+ title: 'Overskrift 2',
22
+ links: [{ text: 'Lenke 2', href: 'https://example.test/lenke-2' }],
23
+ },
24
+ ]
25
+
26
+ const SIMPLE_LINKS: IPktFooterLink[] = [
27
+ { text: 'Lenke 1', href: 'https://example.test/lenke-1' },
28
+ { text: 'Lenke 2', href: 'https://example.test/lenke-2' },
29
+ ]
30
+
31
+ afterEach(() => {
32
+ document.body.innerHTML = ''
33
+ vi.restoreAllMocks()
34
+ })
35
+
36
+ const mount = async <T extends PktFooter | PktFooterSimple>(
37
+ tag: 'pkt-footer' | 'pkt-footer-simple',
38
+ props: Record<string, unknown> = {},
39
+ ): Promise<T> => {
40
+ const el = document.createElement(tag) as T
41
+ el.data = EXAMPLE_HEADER_FOOTER_DATA
42
+ Object.assign(el, props)
43
+ document.body.appendChild(el)
44
+ await customElements.whenDefined(tag)
45
+ await el.updateComplete
46
+ await new Promise((resolve) => setTimeout(resolve, 0))
47
+ await el.updateComplete
48
+ return el
49
+ }
50
+
51
+ describe('PktFooter tilgjengelighet', () => {
52
+ test('kun global footer har ingen a11y-avvik', async () => {
53
+ const el = await mount('pkt-footer')
54
+ expect(await axe(el)).toHaveNoViolations()
55
+ })
56
+
57
+ test('footer med custom kolonner har ingen a11y-avvik', async () => {
58
+ const el = await mount('pkt-footer', { columns: COLUMNS })
59
+ expect(await axe(el)).toHaveNoViolations()
60
+ })
61
+
62
+ test('kompakt footer har ingen a11y-avvik', async () => {
63
+ const el = await mount('pkt-footer-simple', { links: SIMPLE_LINKS })
64
+ expect(await axe(el)).toHaveNoViolations()
65
+ })
66
+ })
@@ -0,0 +1,238 @@
1
+ import '@testing-library/jest-dom'
2
+ import { afterEach, describe, expect, test, vi } from 'vitest'
3
+
4
+ import './footer'
5
+ import './footer-simple'
6
+ import { PktFooter } from './footer'
7
+ import { PktFooterSimple } from './footer-simple'
8
+ import { EXAMPLE_HEADER_FOOTER_DATA } from 'shared-utils/header-footer'
9
+ import type { IPktFooterColumn, IPktFooterLink } from 'shared-types'
10
+
11
+ const COLUMNS: IPktFooterColumn[] = [
12
+ {
13
+ title: 'Overskrift 1',
14
+ text: 'Her kan du skrive en lengre tekst som beskriver løsningen.',
15
+ links: [{ text: 'Lenke 1', href: 'https://example.test/lenke-1' }],
16
+ },
17
+ {
18
+ title: 'Overskrift 2',
19
+ links: [
20
+ { text: 'Lenke 2', href: 'https://example.test/lenke-2', external: true },
21
+ { text: 'Lenke 3', href: 'https://example.test/lenke-3', openInNewTab: true },
22
+ ],
23
+ },
24
+ ]
25
+
26
+ const SIMPLE_LINKS: IPktFooterLink[] = [
27
+ { text: 'Lenke 1', href: 'https://example.test/lenke-1' },
28
+ { text: 'Lenke 2', href: 'https://example.test/lenke-2' },
29
+ ]
30
+
31
+ const mountFooter = async (props: Record<string, unknown> = {}): Promise<PktFooter> => {
32
+ const el = document.createElement('pkt-footer') as PktFooter
33
+ el.data = EXAMPLE_HEADER_FOOTER_DATA
34
+ Object.assign(el, props)
35
+ document.body.appendChild(el)
36
+ await customElements.whenDefined('pkt-footer')
37
+ await el.updateComplete
38
+ await new Promise((resolve) => setTimeout(resolve, 0))
39
+ await el.updateComplete
40
+ return el
41
+ }
42
+
43
+ const mountSimple = async (props: Record<string, unknown> = {}): Promise<PktFooterSimple> => {
44
+ const el = document.createElement('pkt-footer-simple') as PktFooterSimple
45
+ el.data = EXAMPLE_HEADER_FOOTER_DATA
46
+ Object.assign(el, props)
47
+ document.body.appendChild(el)
48
+ await customElements.whenDefined('pkt-footer-simple')
49
+ await el.updateComplete
50
+ await new Promise((resolve) => setTimeout(resolve, 0))
51
+ await el.updateComplete
52
+ return el
53
+ }
54
+
55
+ afterEach(() => {
56
+ document.body.innerHTML = ''
57
+ vi.restoreAllMocks()
58
+ })
59
+
60
+ describe('PktFooter global footer', () => {
61
+ test('rendrer kun global footer uten custom innhold', async () => {
62
+ const el = await mountFooter()
63
+ expect(el.querySelector('.pkt-footer__band--secondary')).toBeNull()
64
+ expect(el.querySelector('.pkt-footer__band--global')).not.toBeNull()
65
+ const titles = [
66
+ ...el.querySelectorAll('.pkt-footer__band--global h2'),
67
+ ].map((t) => t.textContent)
68
+ expect(titles).toEqual(['Kontakt', 'Jobb i Oslo kommune', 'Om nettstedet'])
69
+ })
70
+
71
+ test('rendrer bunnrad med språklenke og some-ikoner', async () => {
72
+ const el = await mountFooter()
73
+ const bottom = el.querySelector('.pkt-footer__band--global .pkt-footer__bottom')
74
+ expect(bottom?.querySelector('a')?.textContent).toContain('English')
75
+ const someLinks = [...el.querySelectorAll('.pkt-footer__band--global .pkt-footer__some-link')]
76
+ expect(someLinks).toHaveLength(3)
77
+ expect(someLinks[0].getAttribute('aria-label')).toBe('Facebook')
78
+ expect(someLinks[0].querySelector('pkt-icon')?.getAttribute('name')).toBe('facebook')
79
+ })
80
+
81
+ test('merker eksterne lenker med toppdomene i parentes', async () => {
82
+ const el = await mountFooter()
83
+ const jobLink = [...el.querySelectorAll('.pkt-footer__band--global a')].find(
84
+ (link) => link.textContent?.includes('Ledige stillinger'),
85
+ )
86
+ expect(jobLink?.querySelector('.pkt-footer__link-domain')?.textContent).toBe('(webcruiter.com)')
87
+ const internalLink = [
88
+ ...el.querySelectorAll('.pkt-footer__band--global a'),
89
+ ].find((link) => link.textContent?.includes('Tilgjengelighet'))
90
+ expect(internalLink?.querySelector('.pkt-footer__link-domain')).toBeNull()
91
+ })
92
+
93
+ test('skjuler #cb-trigger-lenken uten includeConsent', async () => {
94
+ const el = await mountFooter()
95
+ expect(el.textContent).not.toContain('Innstillinger for informasjonskapsler')
96
+ expect(el.querySelector('pkt-consent')).toBeNull()
97
+ })
98
+
99
+ test('erstatter #cb-trigger med pkt-consent når includeConsent er satt', async () => {
100
+ const el = await mountFooter({ includeConsent: true })
101
+ const consent = el.querySelector('pkt-consent')
102
+ expect(consent).not.toBeNull()
103
+ expect(consent?.closest('.pkt-footer__band--global')).not.toBeNull()
104
+ })
105
+
106
+ test('bruker valgt locale', async () => {
107
+ const el = await mountFooter({ locale: 'en-GB' })
108
+ const titles = [
109
+ ...el.querySelectorAll('.pkt-footer__band--global h2'),
110
+ ].map((t) => t.textContent)
111
+ expect(titles).toContain('Contact us')
112
+ })
113
+
114
+ test('skipGlobal dropper global footer og henter ikke data', async () => {
115
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('should not run'))
116
+ const el = document.createElement('pkt-footer') as PktFooter
117
+ el.skipGlobal = true
118
+ el.columns = COLUMNS
119
+ document.body.appendChild(el)
120
+ await customElements.whenDefined('pkt-footer')
121
+ await el.updateComplete
122
+ expect(el.querySelector('.pkt-footer__band--global')).toBeNull()
123
+ expect(el.querySelector('.pkt-footer__band--secondary')).not.toBeNull()
124
+ expect(fetchSpy).not.toHaveBeenCalled()
125
+ })
126
+ })
127
+
128
+ describe('PktFooter henting av data', () => {
129
+ test('henter data fra endepunkt når data ikke er satt', async () => {
130
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
131
+ new Response(JSON.stringify(EXAMPLE_HEADER_FOOTER_DATA), {
132
+ status: 200,
133
+ headers: { 'Content-Type': 'application/json' },
134
+ }),
135
+ )
136
+ const el = document.createElement('pkt-footer') as PktFooter
137
+ document.body.appendChild(el)
138
+ await customElements.whenDefined('pkt-footer')
139
+ await new Promise((resolve) => setTimeout(resolve, 0))
140
+ await el.updateComplete
141
+ expect(globalThis.fetch).toHaveBeenCalledWith(
142
+ 'https://cdn.web.oslo.kommune.no/header-footer/header-footer.json',
143
+ )
144
+ expect(el.querySelector('.pkt-footer__band--global')).not.toBeNull()
145
+ })
146
+
147
+ test('rendrer ingenting og emitter data-error når hentingen feiler', async () => {
148
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
149
+ new Response('boom', { status: 500 }) as unknown as Response,
150
+ )
151
+ const el = document.createElement('pkt-footer') as PktFooter
152
+ const events: CustomEvent[] = []
153
+ el.addEventListener('data-error', (e) => events.push(e as CustomEvent))
154
+ document.body.appendChild(el)
155
+ await customElements.whenDefined('pkt-footer')
156
+ await new Promise((resolve) => setTimeout(resolve, 0))
157
+ await el.updateComplete
158
+ expect(events).toHaveLength(1)
159
+ expect(el.querySelector('.pkt-footer__band--global')).toBeNull()
160
+ expect(el.querySelector('footer')?.textContent?.trim()).toBe('')
161
+ })
162
+ })
163
+
164
+ describe('PktFooter sekundær footer (custom innhold)', () => {
165
+ test('rendrer kolonner med tittel, tekst og lenker', async () => {
166
+ const el = await mountFooter({ columns: COLUMNS })
167
+ const columns = [...el.querySelectorAll('.pkt-footer__band--secondary section')]
168
+ expect(columns).toHaveLength(2)
169
+ expect(columns[0].querySelector('h2')?.textContent).toBe('Overskrift 1')
170
+ expect(columns[0].querySelector('p')?.textContent).toContain(
171
+ 'lengre tekst',
172
+ )
173
+ expect(columns[1].querySelector('p')).toBeNull()
174
+ const link = columns[0].querySelector<HTMLAnchorElement>('a')
175
+ expect(link?.getAttribute('href')).toBe('https://example.test/lenke-1')
176
+ expect(link?.getAttribute('target')).toBe('_self')
177
+ })
178
+
179
+ test('eksterne og nye-fane-lenker får riktige attributter', async () => {
180
+ const el = await mountFooter({ columns: COLUMNS })
181
+ const links = [
182
+ ...el.querySelectorAll<HTMLAnchorElement>('.pkt-footer__band--secondary a'),
183
+ ]
184
+ // external-flagget viser toppdomenet i parentes bak lenketeksten
185
+ const external = links.find((l) => l.textContent?.includes('Lenke 2'))
186
+ expect(external?.querySelector('.pkt-footer__link-domain')?.textContent).toBe('(example.test)')
187
+ const newTab = links.find((l) => l.textContent?.includes('Lenke 3'))
188
+ expect(newTab?.querySelector('.pkt-footer__link-domain')).toBeNull()
189
+ expect(newTab?.getAttribute('target')).toBe('_blank')
190
+ expect(newTab?.getAttribute('rel')).toBe('noopener noreferrer')
191
+ })
192
+
193
+ test('openLinksInNewTab åpner alle custom lenker i ny fane', async () => {
194
+ const el = await mountFooter({ columns: COLUMNS, openLinksInNewTab: true })
195
+ const links = [
196
+ ...el.querySelectorAll<HTMLAnchorElement>('.pkt-footer__band--secondary a'),
197
+ ]
198
+ expect(links.every((l) => l.getAttribute('target') === '_blank')).toBe(true)
199
+ })
200
+
201
+ test('global footer rendres under sekundær footer', async () => {
202
+ const el = await mountFooter({ columns: COLUMNS })
203
+ const footer = el.querySelector('footer.pkt-footer')
204
+ const children = [...(footer?.children ?? [])].map((c) => c.className)
205
+ expect(children[0]).toContain('pkt-footer__band--secondary')
206
+ expect(children[1]).toContain('pkt-footer__band--global')
207
+ })
208
+
209
+ test('consent rendres i sekundær footer ved skipGlobal + includeConsent', async () => {
210
+ const el = await mountFooter({ columns: COLUMNS, skipGlobal: true, includeConsent: true })
211
+ const consent = el.querySelector('pkt-consent')
212
+ expect(consent).not.toBeNull()
213
+ expect(consent?.closest('.pkt-footer__band--secondary')).not.toBeNull()
214
+ })
215
+ })
216
+
217
+ describe('PktFooterSimple', () => {
218
+ test('rendrer kompakt lenkerad med pil-ikoner + global footer', async () => {
219
+ const el = await mountSimple({ links: SIMPLE_LINKS })
220
+ const links = [...el.querySelectorAll<HTMLAnchorElement>('.pkt-footer-simple__link')]
221
+ expect(links).toHaveLength(2)
222
+ expect(links[0].querySelector('pkt-icon')?.getAttribute('name')).toBe('arrow-link')
223
+ expect(el.querySelector('.pkt-footer__band--global')).not.toBeNull()
224
+ })
225
+
226
+ test('rendrer kun global footer uten links', async () => {
227
+ const el = await mountSimple()
228
+ expect(el.querySelector('.pkt-footer-simple .pkt-footer__band--secondary')).toBeNull()
229
+ expect(el.querySelector('.pkt-footer__band--global')).not.toBeNull()
230
+ })
231
+
232
+ test('consent rendres som listepunkt ved skipGlobal + includeConsent', async () => {
233
+ const el = await mountSimple({ links: SIMPLE_LINKS, skipGlobal: true, includeConsent: true })
234
+ expect(el.querySelector('.pkt-footer__band--global')).toBeNull()
235
+ const consent = el.querySelector('pkt-consent')
236
+ expect(consent?.closest('.pkt-footer-simple__list')).not.toBeNull()
237
+ })
238
+ })
@@ -0,0 +1,81 @@
1
+ import { html, nothing, type TemplateResult } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+ import type { IPktFooterColumn } from 'shared-types'
4
+ import { PktFooterBaseElement } from './footer-base'
5
+ import type { IPktFooter } from './types'
6
+
7
+ /**
8
+ * PktFooter — footer med valgfritt custom innhold (grått sekundær-bånd)
9
+ * stablet oppå den globale (blå) footeren. Uten `columns` rendres kun den
10
+ * globale footeren. Den globale henter innhold fra Oslo kommunes felles
11
+ * header/footer-endepunkt (samme som `pkt-header type="global"`).
12
+ */
13
+ export class PktFooter extends PktFooterBaseElement<IPktFooter> implements IPktFooter {
14
+ @property({ type: Array }) columns?: IPktFooterColumn[]
15
+
16
+ connectedCallback() {
17
+ super.connectedCallback()
18
+ this.classList.add('pkt-footer-host')
19
+ }
20
+
21
+ private renderColumn(column: IPktFooterColumn): TemplateResult {
22
+ return html`
23
+ <section>
24
+ <h2>${column.title}</h2>
25
+ ${column.text ? html`<p>${column.text}</p>` : nothing}
26
+ ${column.links?.length
27
+ ? html`
28
+ <ul class="pkt-footer__link-list">
29
+ ${column.links.map((link) => html`<li>${this.renderCustomLink(link)}</li>`)}
30
+ </ul>
31
+ `
32
+ : nothing}
33
+ </section>
34
+ `
35
+ }
36
+
37
+ private renderSecondary(): TemplateResult {
38
+ // Med skipGlobal rendres consent-lenken i sekundær-båndet i stedet
39
+ // for i den globale footeren.
40
+ const consentInSecondary = this.skipGlobal && this.includeConsent
41
+ return html`
42
+ <div class="pkt-footer__band pkt-footer__band--secondary">
43
+ <div class="pkt-footer__inner">
44
+ ${this.columns?.length
45
+ ? html`
46
+ <div class="pkt-footer__sections">
47
+ ${this.columns.map((column) => this.renderColumn(column))}
48
+ </div>
49
+ `
50
+ : nothing}
51
+ ${consentInSecondary
52
+ ? html`<div class="pkt-footer__consent">${this.renderConsent()}</div>`
53
+ : nothing}
54
+ </div>
55
+ </div>
56
+ `
57
+ }
58
+
59
+ render() {
60
+ const hasCustomContent = !!this.columns?.length
61
+ const consentInSecondary = this.skipGlobal && this.includeConsent
62
+ return html`
63
+ <footer class="pkt-footer">
64
+ ${hasCustomContent || consentInSecondary ? this.renderSecondary() : nothing}
65
+ ${this.renderGlobalFooter()}
66
+ </footer>
67
+ `
68
+ }
69
+ }
70
+
71
+ declare global {
72
+ interface HTMLElementTagNameMap {
73
+ 'pkt-footer': PktFooter
74
+ }
75
+ }
76
+
77
+ try {
78
+ customElement('pkt-footer')(PktFooter)
79
+ } catch (e) {
80
+ console.warn('Forsøker å definere <pkt-footer>, men den er allerede definert')
81
+ }