import type { ReactiveController, ReactiveControllerHost } from 'lit' import { createScrollLockToken, setScrollLock as applyScrollLock, type ScrollLockToken, } from 'shared-utils/scroll-lock' import type { THeaderPosition, THeaderScrollBehavior } from './types' export interface HeaderChromeOptions { mobileBreakpoint: number tabletBreakpoint: number position: THeaderPosition scrollBehavior: THeaderScrollBehavior } /** * Shared header shell logic for `pkt-header-service` and `pkt-header-global`: * responsive width measurement (ResizeObserver → `isMobile`/`isTablet`), * scroll-to-hide (`isHidden`), body scroll-lock, and focus return when a menu * closes. * * The menu state machine, click-outside handling and the scroll-lock * *condition* stay in each element since they differ between the variants. */ export class HeaderChromeController implements ReactiveController { private host: ReactiveControllerHost private getOptions: () => HeaderChromeOptions isMobile = false isTablet = false isHidden = false /** When true, scroll-to-hide is paused (e.g. while a menu/overlay is open). */ pauseScrollHide = false private componentWidth = typeof window !== 'undefined' ? window.innerWidth : 0 private resizeObserver?: ResizeObserver private lastScrollPosition = 0 private scrollLockToken: ScrollLockToken = createScrollLockToken() private lastFocusedElement: HTMLElement | null = null private shouldRestoreFocus = false constructor(host: ReactiveControllerHost, getOptions: () => HeaderChromeOptions) { this.host = host this.getOptions = getOptions host.addController(this) } hostConnected(): void { window.addEventListener('scroll', this.handleScroll) } hostDisconnected(): void { this.resizeObserver?.disconnect() window.removeEventListener('scroll', this.handleScroll) applyScrollLock(this.scrollLockToken, false) } /** Start measuring the header element's width. Call from `firstUpdated`. */ observe(element: HTMLElement | undefined): void { if (!element) return this.componentWidth = element.offsetWidth this.refreshBreakpoints() this.resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { this.componentWidth = entry.borderBoxSize && entry.borderBoxSize.length > 0 ? entry.borderBoxSize[0].inlineSize : entry.contentRect.width this.refreshBreakpoints() } }) this.resizeObserver.observe(element) } /** Recompute `isMobile`/`isTablet` (e.g. when the breakpoints change). */ refreshBreakpoints(): void { const { mobileBreakpoint, tabletBreakpoint } = this.getOptions() const isMobile = this.componentWidth < mobileBreakpoint const isTablet = this.componentWidth < tabletBreakpoint if (isMobile !== this.isMobile || isTablet !== this.isTablet) { this.isMobile = isMobile this.isTablet = isTablet this.host.requestUpdate() } } private handleScroll = (): void => { if (this.getOptions().scrollBehavior !== 'hide' || this.pauseScrollHide) return const currentScrollPosition = window.pageYOffset || document.documentElement.scrollTop if (currentScrollPosition < 0) return if (Math.abs(currentScrollPosition - this.lastScrollPosition) < 60) return const hidden = currentScrollPosition > this.lastScrollPosition this.lastScrollPosition = currentScrollPosition if (hidden !== this.isHidden) { this.isHidden = hidden this.host.requestUpdate() } } setScrollLock(shouldLock: boolean): void { applyScrollLock(this.scrollLockToken, shouldLock) } captureFocus(): void { this.lastFocusedElement = document.activeElement as HTMLElement } flagRestoreFocus(): void { this.shouldRestoreFocus = true } restoreFocus(): void { if ( this.shouldRestoreFocus && this.lastFocusedElement && document.contains(this.lastFocusedElement) ) { this.lastFocusedElement.focus() } this.lastFocusedElement = null this.shouldRestoreFocus = false } }