Skip to content

Commit fcdbd06

Browse files
committed
fix(lint): use globalThis instead of window in less-layout
1 parent 8b623d0 commit fcdbd06

5 files changed

Lines changed: 120 additions & 4 deletions

File tree

docs/vite.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ export default defineConfig({
107107
replacement: resolve(__dir, '../packages/core/src/logger.ts'),
108108
},
109109
{ find: '@lessjs/core/less-runtime', replacement: runtimeShim },
110+
{
111+
find: '@lessjs/core/navigation',
112+
replacement: resolve(__dir, '../packages/core/src/navigation.ts'),
113+
},
110114
{
111115
find: '@lessjs/core',
112116
replacement: resolve(__dir, '../packages/core/src/index.ts'),

packages/core/deno.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"./less-runtime.js": "./src/less-runtime.ts",
1616
"./logger": "./src/logger.ts",
1717
"./logger.js": "./src/logger.ts",
18+
"./navigation": "./src/navigation.ts",
19+
"./navigation.js": "./src/navigation.ts",
1820
"./cli/build": "./src/cli/build.ts",
1921
"./cli/build-client": "./src/cli/build-client.ts",
2022
"./cli/build-ssg": "./src/cli/build-ssg.ts"

packages/create/__tests__/cli.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ Deno.test('create-less: generated project builds through the one-command pipelin
177177
denoJson.imports['@lessjs/core/logger'] = pathToFileURL(
178178
join(repoRoot, 'packages', 'core', 'src', 'logger.ts'),
179179
).href;
180+
denoJson.imports['@lessjs/core/navigation'] = pathToFileURL(
181+
join(repoRoot, 'packages', 'core', 'src', 'navigation.ts'),
182+
).href;
180183
denoJson.imports['@lessjs/adapter-lit'] = pathToFileURL(
181184
join(repoRoot, 'packages', 'adapter-lit', 'src', 'index.ts'),
182185
).href;
@@ -211,6 +214,10 @@ Deno.test('create-less: generated project builds through the one-command pipelin
211214
find: '@lessjs/core/less-runtime',
212215
replacement: vitePath(join(repoRoot, 'packages', 'core', 'src', 'less-runtime.ts')),
213216
},
217+
{
218+
find: '@lessjs/core/navigation',
219+
replacement: vitePath(join(repoRoot, 'packages', 'core', 'src', 'navigation.ts')),
220+
},
214221
{
215222
find: '@lessjs/core',
216223
replacement: vitePath(join(repoRoot, 'packages', 'core', 'src', 'index.ts')),

packages/create/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ node_modules/
2525
"@lessjs/adapter-lit": "jsr:@lessjs/adapter-lit@^0.7.0",
2626
"@lessjs/core": "jsr:@lessjs/core@^0.9.0",
2727
"@lessjs/core/less-runtime": "jsr:@lessjs/core@^0.9.0/less-runtime",
28+
"@lessjs/core/navigation": "jsr:@lessjs/core@^0.9.0/navigation",
2829
"@lessjs/ui": "jsr:@lessjs/ui@^0.6.0",
2930
"@lessjs/ui/tokens/colors": "jsr:@lessjs/ui@^0.6.0/tokens/colors",
3031
"@lessjs/ui/tokens/color-values": "jsr:@lessjs/ui@^0.6.0/tokens/color-values",

packages/ui/src/less-layout.ts

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@
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
@@ -42,6 +47,7 @@
4247
import { css, type CSSResult, html, nothing, type TemplateResult } from 'lit';
4348
import { lessDesignTokens } from './design-tokens.js';
4449
import { 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
*/
9199
export 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

Comments
 (0)