import { css, html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { renderRoute } from '../routes.js'; import './app-nav.js'; import { tailwind } from '../styles/tailwind.js'; /** * Root application shell. * * The server renders this with a `path` attribute; the browser reads that * attribute on upgrade, so both renders resolve the same route and hydration * matches. Client-side navigation then updates `path` in place. */ @customElement('app-root') export class AppRoot extends LitElement { static override styles = [ tailwind, css` :host { display: block; } `, ]; /** * Current pathname. Set from the server-rendered attribute, then kept up to * date by the navigation handlers below. */ @property({ type: String }) path = '/'; override connectedCallback(): void { super.connectedCallback(); // connectedCallback only runs in the browser, so this is a safe place to // reach for `window` and to install listeners. window.addEventListener('popstate', this.#onPopState); this.addEventListener('click', this.#onClick); } override disconnectedCallback(): void { super.disconnectedCallback(); window.removeEventListener('popstate', this.#onPopState); this.removeEventListener('click', this.#onClick); } override render() { return html`
${renderRoute(this.path)}
`; } /** Navigates without a full page load and keeps history in sync. */ navigate(pathname: string): void { if (pathname === this.path) return; window.history.pushState({}, '', pathname); this.path = pathname; } #onPopState = (): void => { this.path = window.location.pathname; }; /** * Intercepts same-origin left-clicks on anchors so they route in place. * Everything else — new tabs, modified clicks, downloads, external links — * is left to the browser, which is what keeps middle-click and * "open in new tab" working. */ #onClick = (event: MouseEvent): void => { if (event.defaultPrevented || event.button !== 0) return; if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; // composedPath() is required to see through the shadow boundary. const anchor = event .composedPath() .find((target): target is HTMLAnchorElement => target instanceof HTMLAnchorElement); if (!anchor) return; if (anchor.target !== '' && anchor.target !== '_self') return; if (anchor.hasAttribute('download') || anchor.getAttribute('rel') === 'external') return; if (anchor.origin !== window.location.origin) return; // Routing matches on pathname alone, so anything carrying a query or a // fragment goes to the browser. Intercepting would drop the search string, // skip the scroll to the fragment, and leave a same-page "#id" link doing // nothing at all, since navigate() bails when the pathname is unchanged. if (anchor.search !== '' || anchor.hash !== '') return; event.preventDefault(); this.navigate(anchor.pathname); }; } declare global { interface HTMLElementTagNameMap { 'app-root': AppRoot; } }