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;
}
}