@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
402 lines (357 loc) • 11.7 kB
text/typescript
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 { booleanishConverter, type Booleanish } from 'shared-types'
import {
DEFAULT_HEADER_FOOTER_URL,
deriveSocialIcon,
fetchHeaderFooterData,
mapOdsIcon,
selectLocaleData,
} from 'shared-utils/header-menu'
import type {
IHeaderFooterLocaleData,
IHeaderMenuButton,
IHeaderMenuLink,
IHeaderMenuSection,
IHeaderMenuServices,
IPktHeaderMenu,
THeaderFooterApi,
THeaderMenuLocale,
} from './types'
import '@/components/accordion'
import '@/components/icon'
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
export class PktHeaderMenu extends PktElement<IPktHeaderMenu> implements IPktHeaderMenu {
({ type: String, attribute: 'data-url' })
dataUrl: string = DEFAULT_HEADER_FOOTER_URL
({ type: Object, attribute: false })
data?: THeaderFooterApi
({ type: String })
locale: THeaderMenuLocale = 'nb-NO'
({ type: Boolean, reflect: true, converter: booleanishConverter })
open: Booleanish = false
({ type: String, attribute: 'aria-labelledby', reflect: true })
ariaLabelledBy: string = ''
({ type: Number, attribute: 'mobile-breakpoint' })
mobileBreakpoint: number = 768
() private loadState: LoadState = 'idle'
() private fetchedData?: THeaderFooterApi
() private isMobile = false
private abortController?: AbortController
private mediaQuery?: MediaQueryList
override connectedCallback(): void {
super.connectedCallback()
this.classList.add('pkt-header-menu')
this.updateOpenClass()
this.setupMediaQuery()
if (!this.data) {
void this.loadData()
} else {
this.loadState = 'ready'
this.dispatchEvent(
new CustomEvent('data-loaded', {
detail: { data: this.data },
bubbles: true,
composed: true,
}),
)
}
}
private updateOpenClass() {
this.classList.toggle('pkt-header-menu--open', Boolean(this.open))
}
override disconnectedCallback(): void {
super.disconnectedCallback()
this.abortController?.abort()
this.teardownMediaQuery()
}
override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties)
if (changedProperties.has('open')) {
this.updateOpenClass()
}
if (
changedProperties.has('mobileBreakpoint') &&
changedProperties.get('mobileBreakpoint') !== undefined
) {
this.setupMediaQuery()
}
// Re-fetch only when dataUrl genuinely changes after first load.
// The initial fetch is kicked off from connectedCallback, so we
// skip the noop "field default" change that fires on first update.
if (
changedProperties.has('dataUrl') &&
changedProperties.get('dataUrl') !== undefined &&
!this.data
) {
void this.loadData()
}
if (changedProperties.has('data') && changedProperties.get('data') !== undefined && this.data) {
this.loadState = 'ready'
this.dispatchEvent(
new CustomEvent('data-loaded', {
detail: { data: this.data },
bubbles: true,
composed: true,
}),
)
}
}
private setupMediaQuery() {
this.teardownMediaQuery()
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
this.mediaQuery = window.matchMedia(`(max-width: ${this.mobileBreakpoint - 1}px)`)
this.isMobile = this.mediaQuery.matches
this.mediaQuery.addEventListener('change', this.handleMediaChange)
}
private teardownMediaQuery() {
this.mediaQuery?.removeEventListener('change', this.handleMediaChange)
this.mediaQuery = undefined
}
private handleMediaChange = (event: MediaQueryListEvent) => {
this.isMobile = event.matches
}
private async loadData() {
this.abortController?.abort()
this.abortController = new AbortController()
this.loadState = 'loading'
try {
const data = await fetchHeaderFooterData<string>(this.dataUrl, this.abortController.signal)
this.fetchedData = data
this.loadState = 'ready'
this.dispatchEvent(
new CustomEvent('data-loaded', {
detail: { data },
bubbles: true,
composed: true,
}),
)
} catch (error) {
if ((error as Error).name === 'AbortError') return
this.loadState = 'error'
this.dispatchEvent(
new CustomEvent('data-error', {
detail: { error },
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)
}
render() {
if (this.loadState === 'loading') {
return html`
<nav aria-busy="true">
<p class="pkt-header-menu__loading">Laster meny…</p>
</nav>
`
}
if (this.loadState === 'error' || !this.localeData) {
return html`
<nav aria-hidden=${!this.open}>
<p class="pkt-header-menu__error">Kunne ikke laste meny.</p>
</nav>
`
}
const { megamenu, i18n } = this.localeData
const navAriaLabel = i18n?.navAriaLabel || 'Hovedmeny'
if (this.isMobile) {
return html`
<nav aria-label=${navAriaLabel}>
${this.renderMobileAccordion(megamenu.services, megamenu.sections)}
${this.renderButtons(megamenu.buttons, true)}
${this.renderFooter(megamenu.links, megamenu.some)}
</nav>
`
}
return html`
<nav aria-label=${navAriaLabel}>
${this.renderServices(megamenu.services)} ${this.renderButtons(megamenu.buttons, false)}
${this.renderSections(megamenu.sections)}
${this.renderFooter(megamenu.links, megamenu.some)}
</nav>
`
}
private renderMobileAccordion(
services: IHeaderMenuServices,
sections: IHeaderMenuSection[],
): TemplateResult {
return html`
<div class="pkt-header-menu__sections">
<pkt-accordion
class="pkt-header-menu__sections-inner"
skin="plus-minus"
name="header-menu-accordion"
>
<pkt-accordion-item
class="pkt-header-menu__section"
skin="plus-minus"
id="pkt-header-menu-services"
title=${services.title}
>
${this.renderServicesList(services)}
</pkt-accordion-item>
${sections.map(
(section, index) => html`
<pkt-accordion-item
class="pkt-header-menu__section"
skin="plus-minus"
id=${`pkt-header-menu-section-${index}`}
title=${section.title}
>
${this.renderSectionList(section.links)}
</pkt-accordion-item>
`,
)}
</pkt-accordion>
</div>
`
}
private renderServicesList(services: IHeaderMenuServices): TemplateResult {
return html`
<ul class="pkt-header-menu__services-list">
${services.links.map(
(link) => html`
<li class="pkt-header-menu__service">
<a class="pkt-header-menu__service-link" href=${link.url}>
<pkt-icon
class="pkt-header-menu__service-icon"
name=${mapOdsIcon(link.icon)}
aria-hidden="true"
></pkt-icon>
<span class="pkt-header-menu__service-text">${link.text}</span>
</a>
</li>
`,
)}
</ul>
`
}
private renderServices(services: IHeaderMenuServices): TemplateResult {
return html`
<div class="pkt-header-menu__services">
<h2 class="pkt-header-menu__services-title">${services.title}</h2>
${this.renderServicesList(services)}
</div>
`
}
private renderButtons(
buttons: IHeaderMenuButton[] | undefined,
isMobilePlacement: boolean,
): TemplateResult | typeof nothing {
if (!buttons || buttons.length === 0) return nothing
const classes = classMap({
'pkt-header-menu__buttons': true,
'pkt-header-menu__buttons--mobile': isMobilePlacement,
})
return html`
<div class=${classes}>
${buttons.map(
(button) => html`
<a
class="pkt-btn pkt-btn--secondary pkt-btn--icon-right pkt-btn--small"
href=${button.url}
>
<pkt-icon
class="pkt-btn__icon"
name=${button.iconName ? mapOdsIcon(button.iconName) : 'user'}
aria-hidden="true"
></pkt-icon>
<span class="pkt-btn__text">${button.text}</span>
</a>
`,
)}
</div>
`
}
private renderSections(sections: IHeaderMenuSection[]): TemplateResult | typeof nothing {
if (!sections || sections.length === 0) return nothing
return html`
<div class="pkt-header-menu__sections">
<div class="pkt-header-menu__sections-inner">
${sections.map(
(section) => html`
<div class="pkt-header-menu__section">
<h2 class="pkt-header-menu__section-title">${section.title}</h2>
${this.renderSectionList(section.links)}
</div>
`,
)}
</div>
</div>
`
}
private renderSectionList(links: IHeaderMenuLink[]): TemplateResult {
return html`
<ul class="pkt-header-menu__section-list">
${links.map(
(link) => html`
<li>
<a class="pkt-header-menu__section-link" href=${link.url}>${link.text}</a>
</li>
`,
)}
</ul>
`
}
private renderFooter(
links: IHeaderMenuLink[],
some: IHeaderMenuLink[],
): TemplateResult | typeof nothing {
if ((!links || links.length === 0) && (!some || some.length === 0)) return nothing
return html`
<div class="pkt-header-menu__footer">
${links && links.length > 0
? html`
<ul class="pkt-header-menu__footer-list">
${links.map(
(link) => html`
<li>
<a class="pkt-header-menu__footer-link" href=${link.url}>${link.text}</a>
</li>
`,
)}
</ul>
`
: nothing}
${some && some.length > 0
? html`
<ul class="pkt-header-menu__footer-list pkt-header-menu__footer-list--social">
${some.map((entry) => this.renderSocialLink(entry))}
</ul>
`
: nothing}
</div>
`
}
private renderSocialLink(entry: IHeaderMenuLink): TemplateResult {
const iconName = deriveSocialIcon(entry.url, entry.text)
return html`
<li>
<a class="pkt-header-menu__social-link" href=${entry.url} aria-label=${entry.text}>
${iconName
? html`<pkt-icon name=${iconName} aria-hidden="true"></pkt-icon>`
: html`<span>${entry.text}</span>`}
</a>
</li>
`
}
}
declare global {
interface HTMLElementTagNameMap {
'pkt-header-menu': PktHeaderMenu
}
}
try {
customElement('pkt-header-menu')(PktHeaderMenu)
} catch (e) {
console.warn('Forsøker å definere <pkt-header-menu>, men den er allerede definert')
}