@oslokommune/punkt-elements 13.22.0 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/dist/header.d.ts +1 -0
- package/dist/index.d.ts +259 -1
- package/dist/pkt-header.cjs +302 -0
- package/dist/pkt-header.js +852 -0
- package/dist/pkt-index.cjs +5 -5
- package/dist/pkt-index.js +62 -58
- package/package.json +4 -4
- package/src/components/header/header-service.test.ts +404 -0
- package/src/components/header/header-service.ts +762 -0
- package/src/components/header/header-user-menu.ts +215 -0
- package/src/components/header/header-utils.ts +20 -0
- package/src/components/header/header.ts +141 -0
- package/src/components/header/index.ts +15 -0
- package/src/components/header/types.ts +151 -0
- package/src/components/index.ts +10 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import { PktElement } from '@/base-elements/element'
|
|
2
|
+
import { PktSlotController } from '@/controllers/pkt-slot-controller'
|
|
3
|
+
import { html, nothing, PropertyValues } from 'lit'
|
|
4
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
5
|
+
import { classMap } from 'lit/directives/class-map.js'
|
|
6
|
+
import { createRef, Ref, ref } from 'lit/directives/ref.js'
|
|
7
|
+
import {
|
|
8
|
+
User,
|
|
9
|
+
Representing,
|
|
10
|
+
UserMenuItem,
|
|
11
|
+
TPktHeaderMenu,
|
|
12
|
+
TLogOutButtonPlacement,
|
|
13
|
+
THeaderPosition,
|
|
14
|
+
THeaderScrollBehavior,
|
|
15
|
+
IPktHeader,
|
|
16
|
+
Booleanish,
|
|
17
|
+
booleanishConverter,
|
|
18
|
+
} from './types'
|
|
19
|
+
import { formatLastLoggedIn } from './header-utils'
|
|
20
|
+
|
|
21
|
+
import '@/components/button'
|
|
22
|
+
import '@/components/icon'
|
|
23
|
+
import '@/components/link'
|
|
24
|
+
import '@/components/textinput'
|
|
25
|
+
import './header-user-menu'
|
|
26
|
+
|
|
27
|
+
const CDN_LOGO_PATH = 'https://punkt-cdn.oslo.kommune.no/latest/logos/'
|
|
28
|
+
|
|
29
|
+
@customElement('pkt-header-service')
|
|
30
|
+
export class PktHeaderService extends PktElement<IPktHeader> implements IPktHeader {
|
|
31
|
+
@property({ type: String, attribute: 'service-name' }) serviceName?: string
|
|
32
|
+
@property({ type: String, attribute: 'service-link' }) serviceLink?: string
|
|
33
|
+
@property({ type: String, attribute: 'logo-link' }) logoLink?: string
|
|
34
|
+
@property({ type: String, attribute: 'search-placeholder' }) searchPlaceholder = 'Søk'
|
|
35
|
+
@property({ type: String, attribute: 'search-value' }) searchValue = ''
|
|
36
|
+
@property({ type: Number, attribute: 'mobile-breakpoint' }) mobileBreakpoint: number = 768
|
|
37
|
+
@property({ type: Number, attribute: 'tablet-breakpoint' }) tabletBreakpoint: number = 1280
|
|
38
|
+
@property({ type: String, attribute: 'opened-menu' }) openedMenu: TPktHeaderMenu = 'none'
|
|
39
|
+
@property({ type: String, attribute: 'log-out-button-placement' })
|
|
40
|
+
logOutButtonPlacement: TLogOutButtonPlacement = 'none'
|
|
41
|
+
@property({ type: String }) position: THeaderPosition = 'fixed'
|
|
42
|
+
@property({ type: String, attribute: 'scroll-behavior' }) scrollBehavior: THeaderScrollBehavior =
|
|
43
|
+
'hide'
|
|
44
|
+
|
|
45
|
+
@property({ type: Boolean, attribute: 'hide-logo', converter: booleanishConverter })
|
|
46
|
+
hideLogo: Booleanish = false
|
|
47
|
+
@property({ type: Boolean, converter: booleanishConverter }) compact: Booleanish = false
|
|
48
|
+
@property({ type: Boolean, attribute: 'show-search', converter: booleanishConverter })
|
|
49
|
+
showSearch: Booleanish = false
|
|
50
|
+
@property({
|
|
51
|
+
type: Boolean,
|
|
52
|
+
attribute: 'can-change-representation',
|
|
53
|
+
converter: booleanishConverter,
|
|
54
|
+
})
|
|
55
|
+
canChangeRepresentation: Booleanish = false
|
|
56
|
+
@property({ type: Boolean, attribute: 'has-log-out', converter: booleanishConverter })
|
|
57
|
+
hasLogOut: Booleanish = false
|
|
58
|
+
|
|
59
|
+
@property({ type: Object }) user?: User
|
|
60
|
+
@property({ type: Array, attribute: 'user-menu' }) userMenu?: UserMenuItem[]
|
|
61
|
+
@property({ type: Object }) representing?: Representing
|
|
62
|
+
|
|
63
|
+
@state() private isMobile = false
|
|
64
|
+
@state() private isTablet = false
|
|
65
|
+
@state() private openMenu: TPktHeaderMenu = 'none'
|
|
66
|
+
@state() private isHidden = false
|
|
67
|
+
@state() private componentWidth = typeof window !== 'undefined' ? window.innerWidth : 0
|
|
68
|
+
@state() private hasSlotContent = false
|
|
69
|
+
@state() private alignSlotRight = false
|
|
70
|
+
@state() private alignSearchRight = false
|
|
71
|
+
|
|
72
|
+
defaultSlot: Ref<HTMLElement> = createRef()
|
|
73
|
+
slotController!: PktSlotController
|
|
74
|
+
|
|
75
|
+
private headerRef: Ref<HTMLElement> = createRef()
|
|
76
|
+
private userContainerRef: Ref<HTMLElement> = createRef()
|
|
77
|
+
private slotContainerRef: Ref<HTMLElement> = createRef()
|
|
78
|
+
private searchContainerRef: Ref<HTMLElement> = createRef()
|
|
79
|
+
private slotContentRef: Ref<HTMLElement> = createRef()
|
|
80
|
+
private searchMenuRef: Ref<HTMLElement> = createRef()
|
|
81
|
+
|
|
82
|
+
private resizeObserver?: ResizeObserver
|
|
83
|
+
private lastScrollPosition = 0
|
|
84
|
+
private savedScrollY = 0
|
|
85
|
+
private lastFocusedElement: HTMLElement | null = null
|
|
86
|
+
private shouldRestoreFocus = false
|
|
87
|
+
|
|
88
|
+
constructor() {
|
|
89
|
+
super()
|
|
90
|
+
this.slotController = new PktSlotController(this, this.defaultSlot)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
updateSlots(filledSlots: Set<string | null | undefined>) {
|
|
94
|
+
this.hasSlotContent = filledSlots.has(null) || filledSlots.has(undefined)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
connectedCallback() {
|
|
98
|
+
super.connectedCallback()
|
|
99
|
+
this.setupScrollListener()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
disconnectedCallback() {
|
|
103
|
+
super.disconnectedCallback()
|
|
104
|
+
this.resizeObserver?.disconnect()
|
|
105
|
+
window.removeEventListener('scroll', this.handleScroll)
|
|
106
|
+
this.unlockScroll()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
firstUpdated() {
|
|
110
|
+
this.setupResizeObserver()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
updated(changedProperties: PropertyValues) {
|
|
114
|
+
super.updated(changedProperties)
|
|
115
|
+
|
|
116
|
+
if (changedProperties.has('openedMenu') && this.openedMenu !== this.openMenu) {
|
|
117
|
+
this.openMenu = this.openedMenu
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (changedProperties.has('mobileBreakpoint') || changedProperties.has('tabletBreakpoint')) {
|
|
121
|
+
this.updateIsMobile()
|
|
122
|
+
this.updateIsTablet()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (changedProperties.has('openMenu')) {
|
|
126
|
+
const previousOpenMenu = changedProperties.get('openMenu') as TPktHeaderMenu | undefined
|
|
127
|
+
if (
|
|
128
|
+
this.openMenu !== 'none' &&
|
|
129
|
+
(previousOpenMenu === 'none' || previousOpenMenu === undefined)
|
|
130
|
+
) {
|
|
131
|
+
document.addEventListener('mousedown', this.handleClickOutside)
|
|
132
|
+
document.addEventListener('keydown', this.handleEscapeKey)
|
|
133
|
+
|
|
134
|
+
if (this.openMenu === 'slot' || this.openMenu === 'search') {
|
|
135
|
+
requestAnimationFrame(() => {
|
|
136
|
+
this.checkDropdownAlignment(this.openMenu as 'slot' | 'search')
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
} else if (this.openMenu === 'none' && previousOpenMenu !== 'none') {
|
|
140
|
+
document.removeEventListener('mousedown', this.handleClickOutside)
|
|
141
|
+
document.removeEventListener('keydown', this.handleEscapeKey)
|
|
142
|
+
this.restoreFocus()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (changedProperties.has('openMenu') || changedProperties.has('isMobile')) {
|
|
147
|
+
this.updateScrollLock()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private setupResizeObserver() {
|
|
152
|
+
const headerElement = this.headerRef.value
|
|
153
|
+
if (!headerElement) return
|
|
154
|
+
|
|
155
|
+
this.componentWidth = headerElement.offsetWidth
|
|
156
|
+
this.updateIsMobile()
|
|
157
|
+
this.updateIsTablet()
|
|
158
|
+
|
|
159
|
+
this.resizeObserver = new ResizeObserver((entries) => {
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
|
162
|
+
this.componentWidth = entry.borderBoxSize[0].inlineSize
|
|
163
|
+
} else {
|
|
164
|
+
this.componentWidth = entry.contentRect.width
|
|
165
|
+
}
|
|
166
|
+
this.updateIsMobile()
|
|
167
|
+
this.updateIsTablet()
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
this.resizeObserver.observe(headerElement)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private updateIsMobile() {
|
|
174
|
+
this.isMobile = this.componentWidth < this.mobileBreakpoint
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private updateIsTablet() {
|
|
178
|
+
this.isTablet = this.componentWidth < this.tabletBreakpoint
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private updateScrollLock() {
|
|
182
|
+
const shouldLock = this.position === 'fixed' && this.isMobile && this.openMenu !== 'none'
|
|
183
|
+
if (shouldLock) {
|
|
184
|
+
this.lockScroll()
|
|
185
|
+
} else {
|
|
186
|
+
this.unlockScroll()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private lockScroll() {
|
|
191
|
+
const body = document.body
|
|
192
|
+
const docEl = document.documentElement
|
|
193
|
+
|
|
194
|
+
this.savedScrollY = window.scrollY || window.pageYOffset
|
|
195
|
+
|
|
196
|
+
const scrollBarWidth = window.innerWidth - docEl.clientWidth
|
|
197
|
+
if (scrollBarWidth > 0) {
|
|
198
|
+
body.style.paddingRight = `${scrollBarWidth}px`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
body.style.position = 'fixed'
|
|
202
|
+
body.style.top = `-${this.savedScrollY}px`
|
|
203
|
+
body.style.left = '0'
|
|
204
|
+
body.style.right = '0'
|
|
205
|
+
body.style.width = '100%'
|
|
206
|
+
body.style.overflow = 'hidden'
|
|
207
|
+
|
|
208
|
+
docEl.classList.add('is-scroll-locked')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private unlockScroll() {
|
|
212
|
+
const body = document.body
|
|
213
|
+
const docEl = document.documentElement
|
|
214
|
+
|
|
215
|
+
if (!docEl.classList.contains('is-scroll-locked')) return
|
|
216
|
+
|
|
217
|
+
body.style.removeProperty('position')
|
|
218
|
+
body.style.removeProperty('top')
|
|
219
|
+
body.style.removeProperty('left')
|
|
220
|
+
body.style.removeProperty('right')
|
|
221
|
+
body.style.removeProperty('width')
|
|
222
|
+
body.style.removeProperty('overflow')
|
|
223
|
+
body.style.removeProperty('padding-right')
|
|
224
|
+
docEl.classList.remove('is-scroll-locked')
|
|
225
|
+
|
|
226
|
+
window.scrollTo({ top: this.savedScrollY })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private setupScrollListener() {
|
|
230
|
+
window.addEventListener('scroll', this.handleScroll)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private handleScroll = () => {
|
|
234
|
+
if (!this.shouldHideOnScroll) return
|
|
235
|
+
|
|
236
|
+
const currentScrollPosition = window.pageYOffset || document.documentElement.scrollTop
|
|
237
|
+
if (currentScrollPosition < 0) return
|
|
238
|
+
if (Math.abs(currentScrollPosition - this.lastScrollPosition) < 60) return
|
|
239
|
+
|
|
240
|
+
this.isHidden = currentScrollPosition > this.lastScrollPosition
|
|
241
|
+
this.lastScrollPosition = currentScrollPosition
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private handleClickOutside = (event: MouseEvent) => {
|
|
245
|
+
const target = event.target as Element
|
|
246
|
+
|
|
247
|
+
if (
|
|
248
|
+
this.user &&
|
|
249
|
+
this.openMenu === 'user' &&
|
|
250
|
+
!target.closest('.pkt-header-service__user-container')
|
|
251
|
+
) {
|
|
252
|
+
this.openMenu = 'none'
|
|
253
|
+
}
|
|
254
|
+
if (this.openMenu === 'slot' && !target.closest('.pkt-header-service__slot-container')) {
|
|
255
|
+
this.openMenu = 'none'
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
this.openMenu === 'search' &&
|
|
259
|
+
!target.closest('.pkt-header-service__search-container') &&
|
|
260
|
+
!target.closest('.pkt-header-service__search-input')
|
|
261
|
+
) {
|
|
262
|
+
this.openMenu = 'none'
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private handleFocusOut = (event: FocusEvent, menuType: TPktHeaderMenu) => {
|
|
267
|
+
const relatedTarget = event.relatedTarget as HTMLElement | null
|
|
268
|
+
|
|
269
|
+
let containerRef: Ref<HTMLElement>
|
|
270
|
+
switch (menuType) {
|
|
271
|
+
case 'user':
|
|
272
|
+
containerRef = this.userContainerRef
|
|
273
|
+
break
|
|
274
|
+
case 'slot':
|
|
275
|
+
containerRef = this.slotContainerRef
|
|
276
|
+
break
|
|
277
|
+
case 'search':
|
|
278
|
+
containerRef = this.searchContainerRef
|
|
279
|
+
break
|
|
280
|
+
default:
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const container = containerRef.value
|
|
285
|
+
if (!container) return
|
|
286
|
+
|
|
287
|
+
if (!relatedTarget || !container.contains(relatedTarget)) {
|
|
288
|
+
this.openMenu = 'none'
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private handleEscapeKey = (event: KeyboardEvent) => {
|
|
293
|
+
if (event.key === 'Escape' && this.openMenu !== 'none') {
|
|
294
|
+
event.preventDefault()
|
|
295
|
+
this.shouldRestoreFocus = true
|
|
296
|
+
this.openMenu = 'none'
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private restoreFocus() {
|
|
301
|
+
if (
|
|
302
|
+
this.shouldRestoreFocus &&
|
|
303
|
+
this.lastFocusedElement &&
|
|
304
|
+
document.contains(this.lastFocusedElement)
|
|
305
|
+
) {
|
|
306
|
+
this.lastFocusedElement.focus()
|
|
307
|
+
}
|
|
308
|
+
this.lastFocusedElement = null
|
|
309
|
+
this.shouldRestoreFocus = false
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private checkDropdownAlignment(mode: 'slot' | 'search') {
|
|
313
|
+
const containerRef = mode === 'slot' ? this.slotContainerRef : this.searchContainerRef
|
|
314
|
+
const dropdownRef = mode === 'slot' ? this.slotContentRef : this.searchMenuRef
|
|
315
|
+
if (!containerRef.value || !dropdownRef.value || !this.isTablet || this.isMobile) return
|
|
316
|
+
|
|
317
|
+
const buttonRect = containerRef.value.getBoundingClientRect()
|
|
318
|
+
const dropdownWidth = dropdownRef.value.offsetWidth
|
|
319
|
+
const wouldOverflow = buttonRect.left + dropdownWidth > window.innerWidth
|
|
320
|
+
|
|
321
|
+
if (mode === 'slot') {
|
|
322
|
+
this.alignSlotRight = wouldOverflow
|
|
323
|
+
} else {
|
|
324
|
+
this.alignSearchRight = wouldOverflow
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private handleMenuToggle(mode: TPktHeaderMenu) {
|
|
329
|
+
if (this.openMenu !== 'none') {
|
|
330
|
+
this.openMenu = 'none'
|
|
331
|
+
} else {
|
|
332
|
+
this.lastFocusedElement = document.activeElement as HTMLElement
|
|
333
|
+
|
|
334
|
+
this.openMenu = mode
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private handleLogoClick(e: Event) {
|
|
339
|
+
this.dispatchEvent(
|
|
340
|
+
new CustomEvent('logo-click', {
|
|
341
|
+
bubbles: true,
|
|
342
|
+
composed: true,
|
|
343
|
+
detail: { originalEvent: e },
|
|
344
|
+
}),
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private handleServiceClick(e: Event) {
|
|
349
|
+
this.dispatchEvent(
|
|
350
|
+
new CustomEvent('service-click', {
|
|
351
|
+
bubbles: true,
|
|
352
|
+
composed: true,
|
|
353
|
+
detail: { originalEvent: e },
|
|
354
|
+
}),
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private handleLogout() {
|
|
359
|
+
this.dispatchEvent(
|
|
360
|
+
new CustomEvent('log-out', {
|
|
361
|
+
bubbles: true,
|
|
362
|
+
composed: true,
|
|
363
|
+
}),
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private handleSearch(query: string) {
|
|
368
|
+
this.dispatchEvent(
|
|
369
|
+
new CustomEvent('search', {
|
|
370
|
+
detail: { query },
|
|
371
|
+
bubbles: true,
|
|
372
|
+
composed: true,
|
|
373
|
+
}),
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private handleSearchChange(query: string) {
|
|
378
|
+
this.dispatchEvent(
|
|
379
|
+
new CustomEvent('search-change', {
|
|
380
|
+
detail: { query },
|
|
381
|
+
bubbles: true,
|
|
382
|
+
composed: true,
|
|
383
|
+
}),
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private handleSearchInputChange(e: Event) {
|
|
388
|
+
const value = (e.target as HTMLInputElement).value
|
|
389
|
+
this.handleSearchChange(value)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private handleSearchKeyDown(e: KeyboardEvent) {
|
|
393
|
+
if (e.key === 'Enter') {
|
|
394
|
+
this.handleSearch((e.target as HTMLInputElement).value)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private get formattedLastLoggedIn(): string | undefined {
|
|
399
|
+
return formatLastLoggedIn(this.user?.lastLoggedIn)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private get isFixed(): boolean {
|
|
403
|
+
return this.position === 'fixed'
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private get shouldHideOnScroll(): boolean {
|
|
407
|
+
return this.scrollBehavior === 'hide'
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private get showLogoutInHeader(): boolean {
|
|
411
|
+
return (
|
|
412
|
+
this.hasLogOut &&
|
|
413
|
+
(this.logOutButtonPlacement === 'header' || this.logOutButtonPlacement === 'both')
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private get showLogoutInUserMenu(): boolean {
|
|
418
|
+
return (
|
|
419
|
+
this.hasLogOut &&
|
|
420
|
+
(this.logOutButtonPlacement === 'userMenu' || this.logOutButtonPlacement === 'both')
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private renderLogo() {
|
|
425
|
+
if (this.hideLogo) return nothing
|
|
426
|
+
|
|
427
|
+
const logoIcon = html`
|
|
428
|
+
<pkt-icon name="oslologo" aria-hidden="true" path=${CDN_LOGO_PATH}></pkt-icon>
|
|
429
|
+
`
|
|
430
|
+
|
|
431
|
+
// If logoLink is a non-empty string, render as link
|
|
432
|
+
if (this.logoLink && typeof this.logoLink === 'string') {
|
|
433
|
+
return html`
|
|
434
|
+
<span class="pkt-header-service__logo">
|
|
435
|
+
<a href=${this.logoLink} aria-label="Tilbake til forside" @click=${this.handleLogoClick}>
|
|
436
|
+
${logoIcon}
|
|
437
|
+
</a>
|
|
438
|
+
</span>
|
|
439
|
+
`
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// If logo-link attribute is present but empty, render as clickable button
|
|
443
|
+
if (this.hasAttribute('logo-link')) {
|
|
444
|
+
return html`
|
|
445
|
+
<span class="pkt-header-service__logo">
|
|
446
|
+
<button
|
|
447
|
+
aria-label="Tilbake til forside"
|
|
448
|
+
class="pkt-link-button pkt-link pkt-header-service__logo-link"
|
|
449
|
+
@click=${this.handleLogoClick}
|
|
450
|
+
>
|
|
451
|
+
${logoIcon}
|
|
452
|
+
</button>
|
|
453
|
+
</span>
|
|
454
|
+
`
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return html`
|
|
458
|
+
<span class="pkt-header-service__logo" @click=${this.handleLogoClick}>${logoIcon}</span>
|
|
459
|
+
`
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private renderServiceName() {
|
|
463
|
+
if (!this.serviceName) return nothing
|
|
464
|
+
|
|
465
|
+
// If serviceLink is a non-empty string, render as link (but still dispatch event on click)
|
|
466
|
+
if (this.serviceLink && typeof this.serviceLink === 'string') {
|
|
467
|
+
return html`
|
|
468
|
+
<span class="pkt-header-service__service-name">
|
|
469
|
+
<pkt-link
|
|
470
|
+
href=${this.serviceLink}
|
|
471
|
+
class="pkt-header-service__service-link"
|
|
472
|
+
@click=${this.handleServiceClick}
|
|
473
|
+
>
|
|
474
|
+
${this.serviceName}
|
|
475
|
+
</pkt-link>
|
|
476
|
+
</span>
|
|
477
|
+
`
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// If service-link attribute is present but empty, render as clickable button
|
|
481
|
+
if (this.hasAttribute('service-link')) {
|
|
482
|
+
return html`
|
|
483
|
+
<span class="pkt-header-service__service-name">
|
|
484
|
+
<button
|
|
485
|
+
class="pkt-link-button pkt-link pkt-header-service__service-link"
|
|
486
|
+
@click=${this.handleServiceClick}
|
|
487
|
+
>
|
|
488
|
+
${this.serviceName}
|
|
489
|
+
</button>
|
|
490
|
+
</span>
|
|
491
|
+
`
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// No link - just render the text
|
|
495
|
+
return html`
|
|
496
|
+
<span class="pkt-header-service__service-name" @click=${this.handleServiceClick}>
|
|
497
|
+
<span class="pkt-header-service__service-link">${this.serviceName}</span>
|
|
498
|
+
</span>
|
|
499
|
+
`
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private renderSlotContainer() {
|
|
503
|
+
const slotContainerClasses = classMap({
|
|
504
|
+
'pkt-header-service__slot-container': true,
|
|
505
|
+
'is-open': this.openMenu === 'slot',
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
const slotContentClasses = classMap({
|
|
509
|
+
'pkt-header-service__slot-content': true,
|
|
510
|
+
'align-right': this.alignSlotRight,
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
return html`
|
|
514
|
+
<div
|
|
515
|
+
class=${slotContainerClasses}
|
|
516
|
+
@focusout=${(e: FocusEvent) => this.handleFocusOut(e, 'slot')}
|
|
517
|
+
${ref(this.slotContainerRef)}
|
|
518
|
+
>
|
|
519
|
+
${this.isTablet && this.hasSlotContent
|
|
520
|
+
? html`
|
|
521
|
+
<pkt-button
|
|
522
|
+
skin="secondary"
|
|
523
|
+
variant="icon-only"
|
|
524
|
+
iconName="menu"
|
|
525
|
+
size=${this.isMobile ? 'small' : 'medium'}
|
|
526
|
+
state=${this.openMenu === 'slot' ? 'active' : 'normal'}
|
|
527
|
+
@click=${() => this.handleMenuToggle('slot')}
|
|
528
|
+
aria-expanded=${this.openMenu === 'slot'}
|
|
529
|
+
aria-controls="mobile-slot-menu"
|
|
530
|
+
aria-label="Åpne meny"
|
|
531
|
+
>
|
|
532
|
+
Meny
|
|
533
|
+
</pkt-button>
|
|
534
|
+
`
|
|
535
|
+
: nothing}
|
|
536
|
+
<div
|
|
537
|
+
class=${slotContentClasses}
|
|
538
|
+
id="mobile-slot-menu"
|
|
539
|
+
role=${this.isTablet ? 'menu' : nothing}
|
|
540
|
+
aria-label=${this.isTablet ? 'Meny' : nothing}
|
|
541
|
+
${ref(this.slotContentRef)}
|
|
542
|
+
${ref(this.defaultSlot)}
|
|
543
|
+
></div>
|
|
544
|
+
</div>
|
|
545
|
+
`
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private renderSearch() {
|
|
549
|
+
if (!this.showSearch) return nothing
|
|
550
|
+
|
|
551
|
+
if (this.isTablet) {
|
|
552
|
+
const searchContainerClasses = classMap({
|
|
553
|
+
'pkt-header-service__search-container': true,
|
|
554
|
+
'is-open': this.openMenu === 'search',
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
const searchMenuClasses = classMap({
|
|
558
|
+
'pkt-header-service__mobile-menu': true,
|
|
559
|
+
'is-open': this.openMenu === 'search',
|
|
560
|
+
'align-right': this.alignSearchRight,
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
return html`
|
|
564
|
+
<div
|
|
565
|
+
class=${searchContainerClasses}
|
|
566
|
+
@focusout=${(e: FocusEvent) => this.handleFocusOut(e, 'search')}
|
|
567
|
+
${ref(this.searchContainerRef)}
|
|
568
|
+
>
|
|
569
|
+
<pkt-button
|
|
570
|
+
skin="secondary"
|
|
571
|
+
variant="icon-only"
|
|
572
|
+
iconName="magnifying-glass-big"
|
|
573
|
+
size=${this.isMobile ? 'small' : 'medium'}
|
|
574
|
+
@click=${() => this.handleMenuToggle('search')}
|
|
575
|
+
state=${this.openMenu === 'search' ? 'active' : 'normal'}
|
|
576
|
+
aria-expanded=${this.openMenu === 'search'}
|
|
577
|
+
aria-controls="mobile-search-menu"
|
|
578
|
+
aria-label="Åpne søkefelt"
|
|
579
|
+
>
|
|
580
|
+
Søk
|
|
581
|
+
</pkt-button>
|
|
582
|
+
<div class=${searchMenuClasses} ${ref(this.searchMenuRef)}>
|
|
583
|
+
${this.openMenu === 'search'
|
|
584
|
+
? html`
|
|
585
|
+
<pkt-textinput
|
|
586
|
+
id="mobile-search-menu"
|
|
587
|
+
class="pkt-header-service__search-input"
|
|
588
|
+
type="search"
|
|
589
|
+
label="Søk"
|
|
590
|
+
useWrapper="false"
|
|
591
|
+
placeholder=${this.searchPlaceholder}
|
|
592
|
+
value=${this.searchValue}
|
|
593
|
+
autofocus
|
|
594
|
+
fullwidth
|
|
595
|
+
@input=${this.handleSearchInputChange}
|
|
596
|
+
@keydown=${(e: KeyboardEvent) => {
|
|
597
|
+
if (e.key === 'Enter') {
|
|
598
|
+
this.handleSearch((e.target as HTMLInputElement).value)
|
|
599
|
+
}
|
|
600
|
+
}}
|
|
601
|
+
></pkt-textinput>
|
|
602
|
+
`
|
|
603
|
+
: nothing}
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
`
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return html`
|
|
610
|
+
<pkt-textinput
|
|
611
|
+
id="header-service-search"
|
|
612
|
+
class="pkt-header-service__search-input"
|
|
613
|
+
type="search"
|
|
614
|
+
label="Søk"
|
|
615
|
+
useWrapper="false"
|
|
616
|
+
placeholder=${this.searchPlaceholder}
|
|
617
|
+
value=${this.searchValue}
|
|
618
|
+
@input=${this.handleSearchInputChange}
|
|
619
|
+
@keydown=${this.handleSearchKeyDown}
|
|
620
|
+
></pkt-textinput>
|
|
621
|
+
`
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private renderUserButton() {
|
|
625
|
+
if (!this.user) return nothing
|
|
626
|
+
|
|
627
|
+
const userMenuClasses = classMap({
|
|
628
|
+
'pkt-header-service__user-menu': this.isMobile === false,
|
|
629
|
+
'pkt-header-service__mobile-menu': this.isMobile === true,
|
|
630
|
+
'is-open': this.openMenu === 'user',
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
return html`
|
|
634
|
+
<div
|
|
635
|
+
class="pkt-header-service__user-container"
|
|
636
|
+
@focusout=${(e: FocusEvent) => this.handleFocusOut(e, 'user')}
|
|
637
|
+
${ref(this.userContainerRef)}
|
|
638
|
+
>
|
|
639
|
+
<pkt-button
|
|
640
|
+
class=${classMap({
|
|
641
|
+
'pkt-header-service__user-button': true,
|
|
642
|
+
'pkt-header-service__user-button--mobile': this.isMobile,
|
|
643
|
+
})}
|
|
644
|
+
skin="secondary"
|
|
645
|
+
size=${this.isMobile ? 'small' : 'medium'}
|
|
646
|
+
state=${this.openMenu === 'user' ? 'active' : 'normal'}
|
|
647
|
+
variant="icons-right-and-left"
|
|
648
|
+
iconName="user"
|
|
649
|
+
secondIconName=${this.openMenu === 'user' ? 'chevron-thin-up' : 'chevron-thin-down'}
|
|
650
|
+
@click=${() => this.handleMenuToggle('user')}
|
|
651
|
+
>
|
|
652
|
+
<span class="pkt-sr-only">Brukermeny: </span>
|
|
653
|
+
${this.representing?.name || this.user.name}
|
|
654
|
+
</pkt-button>
|
|
655
|
+
${this.openMenu === 'user' && this.user
|
|
656
|
+
? html`
|
|
657
|
+
<div class=${userMenuClasses}>
|
|
658
|
+
<pkt-header-user-menu
|
|
659
|
+
.user=${this.user}
|
|
660
|
+
formatted-last-logged-in=${this.formattedLastLoggedIn || nothing}
|
|
661
|
+
.representing=${this.representing}
|
|
662
|
+
.userMenu=${this.userMenu}
|
|
663
|
+
?can-change-representation=${this.canChangeRepresentation}
|
|
664
|
+
?logout-on-click=${this.showLogoutInUserMenu}
|
|
665
|
+
@change-representation=${() =>
|
|
666
|
+
this.dispatchEvent(
|
|
667
|
+
new CustomEvent('change-representation', { bubbles: true, composed: true }),
|
|
668
|
+
)}
|
|
669
|
+
@log-out=${this.handleLogout}
|
|
670
|
+
></pkt-header-user-menu>
|
|
671
|
+
</div>
|
|
672
|
+
`
|
|
673
|
+
: nothing}
|
|
674
|
+
</div>
|
|
675
|
+
`
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private renderHeader() {
|
|
679
|
+
const headerClasses = classMap({
|
|
680
|
+
'pkt-header-service': true,
|
|
681
|
+
'pkt-header-service--compact': this.compact,
|
|
682
|
+
'pkt-header-service--mobile': this.isMobile,
|
|
683
|
+
'pkt-header-service--tablet': this.isTablet,
|
|
684
|
+
'pkt-header-service--fixed': this.isFixed,
|
|
685
|
+
'pkt-header-service--scroll-to-hide': this.shouldHideOnScroll,
|
|
686
|
+
'pkt-header-service--hidden': this.isHidden,
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
const logoAreaClasses = classMap({
|
|
690
|
+
'pkt-header-service__logo-area': true,
|
|
691
|
+
'pkt-header-service__logo-area--without-service': !this.serviceName,
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
return html`
|
|
695
|
+
<header class=${headerClasses} ${ref(this.headerRef)}>
|
|
696
|
+
<div class=${logoAreaClasses}>${this.renderLogo()} ${this.renderServiceName()}</div>
|
|
697
|
+
|
|
698
|
+
<div class="pkt-header-service__content">
|
|
699
|
+
${this.renderSlotContainer()} ${this.renderSearch()}
|
|
700
|
+
${this.isTablet && this.showLogoutInHeader
|
|
701
|
+
? html`
|
|
702
|
+
<pkt-button
|
|
703
|
+
skin="secondary"
|
|
704
|
+
size=${this.isMobile ? 'small' : 'medium'}
|
|
705
|
+
variant="icon-only"
|
|
706
|
+
iconName="exit"
|
|
707
|
+
@click=${this.handleLogout}
|
|
708
|
+
>
|
|
709
|
+
Logg ut
|
|
710
|
+
</pkt-button>
|
|
711
|
+
`
|
|
712
|
+
: nothing}
|
|
713
|
+
</div>
|
|
714
|
+
|
|
715
|
+
<div class="pkt-header-service__user">
|
|
716
|
+
${this.renderUserButton()}
|
|
717
|
+
${!this.isTablet && this.showLogoutInHeader
|
|
718
|
+
? html`
|
|
719
|
+
<pkt-button
|
|
720
|
+
skin="tertiary"
|
|
721
|
+
size="medium"
|
|
722
|
+
variant="icon-right"
|
|
723
|
+
iconName="exit"
|
|
724
|
+
@click=${this.handleLogout}
|
|
725
|
+
>
|
|
726
|
+
Logg ut
|
|
727
|
+
</pkt-button>
|
|
728
|
+
`
|
|
729
|
+
: nothing}
|
|
730
|
+
</div>
|
|
731
|
+
</header>
|
|
732
|
+
`
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
render() {
|
|
736
|
+
const headerElement = this.renderHeader()
|
|
737
|
+
|
|
738
|
+
if (this.isFixed) {
|
|
739
|
+
const spacerClasses = classMap({
|
|
740
|
+
'pkt-header-service-spacer': true,
|
|
741
|
+
'pkt-header-service-spacer--compact': this.compact,
|
|
742
|
+
'pkt-header-service-spacer--has-user': !!this.user,
|
|
743
|
+
'pkt-header-service-spacer--mobile': this.isMobile,
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
return html`
|
|
747
|
+
<div class="pkt-header-service-wrapper">
|
|
748
|
+
${headerElement}
|
|
749
|
+
<div class=${spacerClasses}></div>
|
|
750
|
+
</div>
|
|
751
|
+
`
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return headerElement
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
declare global {
|
|
759
|
+
interface HTMLElementTagNameMap {
|
|
760
|
+
'pkt-header-service': PktHeaderService
|
|
761
|
+
}
|
|
762
|
+
}
|