1010 * - Mobile hamburger menu (L0 details/summary)
1111 * - Theme toggle via less-theme-toggle Island
1212 * - Footer with links
13+ * - SPA navigation via Navigation API (navigate/fetch/swap)
14+ * Intercepts internal link clicks, uses navigate() for URL
15+ * update, fetches new page HTML, and swaps slot content.
16+ * Falls back to History API when Navigation API unavailable.
1317 *
1418 * LessJS Architecture:
1519 * - This is a Layer 2 (DSD Interactive) component
1620 * - v0.6.2: Uses WithDsdHydration Mixin for DSD hydration
1721 * with declarative event binding and direct DOM manipulation
1822 * - Theme toggle is handled by less-theme-toggle Island
1923 * - Navigation is data-driven via navItems property (no hardcoded links)
24+ * - v0.9.0: Uses @lessjs/core/navigation for SPA navigation
2025 *
2126 * Usage (data-driven navigation):
2227 * ```html
4247import { css , type CSSResult , html , nothing , type TemplateResult } from 'lit' ;
4348import { lessDesignTokens } from './design-tokens.js' ;
4449import { DsdLitElement } from '@lessjs/adapter-lit' ;
50+ import { navigate , onNavigate } from '@lessjs/core/navigation' ;
4551
4652// CRITICAL: less-layout's template uses <less-theme-toggle>, so we MUST import it
4753// so that the SSR renderer can recursively render its DSD shadow root.
@@ -79,14 +85,16 @@ export interface HeaderNavLink {
7985}
8086
8187/**
82- * App layout with DSD hydration.
88+ * App layout with DSD hydration and SPA navigation .
8389 *
8490 * Uses WithDsdHydration Mixin for the common DSD pattern:
8591 * - Detects pre-populated shadow root from DSD
8692 * - Binds events declared in `static hydrateEvents`
8793 * - Cleans up listeners on disconnect
8894 *
89- * Layout-specific: also sets up native <details> toggle for mobile menu.
95+ * v0.9.0: SPA navigation via Navigation API + fetch-and-swap.
96+ * Internal links use data-nav attribute; click handling is delegated
97+ * from the shadow root, working with both DSD and non-DSD modes.
9098 */
9199export class LessLayout extends DsdLitElement {
92100 /** Declarative event bindings for DSD hydration */
@@ -668,10 +676,32 @@ export class LessLayout extends DsdLitElement {
668676 override connectedCallback ( ) {
669677 super . connectedCallback ( ) ; // Mixin handles _hydrateEvents()
670678
671- // Layout-specific: also set up native <details> toggle for mobile menu
679+ // Layout-specific: set up native <details> toggle for mobile menu
672680 if ( this . _dsdHydrated ) {
673681 this . _setupDetailsToggle ( ) ;
674682 }
683+
684+ // ── SPA navigation: event delegation for all internal nav links ──
685+ // Uses data-nav attribute instead of @click on each <a> tag.
686+ // This works with both DSD (pre-rendered HTML) and non-DSD (Lit render)
687+ // because event delegation at the shadow root level catches all clicks.
688+ this . _navCleanup = this . _setupNavDelegation ( ) ;
689+
690+ // ── Listen for navigation events ──
691+ // After navigate() updates the URL, swap in the new page content
692+ // via fetch-and-swap so the user gets a SPA-like experience.
693+ this . _navUnlisten = onNavigate ( ( url , navType ) => {
694+ if ( navType === 'push' ) {
695+ this . currentPath = url . pathname ;
696+ this . _loadContent ( url . pathname ) ;
697+ }
698+ } ) ;
699+ }
700+
701+ override disconnectedCallback ( ) {
702+ super . disconnectedCallback ( ) ;
703+ this . _navCleanup ?.( ) ;
704+ this . _navUnlisten ?.( ) ;
675705 }
676706
677707 /**
@@ -716,6 +746,75 @@ export class LessLayout extends DsdLitElement {
716746 }
717747 }
718748
749+ // ─── Private fields ───────────────────────────────────────────
750+ /** Cleanup for nav click delegation */
751+ private _navCleanup ?: ( ) => void ;
752+ /** Cleanup for onNavigate listener */
753+ private _navUnlisten ?: ( ) => void ;
754+
755+ // ─── SPA Navigation ───────────────────────────────────────────
756+
757+ /**
758+ * Set up event delegation for all nav links on the shadow root.
759+ * Intercepts clicks on <a data-nav="..."> elements and routes them
760+ * through the Navigation API for SPA-like page transitions.
761+ */
762+ private _setupNavDelegation ( ) : ( ) => void {
763+ if ( ! this . shadowRoot ) return ( ) => { } ;
764+ const handler = ( e : Event ) => {
765+ const link = ( e . target as HTMLElement ) . closest < HTMLAnchorElement > ( '[data-nav]' ) ;
766+ if ( ! link ) return ;
767+ const path = link . getAttribute ( 'data-nav' ) ;
768+ if ( ! path || path . startsWith ( 'http' ) ) return ;
769+ e . preventDefault ( ) ;
770+ navigate ( path ) ;
771+ } ;
772+ this . shadowRoot . addEventListener ( 'click' , handler ) ;
773+ return ( ) => this . shadowRoot ?. removeEventListener ( 'click' , handler ) ;
774+ }
775+
776+ /**
777+ * Fetch a new page and swap its content into the layout's slot.
778+ *
779+ * Strategy (SSG-optimized):
780+ * 1. Fetch the full HTML of the target page
781+ * 2. Parse and find the <less-layout> element
782+ * 3. Replace this element's children with the new page's
783+ * light DOM content (projected via <slot>)
784+ * 4. Update currentPath for sidebar highlighting
785+ * 5. Scroll to top
786+ *
787+ * If anything fails (network, parsing), falls back to full reload.
788+ */
789+ private async _loadContent ( path : string ) : Promise < void > {
790+ try {
791+ const resp = await fetch ( path ) ;
792+ if ( ! resp . ok ) throw new Error ( `HTTP ${ resp . status } ` ) ;
793+ const html = await resp . text ( ) ;
794+
795+ const tmp = document . createElement ( 'div' ) ;
796+ tmp . innerHTML = html ;
797+
798+ const newLayout = tmp . querySelector < HTMLElement > ( 'less-layout' ) ;
799+ if ( ! newLayout ) throw new Error ( 'No less-layout found in response' ) ;
800+
801+ // Replace this layout's light DOM children with the new page's
802+ // (they are projected through <slot></slot> in the template)
803+ while ( this . firstChild ) this . removeChild ( this . firstChild ) ;
804+ while ( newLayout . firstChild ) this . appendChild ( newLayout . firstChild ) ;
805+
806+ // Update sidebar active state
807+ this . currentPath = path ;
808+
809+ // Scroll to top for a fresh viewport
810+ globalThis . scrollTo ( { top : 0 , behavior : 'smooth' } ) ;
811+ } catch {
812+ // Fallback: full reload — Navigation API already updated the URL,
813+ // so this acts as a normal page load from the new URL
814+ globalThis . location . reload ( ) ;
815+ }
816+ }
817+
719818 private _navLink ( path : string , text : string ) {
720819 const isExternal = path . startsWith ( 'http' ) ;
721820 const isActive = ! isExternal && this . currentPath === path ;
@@ -726,6 +825,7 @@ export class LessLayout extends DsdLitElement {
726825 aria-current ="${ isActive ? 'page' : undefined } "
727826 target ="${ isExternal ? '_blank' : nothing } "
728827 rel ="${ isExternal ? 'noopener noreferrer' : nothing } "
828+ data-nav ="${ isExternal ? '' : path } "
729829 > ${ text } </ a >
730830 ` ;
731831 }
@@ -763,7 +863,9 @@ export class LessLayout extends DsdLitElement {
763863 ${ links . map (
764864 ( link ) =>
765865 html `
766- < a href ="${ link . href } "> ${ link . label } </ a >
866+ < a href ="${ link . href } " data-nav ="${ link . href . startsWith ( 'http' )
867+ ? ''
868+ : link . href } "> ${ link . label } </ a >
767869 ` ,
768870 ) }
769871 </ nav >
0 commit comments