UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

402 lines (357 loc) 11.7 kB
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 { @property({ type: String, attribute: 'data-url' }) dataUrl: string = DEFAULT_HEADER_FOOTER_URL @property({ type: Object, attribute: false }) data?: THeaderFooterApi @property({ type: String }) locale: THeaderMenuLocale = 'nb-NO' @property({ type: Boolean, reflect: true, converter: booleanishConverter }) open: Booleanish = false @property({ type: String, attribute: 'aria-labelledby', reflect: true }) ariaLabelledBy: string = '' @property({ type: Number, attribute: 'mobile-breakpoint' }) mobileBreakpoint: number = 768 @state() private loadState: LoadState = 'idle' @state() private fetchedData?: THeaderFooterApi @state() 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') }