@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.
@@ -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
+ }