UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

765 lines (673 loc) 24.4 kB
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 '@/components/button' import '@/components/icon' import '@/components/link' import '@/components/textinput' import './header-user-menu' const CDN_LOGO_PATH = 'https://punkt-cdn.oslo.kommune.no/latest/logos/' export class PktHeaderService extends PktElementWithSlot<IPktHeader> 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 @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 isMobile = false @state() private isTablet = false @state() private openMenu: THeaderMenu = 'none' @state() private isHidden = false @state() private componentWidth = typeof window !== 'undefined' ? window.innerWidth : 0 @state() private alignSlotRight = false @state() private alignSearchRight = false private headerRef: Ref<HTMLElement> = createRef() private userContainerRef: Ref<HTMLElement> = createRef() private slotContainerRef: Ref<HTMLElement> = createRef() private searchContainerRef: Ref<HTMLElement> = createRef() private slotContentRef: Ref<HTMLElement> = createRef() private searchMenuRef: Ref<HTMLElement> = createRef() private resizeObserver?: ResizeObserver private lastScrollPosition = 0 private savedScrollY = 0 private lastFocusedElement: HTMLElement | null = null private shouldRestoreFocus = false connectedCallback() { super.connectedCallback() this.setupScrollListener() } disconnectedCallback() { super.disconnectedCallback() this.resizeObserver?.disconnect() window.removeEventListener('scroll', this.handleScroll) this.unlockScroll() } firstUpdated() { this.setupResizeObserver() } 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.updateIsMobile() this.updateIsTablet() } 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.restoreFocus() } } if (changedProperties.has('openMenu') || changedProperties.has('isMobile')) { this.updateScrollLock() } } private setupResizeObserver() { const headerElement = this.headerRef.value if (!headerElement) return this.componentWidth = headerElement.offsetWidth this.updateIsMobile() this.updateIsTablet() this.resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { if (entry.borderBoxSize && entry.borderBoxSize.length > 0) { this.componentWidth = entry.borderBoxSize[0].inlineSize } else { this.componentWidth = entry.contentRect.width } this.updateIsMobile() this.updateIsTablet() } }) this.resizeObserver.observe(headerElement) } private updateIsMobile() { this.isMobile = this.componentWidth < this.mobileBreakpoint } private updateIsTablet() { this.isTablet = this.componentWidth < this.tabletBreakpoint } private updateScrollLock() { const shouldLock = this.position === 'fixed' && this.isMobile && this.openMenu !== 'none' if (shouldLock) { this.lockScroll() } else { this.unlockScroll() } } private lockScroll() { const body = document.body const docEl = document.documentElement this.savedScrollY = window.scrollY || window.pageYOffset const scrollBarWidth = window.innerWidth - docEl.clientWidth if (scrollBarWidth > 0) { body.style.paddingRight = `${scrollBarWidth}px` } body.style.position = 'fixed' body.style.top = `-${this.savedScrollY}px` body.style.left = '0' body.style.right = '0' body.style.width = '100%' body.style.overflow = 'hidden' docEl.classList.add('is-scroll-locked') } private unlockScroll() { const body = document.body const docEl = document.documentElement if (!docEl.classList.contains('is-scroll-locked')) return body.style.removeProperty('position') body.style.removeProperty('top') body.style.removeProperty('left') body.style.removeProperty('right') body.style.removeProperty('width') body.style.removeProperty('overflow') body.style.removeProperty('padding-right') docEl.classList.remove('is-scroll-locked') window.scrollTo({ top: this.savedScrollY }) } private setupScrollListener() { window.addEventListener('scroll', this.handleScroll) } private handleScroll = () => { if (!this.shouldHideOnScroll) return const currentScrollPosition = window.pageYOffset || document.documentElement.scrollTop if (currentScrollPosition < 0) return if (Math.abs(currentScrollPosition - this.lastScrollPosition) < 60) return this.isHidden = currentScrollPosition > this.lastScrollPosition this.lastScrollPosition = currentScrollPosition } 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' } } private handleFocusOut = (event: FocusEvent, menuType: THeaderMenu) => { const relatedTarget = event.relatedTarget as HTMLElement | null let containerRef: Ref<HTMLElement> 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.shouldRestoreFocus = true this.openMenu = 'none' } } private restoreFocus() { if ( this.shouldRestoreFocus && this.lastFocusedElement && document.contains(this.lastFocusedElement) ) { this.lastFocusedElement.focus() } this.lastFocusedElement = null this.shouldRestoreFocus = false } 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.lastFocusedElement = document.activeElement as HTMLElement 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 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` <pkt-icon name="oslologo" aria-hidden="true" path=${CDN_LOGO_PATH}></pkt-icon> ` // If logoLink is a non-empty string, render as link if (this.logoLink && typeof this.logoLink === 'string') { return html` <span class="pkt-header-service__logo"> <a href=${this.logoLink} aria-label="Tilbake til forside" @click=${this.handleLogoClick}> ${logoIcon} </a> </span> ` } // If logo-link attribute is present but empty, render as clickable button if (this.hasAttribute('logo-link')) { return html` <span class="pkt-header-service__logo"> <button aria-label="Tilbake til forside" class="pkt-link-button pkt-link pkt-header-service__logo-link" @click=${this.handleLogoClick} > ${logoIcon} </button> </span> ` } return html` <span class="pkt-header-service__logo" @click=${this.handleLogoClick}>${logoIcon}</span> ` } 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` <span class="pkt-header-service__service-name"> <pkt-link href=${this.serviceLink} class="pkt-header-service__service-link" @click=${this.handleServiceClick} > ${this.serviceName} </pkt-link> </span> ` } // If service-link attribute is present but empty, render as clickable button if (this.hasAttribute('service-link')) { return html` <span class="pkt-header-service__service-name"> <button class="pkt-link-button pkt-link pkt-header-service__service-link" @click=${this.handleServiceClick} > ${this.serviceName} </button> </span> ` } // No link - just render the text return html` <span class="pkt-header-service__service-name" @click=${this.handleServiceClick}> <span class="pkt-header-service__service-link">${this.serviceName}</span> </span> ` } 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` <div class=${slotContainerClasses} @focusout=${(e: FocusEvent) => this.handleFocusOut(e, 'slot')} ${ref(this.slotContainerRef)} > ${this.isTablet && this.hasSlotContent() ? html` <pkt-button skin="secondary" variant=${this.slotMenuVariant} iconName="menu" size=${this.isMobile ? 'small' : 'medium'} state=${this.openMenu === 'slot' ? 'active' : 'normal'} @click=${() => this.handleMenuToggle('slot')} aria-expanded=${this.openMenu === 'slot'} aria-controls="mobile-slot-menu" aria-label="Åpne meny" > ${this.slotMenuText} </pkt-button> ` : nothing} <div class=${slotContentClasses} id="mobile-slot-menu" role=${this.isTablet ? 'menu' : nothing} aria-label=${this.isTablet ? 'Meny' : nothing} ${ref(this.slotContentRef)} > ${slotContent(this)} </div> </div> ` } 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` <div class=${searchContainerClasses} @focusout=${(e: FocusEvent) => this.handleFocusOut(e, 'search')} ${ref(this.searchContainerRef)} > <pkt-button skin="secondary" variant="icon-only" iconName="magnifying-glass-big" size=${this.isMobile ? 'small' : 'medium'} @click=${() => 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 </pkt-button> <div class=${searchMenuClasses} ${ref(this.searchMenuRef)}> ${this.openMenu === 'search' ? html` <pkt-textinput id="mobile-search-menu" class="pkt-header-service__search-input" type="search" label="Søk" useWrapper="false" placeholder=${this.searchPlaceholder} value=${this.searchValue} autofocus fullwidth @input=${this.handleSearchInputChange} @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') { this.handleSearch((e.target as HTMLInputElement).value) } }} ></pkt-textinput> ` : nothing} </div> </div> ` } return html` <pkt-textinput id="header-service-search" class="pkt-header-service__search-input" type="search" label="Søk" useWrapper="false" placeholder=${this.searchPlaceholder} value=${this.searchValue} @input=${this.handleSearchInputChange} @keydown=${this.handleSearchKeyDown} ></pkt-textinput> ` } 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` <div class="pkt-header-service__user-container" @focusout=${(e: FocusEvent) => this.handleFocusOut(e, 'user')} ${ref(this.userContainerRef)} > <pkt-button class=${classMap({ 'pkt-header-service__user-button': true, 'pkt-header-service__user-button--mobile': this.isMobile, })} skin="secondary" size=${this.isMobile ? 'small' : 'medium'} state=${this.openMenu === 'user' ? 'active' : 'normal'} variant="icons-right-and-left" iconName="user" secondIconName=${this.openMenu === 'user' ? 'chevron-thin-up' : 'chevron-thin-down'} @click=${() => this.handleMenuToggle('user')} > <span class="pkt-sr-only">Brukermeny: </span> <span>${this.representing?.name || this.user.name}</span> </pkt-button> ${this.openMenu === 'user' && this.user ? html` <div class=${userMenuClasses}> <pkt-header-user-menu .user=${this.user} formatted-last-logged-in=${this.formattedLastLoggedIn || nothing} .representing=${this.representing} .userMenu=${this.userMenu} ?can-change-representation=${this.canChangeRepresentation} ?logout-on-click=${this.showLogoutInUserMenu} @change-representation=${() => this.dispatchEvent( new CustomEvent('change-representation', { bubbles: true, composed: true }), )} @log-out=${this.handleLogout} ></pkt-header-user-menu> </div> ` : nothing} </div> ` } 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--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` <header class=${headerClasses} ${ref(this.headerRef)}> <div class=${logoAreaClasses}>${this.renderLogo()} ${this.renderServiceName()}</div> ${this.hasSlotContent() || this.showSearch || this.showLogoutInHeader ? html`<div class="pkt-header-service__content"> ${this.renderSlotContainer()} ${this.renderSearch()} ${this.isTablet && this.showLogoutInHeader ? html` <pkt-button skin="secondary" size=${this.isMobile ? 'small' : 'medium'} variant="icon-only" iconName="exit" @click=${this.handleLogout} > Logg ut </pkt-button> ` : nothing} </div>` : nothing} ${this.user || (!this.isTablet && this.showLogoutInHeader) ? html`<div class="pkt-header-service__user"> ${this.renderUserButton()} ${!this.isTablet && this.showLogoutInHeader ? html` <pkt-button skin="tertiary" size="medium" variant="icon-right" iconName="exit" @click=${this.handleLogout} > Logg ut </pkt-button> ` : nothing} </div>` : nothing} </header> ` } 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` <div class="pkt-header-service-wrapper"> ${headerElement} <div class=${spacerClasses}></div> </div> ` } return headerElement } } declare global { interface HTMLElementTagNameMap { 'pkt-header-service': PktHeaderService } } try { customElement('pkt-header-service')(PktHeaderService) } catch (e) { console.warn('Forsøker å definere <pkt-header-service>, men den er allerede definert') }