import { PktElement } from '@/base-elements/element' 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 { ifDefined } from 'lit/directives/if-defined.js' import { createRef, type Ref, ref } from 'lit/directives/ref.js' import { fetchHeaderFooterData, selectLocaleData } from 'shared-utils/header-footer' import { getDeepActiveElement, getFocusableElementsDeep } from 'shared-utils/focus-trap' import { type IHeaderFooterLocaleData, DEFAULT_HEADER_LOGO_LINK } from 'shared-types' import { type Booleanish, booleanishConverter, type IPktHeaderGlobal, type Representing, type TGlobalHeaderMenu, type THeaderFooterApi, type THeaderMenuLocale, type THeaderPosition, type THeaderScrollBehavior, type TLogOutButtonPlacement, type User, type UserMenuItem, } from './types' import { formatLastLoggedIn } from './header-utils' import { HeaderChromeController } from './header-chrome-controller' import '@/components/button' import '@/components/icon' import '@/components/searchinput' import '@/components/header-menu' import './header-user-menu' const defaultLogoPath = 'https://punkt-cdn.oslo.kommune.no/latest/logos/' if (typeof window !== 'undefined') { window.pktLogoPath = window.pktLogoPath || defaultLogoPath } const MENU_ID = 'pkt-header-global-menu' const MENU_TOGGLE_ID = 'pkt-header-global-menu-toggle' const SEARCH_PANEL_ID = 'pkt-header-global-search-panel' const SEARCH_TOGGLE_ID = 'pkt-header-global-search-toggle' export class PktHeaderGlobal extends PktElement implements IPktHeaderGlobal { @property({ type: String, attribute: 'data-url' }) dataUrl?: string @property({ type: Object, attribute: false }) data?: THeaderFooterApi @property({ type: String }) locale: THeaderMenuLocale = 'nb-NO' @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?: string @property({ type: String }) position: THeaderPosition = 'fixed' @property({ type: String, attribute: 'scroll-behavior' }) scrollBehavior: THeaderScrollBehavior = 'hide' @property({ type: Number, attribute: 'mobile-breakpoint' }) mobileBreakpoint = 768 @property({ type: Number, attribute: 'tablet-breakpoint' }) tabletBreakpoint = 1024 @property({ type: String, attribute: 'log-out-button-placement' }) logOutButtonPlacement: TLogOutButtonPlacement = 'none' @property({ type: Boolean, attribute: 'show-search', converter: booleanishConverter }) showSearch: Booleanish = true @property({ type: Boolean, attribute: 'show-contact', converter: booleanishConverter }) showContact: Booleanish = true @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: TGlobalHeaderMenu = 'none' @state() private fetchedData?: THeaderFooterApi @state() private loadError = false @state() private headerAnchor = { top: 0, left: 0, width: 0 } private headerRef: Ref = createRef() private menuContainerRef: Ref = createRef() private userContainerRef: Ref = createRef() private abortController?: AbortController 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 } connectedCallback() { super.connectedCallback() this.classList.add('pkt-header-global-host') if (this.data) { this.emitDataLoaded(this.data) } else { void this.loadData() } } disconnectedCallback() { super.disconnectedCallback() this.abortController?.abort() document.removeEventListener('mousedown', this.handleClickOutside) document.removeEventListener('keydown', this.handleEscapeKey) document.removeEventListener('keydown', this.handleOverlayKeydown) document.removeEventListener('focusin', this.handleOverlayFocusIn) } firstUpdated() { this.chrome.observe(this.headerRef.value) } willUpdate(changedProperties: PropertyValues) { super.willUpdate(changedProperties) if (this.openMenu === 'menu' || this.openMenu === 'search') { const rect = this.headerRef.value?.getBoundingClientRect() if (rect) { this.headerAnchor = { top: rect.top, left: rect.left, width: rect.width } } } } private get overlayAnchorStyle(): string { const { top, left, width } = this.headerAnchor return `--pkt-header-global-top: ${top}px; --pkt-header-global-left: ${left}px; --pkt-header-global-width: ${width}px` } updated(changedProperties: PropertyValues) { super.updated(changedProperties) if (changedProperties.has('mobileBreakpoint') || changedProperties.has('tabletBreakpoint')) { this.chrome.refreshBreakpoints() } if ( changedProperties.has('dataUrl') && changedProperties.get('dataUrl') !== undefined && !this.data ) { void this.loadData() } if (changedProperties.has('data') && this.data) { this.emitDataLoaded(this.data) } if (changedProperties.has('openMenu')) { const previous = changedProperties.get('openMenu') as TGlobalHeaderMenu | undefined if (this.openMenu !== 'none' && (previous === 'none' || previous === undefined)) { document.addEventListener('mousedown', this.handleClickOutside) document.addEventListener('keydown', this.handleEscapeKey) } else if (this.openMenu === 'none' && previous && previous !== 'none') { document.removeEventListener('mousedown', this.handleClickOutside) document.removeEventListener('keydown', this.handleEscapeKey) this.chrome.restoreFocus() } const wasOverlay = previous === 'menu' || previous === 'search' const isOverlay = this.openMenu === 'menu' || this.openMenu === 'search' if (isOverlay && !wasOverlay) { document.addEventListener('keydown', this.handleOverlayKeydown) document.addEventListener('focusin', this.handleOverlayFocusIn) } else if (!isOverlay && wasOverlay) { document.removeEventListener('keydown', this.handleOverlayKeydown) document.removeEventListener('focusin', this.handleOverlayFocusIn) } if (this.openMenu === 'search' && previous !== 'search') { requestAnimationFrame(() => { this.querySelector(`#${SEARCH_PANEL_ID} input`)?.focus() }) } this.chrome.pauseScrollHide = this.openMenu !== 'none' this.chrome.setScrollLock(this.openMenu === 'menu' || this.openMenu === 'search') } } private async loadData() { this.abortController?.abort() this.abortController = new AbortController() this.loadError = false try { const data = await fetchHeaderFooterData(this.dataUrl, this.abortController.signal) this.fetchedData = data this.emitDataLoaded(data) } catch (error) { if ((error as Error).name === 'AbortError') return this.loadError = true this.dispatchEvent( new CustomEvent('data-error', { detail: { error }, bubbles: true, composed: true }), ) } } private emitDataLoaded(data: THeaderFooterApi) { this.dispatchEvent( new CustomEvent('data-loaded', { detail: { data }, bubbles: true, composed: true }), ) } private get effectiveData(): THeaderFooterApi | undefined { return this.data ?? this.fetchedData } private get localeData(): IHeaderFooterLocaleData | undefined { return selectLocaleData(this.effectiveData, this.locale) } private get shouldHideOnScroll(): boolean { return this.scrollBehavior === 'hide' } private get isFixed(): boolean { return this.position === 'fixed' } private get isSticky(): boolean { return this.position === 'sticky' } private toggleMenu(mode: TGlobalHeaderMenu) { if (this.openMenu === mode) { this.openMenu = 'none' } else { this.chrome.captureFocus() this.openMenu = mode } } private handleClickOutside = (event: MouseEvent) => { const target = event.target as Element if ( this.openMenu === 'user' && !target.closest('.pkt-header-global__user-container') && !target.closest('.pkt-header-global__user-menu') ) { this.openMenu = 'none' } if ( this.openMenu === 'menu' && !target.closest('.pkt-header-menu') && !target.closest(`#${MENU_TOGGLE_ID}`) ) { this.openMenu = 'none' } if ( this.openMenu === 'search' && !target.closest('.pkt-header-global__search-panel-inner') && !target.closest(`#${SEARCH_TOGGLE_ID}`) ) { this.openMenu = 'none' } } private handleEscapeKey = (event: KeyboardEvent) => { if (event.key === 'Escape' && this.openMenu !== 'none') { event.preventDefault() this.chrome.flagRestoreFocus() this.openMenu = 'none' } } private get activeOverlay(): { panel: HTMLElement | null; toggle: HTMLElement | null } | null { if (this.openMenu === 'menu') { return { panel: this.menuContainerRef.value ?? null, toggle: this.querySelector(`#${MENU_TOGGLE_ID}`), } } if (this.openMenu === 'search') { return { panel: this.querySelector(`#${SEARCH_PANEL_ID}`), toggle: this.querySelector(`#${SEARCH_TOGGLE_ID}`), } } return null } private focusToggle(toggle: HTMLElement | null) { ;(toggle?.querySelector('button') ?? toggle)?.focus() } private handleOverlayKeydown = (event: KeyboardEvent) => { if (event.key !== 'Tab') return const overlay = this.activeOverlay if (!overlay || !overlay.panel || !overlay.toggle) return const { panel, toggle } = overlay const focusables = getFocusableElementsDeep(panel) const active = getDeepActiveElement() if (active && toggle.contains(active)) { if (!event.shiftKey && focusables.length > 0) { event.preventDefault() focusables[0].focus() } return } if (focusables.length === 0) return const first = focusables[0] const last = focusables[focusables.length - 1] const tabbingForwardOut = !event.shiftKey && active === last const tabbingBackwardOut = event.shiftKey && active === first if (tabbingForwardOut || tabbingBackwardOut) { event.preventDefault() this.openMenu = 'none' this.focusToggle(toggle) } } private handleOverlayFocusIn = (event: FocusEvent) => { const overlay = this.activeOverlay if (!overlay || !overlay.panel) return const target = event.target as Node | null if (!target) return if (overlay.panel.contains(target) || overlay.toggle?.contains(target)) return this.openMenu = 'none' } private handleSearch(query: string) { this.dispatchEvent( new CustomEvent('search', { detail: { query }, bubbles: true, composed: true }), ) } private handleLogout() { this.dispatchEvent(new CustomEvent('log-out', { bubbles: true, composed: true })) } private handleChangeRepresentation() { this.dispatchEvent(new CustomEvent('change-representation', { bubbles: true, composed: true })) } private get formattedLastLoggedIn(): string | undefined { return formatLastLoggedIn(this.user?.lastLoggedIn) } private get showLogoutInUserMenu(): boolean { return ( this.hasLogOut && (this.logOutButtonPlacement === 'userMenu' || this.logOutButtonPlacement === 'both') ) } private handleLogoClick(e: Event) { this.dispatchEvent( new CustomEvent('logo-click', { bubbles: true, composed: true, detail: { originalEvent: e }, }), ) } private renderLogo() { const logoIcon = html` ` return html` ` } private renderSearch(localeData: IHeaderFooterLocaleData): TemplateResult | typeof nothing { if (!this.showSearch) return nothing const search = localeData.search if (!search) return nothing const placeholder = this.searchPlaceholder ?? search.input.placeholder return html` ` } private renderContact(localeData: IHeaderFooterLocaleData): TemplateResult | typeof nothing { // Hide the contact link when the user menu is showing (a logged-in user). if (!this.showContact || this.user) return nothing const contact = localeData.header?.contact const classes = classMap({ 'pkt-header-global__contact': true, 'pkt-btn': true, 'pkt-btn--secondary': true, 'pkt-btn--label-only': true, 'pkt-btn--small': this.isMobile || this.isTablet, 'pkt-btn--medium': !this.isMobile && !this.isTablet, }) if (!contact) return nothing return html` ${contact.text} ` } private get isLoading(): boolean { return !this.localeData && !this.loadError } private renderMenuToggle(localeData?: IHeaderFooterLocaleData) { const menuText = localeData?.i18n?.menu || 'Meny' const isOpen = this.openMenu === 'menu' return html` this.toggleMenu('menu')} > ${menuText} ` } private renderSearchToggle(localeData: IHeaderFooterLocaleData): TemplateResult | typeof nothing { if (!this.showSearch || !localeData.search) return nothing const isOpen = this.openMenu === 'search' return html` this.toggleMenu('search')} > ${localeData.i18n?.search || 'Søk'} ` } private renderSearchPanel(localeData: IHeaderFooterLocaleData): TemplateResult | typeof nothing { const search = localeData.search if (this.openMenu !== 'search' || !this.showSearch || !search) return nothing const placeholder = this.searchPlaceholder ?? search.input.placeholder const classes = classMap({ 'pkt-header-global__search-panel': true, [`pkt-header-global__search-panel--${this.overlayBreakpointClass}`]: !!this.overlayBreakpointClass, }) return html`
) => this.handleSearch(e.detail.value)} >
` } private renderUser() { if (!this.user) return nothing const isOpen = this.openMenu === 'user' const userMenuClasses = classMap({ 'pkt-header-global__user-menu': true, 'is-open': isOpen, }) return html`
this.toggleMenu('user')} > Brukermeny: ${this.representing?.name || this.user.name} ${isOpen ? html`
this.handleChangeRepresentation()} @log-out=${() => this.handleLogout()} >
` : nothing}
` } private get overlayBreakpointClass(): string { if (this.isMobile) return 'mobile' if (this.isTablet) return 'tablet' return '' } private renderMenu() { if (this.openMenu !== 'menu') return nothing const classes = classMap({ 'pkt-header-global__menu': true, [`pkt-header-global__menu--${this.overlayBreakpointClass}`]: !!this.overlayBreakpointClass, }) return html`
${this.localeData ? html` ` : html` `}
` } private renderHeader() { const headerClasses = classMap({ 'pkt-header-global': true, 'pkt-header-global--mobile': this.isMobile, 'pkt-header-global--tablet': this.isTablet, 'pkt-header-global--fixed': this.isFixed, 'pkt-header-global--sticky': this.isSticky, 'pkt-header-global--scroll-to-hide': this.shouldHideOnScroll, 'pkt-header-global--hidden': this.isHidden, }) const localeData = this.localeData const compact = this.isMobile return html`
${this.renderLogo()}
${!compact && localeData ? this.renderSearch(localeData) : nothing} ${compact && localeData ? this.renderSearchToggle(localeData) : nothing} ${this.renderMenuToggle(localeData)} ${!compact && localeData ? this.renderContact(localeData) : nothing}
${this.renderUser()}
` } render() { const headerElement = this.renderHeader() const localeData = this.localeData const menu = this.renderMenu() const searchPanel = localeData ? this.renderSearchPanel(localeData) : nothing if (this.isFixed) { const spacerClasses = classMap({ 'pkt-header-global-spacer': true, 'pkt-header-global-spacer--mobile': this.isMobile, 'pkt-header-global-spacer--tablet': this.isTablet && !this.isMobile, 'pkt-header-global-spacer--has-user': !!this.user, }) return html`
${headerElement}
${searchPanel}${menu}
` } return html`${headerElement}${searchPanel}${menu}` } } declare global { interface HTMLElementTagNameMap { 'pkt-header-global': PktHeaderGlobal } } try { customElement('pkt-header-global')(PktHeaderGlobal) } catch (e) { console.warn('Forsøker å definere , men den er allerede definert') }