@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
765 lines (673 loc) • 24.4 kB
text/typescript
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 {
serviceName?: string
serviceLink?: string
logoLink?: string
searchPlaceholder = 'Søk'
searchValue = ''
mobileBreakpoint: number = 768
tabletBreakpoint: number = 1280
openedMenu: THeaderMenu = 'none'
logOutButtonPlacement: TLogOutButtonPlacement = 'none'
position: THeaderPosition = 'fixed'
scrollBehavior: THeaderScrollBehavior =
'hide'
slotMenuVariant: TSlotMenuVariant = 'icon-only'
slotMenuText = 'Meny'
hideLogo: Booleanish = false
compact: Booleanish = false
showSearch: Booleanish = false
canChangeRepresentation: Booleanish = false
hasLogOut: Booleanish = false
user?: User
userMenu?: UserMenuItem[]
representing?: Representing
private isMobile = false
private isTablet = false
private openMenu: THeaderMenu = 'none'
private isHidden = false
private componentWidth = typeof window !== 'undefined' ? window.innerWidth : 0
private alignSlotRight = false
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" =${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"
=${this.handleLogoClick}
>
${logoIcon}
</button>
</span>
`
}
return html`
<span class="pkt-header-service__logo" =${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"
=${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"
=${this.handleServiceClick}
>
${this.serviceName}
</button>
</span>
`
}
// No link - just render the text
return html`
<span class="pkt-header-service__service-name" =${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}
=${(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'}
=${() => 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}
=${(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'}
=${() => 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
=${this.handleSearchInputChange}
=${(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}
=${this.handleSearchInputChange}
=${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"
=${(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'}
=${() => 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}
-representation=${() =>
this.dispatchEvent(
new CustomEvent('change-representation', { bubbles: true, composed: true }),
)}
-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"
=${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"
=${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')
}