import { PktElementWithSlot } from '@/base-elements/element-with-slot' import { html, nothing, type PropertyValues, type TemplateResult } from 'lit' import { customElement, property, state } from 'lit/decorators.js' import { classMap } from 'lit/directives/class-map.js' import { createRef, Ref, ref } from 'lit/directives/ref.js' import { slotContent } from '@/directives/slot-content' import { User, Representing, UserMenuItem, THeaderMenu, TLogOutButtonPlacement, THeaderPosition, THeaderScrollBehavior, TSlotMenuVariant, IPktHeader, Booleanish, booleanishConverter, } from './types' import { formatLastLoggedIn } from './header-utils' import { HeaderChromeController } from './header-chrome-controller' import { DEFAULT_HEADER_LOGO_LINK } from 'shared-types' import '@/components/button' import '@/components/icon' import '@/components/link' import '@/components/textinput' import './header-user-menu' const defaultLogoPath = 'https://punkt-cdn.oslo.kommune.no/latest/logos/' // Allow global override of logo assets path if (typeof window !== 'undefined') { window.pktLogoPath = window.pktLogoPath || defaultLogoPath } export class PktHeaderService extends PktElementWithSlot implements IPktHeader { @property({ type: String, attribute: 'service-name' }) serviceName?: string @property({ type: String, attribute: 'service-link' }) serviceLink?: string @property({ type: String, attribute: 'logo-link' }) logoLink: string = DEFAULT_HEADER_LOGO_LINK @property({ type: String, attribute: 'logo-path' }) logoPath: string | undefined = typeof window !== 'undefined' ? window.pktLogoPath : defaultLogoPath @property({ type: String, attribute: 'search-placeholder' }) searchPlaceholder = 'Søk' @property({ type: String, attribute: 'search-value' }) searchValue = '' @property({ type: Number, attribute: 'mobile-breakpoint' }) mobileBreakpoint: number = 768 @property({ type: Number, attribute: 'tablet-breakpoint' }) tabletBreakpoint: number = 1280 @property({ type: String, attribute: 'opened-menu' }) openedMenu: THeaderMenu = 'none' @property({ type: String, attribute: 'log-out-button-placement' }) logOutButtonPlacement: TLogOutButtonPlacement = 'none' @property({ type: String }) position: THeaderPosition = 'fixed' @property({ type: String, attribute: 'scroll-behavior' }) scrollBehavior: THeaderScrollBehavior = 'hide' @property({ type: String, attribute: 'slot-menu-variant' }) slotMenuVariant: TSlotMenuVariant = 'icon-only' @property({ type: String, attribute: 'slot-menu-text' }) slotMenuText = 'Meny' @property({ type: Boolean, attribute: 'hide-logo', converter: booleanishConverter }) hideLogo: Booleanish = false @property({ type: Boolean, converter: booleanishConverter }) compact: Booleanish = false @property({ type: Boolean, attribute: 'show-search', converter: booleanishConverter }) showSearch: Booleanish = false @property({ type: Boolean, attribute: 'can-change-representation', converter: booleanishConverter, }) canChangeRepresentation: Booleanish = false @property({ type: Boolean, attribute: 'has-log-out', converter: booleanishConverter }) hasLogOut: Booleanish = false @property({ type: Object }) user?: User @property({ type: Array, attribute: 'user-menu' }) userMenu?: UserMenuItem[] @property({ type: Object }) representing?: Representing @state() private openMenu: THeaderMenu = 'none' @state() private alignSlotRight = false @state() private alignSearchRight = false private headerRef: Ref = createRef() private userContainerRef: Ref = createRef() private slotContainerRef: Ref = createRef() private searchContainerRef: Ref = createRef() private slotContentRef: Ref = createRef() private searchMenuRef: Ref = createRef() // Shared shell logic (breakpoints, scroll-to-hide, scroll lock, focus return). private chrome = new HeaderChromeController(this, () => ({ mobileBreakpoint: this.mobileBreakpoint, tabletBreakpoint: this.tabletBreakpoint, position: this.position, scrollBehavior: this.scrollBehavior, })) private get isMobile(): boolean { return this.chrome.isMobile } private get isTablet(): boolean { return this.chrome.isTablet } private get isHidden(): boolean { return this.chrome.isHidden } firstUpdated() { this.chrome.observe(this.headerRef.value) } updated(changedProperties: PropertyValues) { super.updated(changedProperties) if (changedProperties.has('openedMenu') && this.openedMenu !== this.openMenu) { this.openMenu = this.openedMenu } if (changedProperties.has('mobileBreakpoint') || changedProperties.has('tabletBreakpoint')) { this.chrome.refreshBreakpoints() } if (changedProperties.has('openMenu')) { const previousOpenMenu = changedProperties.get('openMenu') as THeaderMenu | undefined if ( this.openMenu !== 'none' && (previousOpenMenu === 'none' || previousOpenMenu === undefined) ) { document.addEventListener('mousedown', this.handleClickOutside) document.addEventListener('keydown', this.handleEscapeKey) if (this.openMenu === 'slot' || this.openMenu === 'search') { requestAnimationFrame(() => { this.checkDropdownAlignment(this.openMenu as 'slot' | 'search') }) } } else if (this.openMenu === 'none' && previousOpenMenu !== 'none') { document.removeEventListener('mousedown', this.handleClickOutside) document.removeEventListener('keydown', this.handleEscapeKey) this.chrome.restoreFocus() } } // Lock background scroll while a menu is open on mobile (recomputed every // update so it also reacts to breakpoint changes from the controller). this.chrome.setScrollLock( (this.isFixed || this.isSticky) && this.isMobile && this.openMenu !== 'none', ) } private handleClickOutside = (event: MouseEvent) => { const target = event.target as Element if ( this.user && this.openMenu === 'user' && !target.closest('.pkt-header-service__user-container') ) { this.openMenu = 'none' } if (this.openMenu === 'slot' && !target.closest('.pkt-header-service__slot-container')) { this.openMenu = 'none' } if ( this.openMenu === 'search' && !target.closest('.pkt-header-service__search-container') && !target.closest('.pkt-header-service__search-input') ) { this.openMenu = 'none' } } // Close the slot menu when the user makes an intentional click inside it. private handleSlotContentClick = (event: MouseEvent) => { if (!this.isTablet || this.openMenu !== 'slot') return const interactive = (event.target as HTMLElement).closest( 'a[href], button, [role="link"], [role="button"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]', ) if (!interactive || !this.slotContentRef.value?.contains(interactive)) return if (interactive.closest('[data-pkt-header-keep-open]')) return if ( (interactive as HTMLButtonElement).disabled || interactive.getAttribute('aria-disabled') === 'true' ) return this.openMenu = 'none' } private handleFocusOut = (event: FocusEvent, menuType: THeaderMenu) => { const relatedTarget = event.relatedTarget as HTMLElement | null let containerRef: Ref switch (menuType) { case 'user': containerRef = this.userContainerRef break case 'slot': containerRef = this.slotContainerRef break case 'search': containerRef = this.searchContainerRef break default: return } const container = containerRef.value if (!container) return if (!relatedTarget || !container.contains(relatedTarget)) { this.openMenu = 'none' } } private handleEscapeKey = (event: KeyboardEvent) => { if (event.key === 'Escape' && this.openMenu !== 'none') { event.preventDefault() this.chrome.flagRestoreFocus() this.openMenu = 'none' } } private checkDropdownAlignment(mode: 'slot' | 'search') { const containerRef = mode === 'slot' ? this.slotContainerRef : this.searchContainerRef const dropdownRef = mode === 'slot' ? this.slotContentRef : this.searchMenuRef if (!containerRef.value || !dropdownRef.value || !this.isTablet || this.isMobile) return const buttonRect = containerRef.value.getBoundingClientRect() const dropdownWidth = dropdownRef.value.offsetWidth const wouldOverflow = buttonRect.left + dropdownWidth > window.innerWidth if (mode === 'slot') { this.alignSlotRight = wouldOverflow } else { this.alignSearchRight = wouldOverflow } } private handleMenuToggle(mode: THeaderMenu) { if (this.openMenu !== 'none') { this.openMenu = 'none' } else { this.chrome.captureFocus() this.openMenu = mode } } private handleLogoClick(e: Event) { this.dispatchEvent( new CustomEvent('logo-click', { bubbles: true, composed: true, detail: { originalEvent: e }, }), ) } private handleServiceClick(e: Event) { this.dispatchEvent( new CustomEvent('service-click', { bubbles: true, composed: true, detail: { originalEvent: e }, }), ) } private handleLogout() { this.dispatchEvent( new CustomEvent('log-out', { bubbles: true, composed: true, }), ) } private handleSearch(query: string) { this.dispatchEvent( new CustomEvent('search', { detail: { query }, bubbles: true, composed: true, }), ) } private handleSearchChange(query: string) { this.dispatchEvent( new CustomEvent('search-change', { detail: { query }, bubbles: true, composed: true, }), ) } private handleSearchInputChange(e: Event) { const value = (e.target as HTMLInputElement).value this.handleSearchChange(value) } private handleSearchKeyDown(e: KeyboardEvent) { if (e.key === 'Enter') { this.handleSearch((e.target as HTMLInputElement).value) } } private get formattedLastLoggedIn(): string | undefined { return formatLastLoggedIn(this.user?.lastLoggedIn) } private get isFixed(): boolean { return this.position === 'fixed' } private get isSticky(): boolean { return this.position === 'sticky' } private get shouldHideOnScroll(): boolean { return this.scrollBehavior === 'hide' } private get showLogoutInHeader(): boolean { return ( this.hasLogOut && (this.logOutButtonPlacement === 'header' || this.logOutButtonPlacement === 'both') ) } private get showLogoutInUserMenu(): boolean { return ( this.hasLogOut && (this.logOutButtonPlacement === 'userMenu' || this.logOutButtonPlacement === 'both') ) } private renderLogo() { if (this.hideLogo) return nothing const logoIcon = html` ` return html` ` } private renderServiceName() { if (!this.serviceName) return nothing // If serviceLink is a non-empty string, render as link (but still dispatch event on click) if (this.serviceLink && typeof this.serviceLink === 'string') { return html` ${this.serviceName} ` } // If service-link attribute is present but empty, render as clickable button if (this.hasAttribute('service-link')) { return html` ` } // No link - just render the text return html` ${this.serviceName} ` } private renderSlotContainer(): TemplateResult | typeof nothing { if (!this.hasSlotContent()) return nothing const slotContainerClasses = classMap({ 'pkt-header-service__slot-container': true, 'is-open': this.openMenu === 'slot', }) const slotContentClasses = classMap({ 'pkt-header-service__slot-content': true, 'align-right': this.alignSlotRight, }) return html`
this.handleFocusOut(e, 'slot')} ${ref(this.slotContainerRef)} > ${this.isTablet && this.hasSlotContent() ? html` this.handleMenuToggle('slot')} aria-expanded=${this.openMenu === 'slot'} aria-controls="mobile-slot-menu" aria-label="Åpne meny" > ${this.slotMenuText} ` : nothing}
${slotContent(this)}
` } private renderSearch() { if (!this.showSearch) return nothing if (this.isTablet) { const searchContainerClasses = classMap({ 'pkt-header-service__search-container': true, 'is-open': this.openMenu === 'search', }) const searchMenuClasses = classMap({ 'pkt-header-service__mobile-menu': true, 'is-open': this.openMenu === 'search', 'align-right': this.alignSearchRight, }) return html`
this.handleFocusOut(e, 'search')} ${ref(this.searchContainerRef)} > this.handleMenuToggle('search')} state=${this.openMenu === 'search' ? 'active' : 'normal'} aria-expanded=${this.openMenu === 'search'} aria-controls="mobile-search-menu" aria-label="Åpne søkefelt" > Søk
${this.openMenu === 'search' ? html` { if (e.key === 'Enter') { this.handleSearch((e.target as HTMLInputElement).value) } }} > ` : nothing}
` } return html` ` } private renderUserButton() { if (!this.user) return nothing const userMenuClasses = classMap({ 'pkt-header-service__user-menu': this.isMobile === false, 'pkt-header-service__mobile-menu': this.isMobile === true, 'is-open': this.openMenu === 'user', }) return html`
this.handleFocusOut(e, 'user')} ${ref(this.userContainerRef)} > this.handleMenuToggle('user')} > Brukermeny: ${this.representing?.name || this.user.name} ${this.openMenu === 'user' && this.user ? html`
this.dispatchEvent( new CustomEvent('change-representation', { bubbles: true, composed: true }), )} @log-out=${this.handleLogout} >
` : nothing}
` } private renderHeader() { const headerClasses = classMap({ 'pkt-header-service': true, 'pkt-header-service--compact': this.compact, 'pkt-header-service--mobile': this.isMobile, 'pkt-header-service--tablet': this.isTablet, 'pkt-header-service--fixed': this.isFixed, 'pkt-header-service--sticky': this.isSticky, 'pkt-header-service--scroll-to-hide': this.shouldHideOnScroll, 'pkt-header-service--hidden': this.isHidden, }) const logoAreaClasses = classMap({ 'pkt-header-service__logo-area': true, 'pkt-header-service__logo-area--without-service': !this.serviceName, }) return html`
${this.renderLogo()} ${this.renderServiceName()}
${this.hasSlotContent() || this.showSearch || this.showLogoutInHeader ? html`
${this.renderSlotContainer()} ${this.renderSearch()} ${this.isTablet && this.showLogoutInHeader ? html` Logg ut ` : nothing}
` : nothing} ${this.user || (!this.isTablet && this.showLogoutInHeader) ? html`
${this.renderUserButton()} ${!this.isTablet && this.showLogoutInHeader ? html` Logg ut ` : nothing}
` : nothing}
` } render() { const headerElement = this.renderHeader() if (this.isFixed) { const spacerClasses = classMap({ 'pkt-header-service-spacer': true, 'pkt-header-service-spacer--compact': this.compact, 'pkt-header-service-spacer--has-user': !!this.user, 'pkt-header-service-spacer--mobile': this.isMobile, 'pkt-header-service-spacer--tablet': this.isTablet, }) return html`
${headerElement}
` } return headerElement } } declare global { interface HTMLElementTagNameMap { 'pkt-header-service': PktHeaderService } } try { customElement('pkt-header-service')(PktHeaderService) } catch (e) { console.warn('Forsøker å definere , men den er allerede definert') }