lightweight-router 1.0.9 β 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -17
- package/dist/router.min.js +1 -1
- package/package.json +1 -1
- package/src/blog.html +24 -0
- package/src/home.html +25 -0
- package/src/index.html +163 -10
- package/src/pricing.html +177 -0
- package/src/router.js +97 -4
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ PageFlick is a minimal lightweight client-side router with intelligent prefetchi
|
|
|
13
13
|
- π¨ Built-in loading animations
|
|
14
14
|
- π°οΈ Based on History API so you can use native browser navigation
|
|
15
15
|
- π€ Automatic title change
|
|
16
|
-
|
|
16
|
+
- π **Scroll routes** β multi-section landing pages: full-height `<route scroll>` blocks, smooth in-page navigation, and URL bar sync while scrolling
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
@@ -35,14 +35,15 @@ Example:
|
|
|
35
35
|
```javascript
|
|
36
36
|
import { startRouter } from "lightweight-router";
|
|
37
37
|
|
|
38
|
-
startRouter()
|
|
38
|
+
startRouter();
|
|
39
39
|
|
|
40
40
|
//or with your callback
|
|
41
41
|
|
|
42
42
|
startRouter({
|
|
43
|
-
onRouteChange:
|
|
44
|
-
console.log("Route changed:",
|
|
43
|
+
onRouteChange: path => {
|
|
44
|
+
console.log("Route changed:", path);
|
|
45
45
|
},
|
|
46
|
+
debug: true, // optional: log router activity to the console
|
|
46
47
|
});
|
|
47
48
|
```
|
|
48
49
|
|
|
@@ -74,7 +75,8 @@ Initializes the router with the given options.
|
|
|
74
75
|
#### Parameters
|
|
75
76
|
|
|
76
77
|
- `options` (Object): Configuration options for the router.
|
|
77
|
-
- `onRouteChange` (Function):
|
|
78
|
+
- `onRouteChange` (Function): Called with the **current path string** when the route changes (including when the URL is updated while scrolling through scroll routes).
|
|
79
|
+
- `debug` (Boolean): When `true`, logs navigation and prefetch activity to the console.
|
|
78
80
|
|
|
79
81
|
## Examples
|
|
80
82
|
|
|
@@ -86,19 +88,20 @@ Your website content
|
|
|
86
88
|
import { startRouter } from "./router.js";
|
|
87
89
|
|
|
88
90
|
startRouter({
|
|
89
|
-
onRouteChange:
|
|
90
|
-
console.log("Route changed:",
|
|
91
|
+
onRouteChange: path => {
|
|
92
|
+
console.log("Route changed:", path);
|
|
91
93
|
},
|
|
92
94
|
});
|
|
93
95
|
</script>
|
|
94
96
|
```
|
|
95
97
|
|
|
98
|
+
See **`src/index.html`** in this repo for a full **scroll routes** landing example (`<route scroll>` sections and nav).
|
|
99
|
+
|
|
96
100
|
## Prefetching
|
|
97
101
|
|
|
98
102
|
By default, links are prefetched when they get in the user's screen using an `IntersectionObserver`. This ensures that the content is loaded in the background before the user clicks on the link, providing a smoother navigation experience.
|
|
99
103
|
This behaviour is automatically disabled if the user has data saving preferences.
|
|
100
104
|
|
|
101
|
-
|
|
102
105
|
If you have too many links at once or too many requests, you can add the `prefetch="onHover"` attribute to your links or some of them (usually links to huge pages that are not often visited):
|
|
103
106
|
|
|
104
107
|
```html
|
|
@@ -110,6 +113,31 @@ The minified version was created with uglify-js, clean.css and then ultra minifi
|
|
|
110
113
|
The size of the gzipped version was calculated with: https://dafrok.github.io/gzip-size-online/
|
|
111
114
|
It's worth to note that nonetheless Terser give better results than uglify-js. The final uglify version packed by packjs.com was the smallest.
|
|
112
115
|
|
|
116
|
+
## Scroll routes (landing sections)
|
|
117
|
+
|
|
118
|
+
Use this for a **single HTML page** that behaves like several βroutesβ: tall sections that share one document, each with its own pathname. Mark sections with **`scroll`** on `<route>` and a **`path`** that matches the URL you want in the address bar.
|
|
119
|
+
|
|
120
|
+
```html
|
|
121
|
+
<router>
|
|
122
|
+
<route scroll path="/">
|
|
123
|
+
<!-- hero -->
|
|
124
|
+
</route>
|
|
125
|
+
<route scroll path="/features">
|
|
126
|
+
<!-- features -->
|
|
127
|
+
</route>
|
|
128
|
+
<route scroll path="/pricing.html">
|
|
129
|
+
<!-- pricing -->
|
|
130
|
+
</route>
|
|
131
|
+
</router>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Behavior**
|
|
135
|
+
|
|
136
|
+
- When the current URL matches a **`route[scroll]`** `path`, all scroll routes are shown (non-scroll routes inside `<router>` are hidden), and the matching section is scrolled into view. Internal links to another scroll route use **smooth** scrolling.
|
|
137
|
+
- While the user scrolls, an **IntersectionObserver** picks the most visible section and updates the URL with **`history.replaceState`** (and fires `onRouteChange`) so the address bar tracks the section in view.
|
|
138
|
+
- Empty scroll routes still **fetch** HTML from the server like normal routes (prefetching applies the same way).
|
|
139
|
+
- **Deep links**: opening `/features` directly only works if your static server **falls back to this HTML** for unknown paths (for example `npx serve . --single`).
|
|
140
|
+
|
|
113
141
|
## Browser Support
|
|
114
142
|
|
|
115
143
|
The router is intended for modern browsers. Required features:
|
|
@@ -136,11 +164,11 @@ This allows only the changing part of the document to be updated, improving perf
|
|
|
136
164
|
Once you configured your server to respond to this type of request, wrap the part of your document that changes in a `router` tag. Inside the `router` tag, render the current initial route inside a `route` tag like this:
|
|
137
165
|
|
|
138
166
|
```html
|
|
139
|
-
|
|
167
|
+
<!-- Header menu and parts that don't change -->
|
|
140
168
|
<router>
|
|
141
169
|
<route path="/">home content</route>
|
|
142
170
|
</router>
|
|
143
|
-
|
|
171
|
+
<!-- footer etc.. -->
|
|
144
172
|
```
|
|
145
173
|
|
|
146
174
|
You can also prerender most visited routes by rendering them inside the `router` tag in their appropriate `route` tags for faster loading times:
|
|
@@ -159,20 +187,15 @@ Right now errors are shown without styling as the content of the page.
|
|
|
159
187
|
If you like to use Preact and Deno for easy server config consider using the server side router present in [Singularity](https://github.com/andreafuturi/Singularity/) framework.
|
|
160
188
|
It does exactly what we're talking about automatically by returning full html renders on normal request (sorrounded by an Index.jsx) and returing only partial route html when route is requested from inside the page (with onlyRoute param).
|
|
161
189
|
|
|
162
|
-
|
|
163
190
|
## Performance Tips
|
|
191
|
+
|
|
164
192
|
- Implement server-side partial responses for better bandwidth usage
|
|
165
193
|
- Consider using the `prefetch="onHover"` attribute for less important links
|
|
166
194
|
|
|
167
195
|
## Future Development
|
|
196
|
+
|
|
168
197
|
- Delay router intialization on first link hover for better performances?
|
|
169
198
|
- Implement html streaming for faster page load
|
|
170
199
|
- Cooler Error handling
|
|
171
200
|
- Disable caching on certain links/routes (that needs to be always up to date)
|
|
172
201
|
- Cache limiting
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
package/dist/router.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
(()=>{let
|
|
1
|
+
(()=>{let c={},o=!1,s=(...e)=>o&&console.log("π¦ Router:",...e),h="auto",t=null,u=0,n=e=>(e??"").replace(/\/$/,""),d=(e,t)=>{var r,o=n(t);for(r of e.querySelectorAll("route[scroll]"))if(n(r.getAttribute("path"))===o)return r;return null},f=()=>{t?.disconnect(),t=null},g=e=>{f();e=[...e.querySelectorAll("route[scroll]")];e.length&&(t=new IntersectionObserver(e=>{var t,r;Date.now()<u||(e=e.filter(e=>e.isIntersecting)).length&&(e.sort((e,t)=>t.intersectionRatio-e.intersectionRatio),e=e[0].target.getAttribute("path"))&&(t=n(e))!==n(globalThis.location.pathname)&&((r=new URL(globalThis.location.href)).pathname=e.startsWith("/")?e:"/"+e,globalThis.history.replaceState(null,"",r.pathname+r.search+r.hash),s("π Scroll URL sync:",t),y)&&y(t)},{root:null,rootMargin:"-38% 0px -38% 0px",threshold:[0,.1,.25,.5,.75,1]}),e.forEach(e=>t.observe(e)))},a=async()=>{s("Navigation triggered to:",globalThis.location.pathname),document.body.classList.add("loading");var e=globalThis.location.pathname.replace(/\/$/,"");let t=document.querySelector("router"),r=t.querySelector(`route[path="${e}"]`);if(r||(s("Creating new route element for:",e),(r=document.createElement("route")).setAttribute("path",e),t.appendChild(r)),!r.innerHTML){s("Fetching content for:",globalThis.location.href);let e=c[globalThis.location.href];e||(e=await v(globalThis.location.href),c[globalThis.location.href]=e);var o,n=(new DOMParser).parseFromString(e,"text/html"),a=n.querySelector("title"),a=(a&&(s("Updating page title to:",a.textContent),document.title=a.textContent),r.innerHTML=n.body.innerHTML,Array.from(r.querySelectorAll("script")));s("Executing",a.length,"scripts from fetched content");for(o of a){var l=document.createElement("script");o.src?l.src=o.src:l.textContent=o.textContent,o.parentNode.replaceChild(l,o)}}n=[...t.querySelectorAll("route[scroll]")],a=[...t.querySelectorAll("route:not([scroll])")];let i=d(t,e);if(Boolean(i)){n.forEach(e=>{e.style.display=""}),a.forEach(e=>{e.style.display="none"});let e=h;h="auto","smooth"===e&&(u=Date.now()+550),requestAnimationFrame(()=>{i.scrollIntoView({behavior:e,block:"start"}),requestAnimationFrame(()=>g(t))})}else f(),n.forEach(e=>{e.style.display="none"}),t.querySelectorAll("route").forEach(e=>e.style.display="none"),r.style.display="contents",window.scrollTo(0,0);document.body.classList.remove("loading"),y&&y(e),s("Route change completed")},l=async e=>{c[e.href]||(s("Prefetching content for:",e.href),c[e.href]=await v(e.href))},i=(e,r)=>{s("π Intersection Observer triggered for",e.length,"entries"),e.forEach(e=>{var t=e.target;s(`π― Link ${t.href} intersection:`,{isIntersecting:e.isIntersecting,intersectionRatio:e.intersectionRatio,alreadyCached:!!c[t.href]}),e.isIntersecting&&(c[t.href]?s("π¦ Content already cached for:",t.href):(l(t),s("ποΈ Unobserving link after prefetch initiated:",t.href),r.unobserve(t)))})},p=e=>{var t,r,o=e.target.closest("A");o&&o.href&&m(o.href)&&o.origin===location.origin?(t=document.querySelector("router"),r=new URL(o.href).pathname.replace(/\/$/,""),t&&d(t,r)?(s("π Scroll-route link β smooth scroll:",o.href),e.preventDefault(),h="smooth"):(s("Internal link clicked:",o.href),e.preventDefault()),globalThis.history.pushState(null,null,o.href),globalThis.dispatchEvent(new Event("popstate"))):s("Invalid link click:",o?.href)};function m(e){if(!e||e.startsWith("#")||e.startsWith("javascript:"))return!1;if(e.startsWith("/"))return!0;try{var t=new URL(e,window.location.origin),r=new URL(window.location.href);return r.hostname.replace(/^www\./,"")!==t.hostname.replace(/^www\./,"")?!1:r.pathname!==t.pathname||!t.hash}catch{return!1}}let y,v=async e=>{e=await r(e);return e.ok?e.text():"Couldn't fetch the route - HTTP error! status: "+e.status},r=async e=>{if(!b){var t=await fetch(e,{method:"POST",body:"onlyRoute"});if(t.ok)return t}return fetch(e)},b=!1,e=(e={})=>{var{onRouteChange:t,debug:r}=e,e=(o=r,s("Router starting...",e),t&&(r=t,y=r),document.createElement("style")),t=(e.textContent=".loading{animation:pulse 1s infinite alternate}@keyframes pulse{from{opacity:.6}to{opacity:.1}}route{content-visibility:auto}",document.head.appendChild(e),document.querySelector("router")),r=globalThis.location.pathname,r=(t||(s("Creating new router element"),t=document.createElement("router"),(e=document.createElement("route")).setAttribute("path",r),e.innerHTML=document.body.innerHTML,t.appendChild(e),document.body.innerHTML="",document.body.appendChild(t),b=!0),globalThis.addEventListener("popstate",a),document.addEventListener("click",p),document.body.addEventListener("mouseover",e=>{"A"===e.target.tagName&&"onHover"===e.target.getAttribute("prefetch")&&(async e=>{e=e.target;!c[e.href]&&m(e.href)&&await l(e)})(e)}),new IntersectionObserver(i,{root:null,threshold:.5}));s("π Created Intersection Observer with config:",{root:"viewport",threshold:.5}),(r=>{let o=navigator.connection&&navigator.connection.saveData;var e=document.querySelectorAll("a");s("π Starting link observation...",{totalLinks:e.length,saveDataMode:o}),e.forEach(e=>{var t="onHover"!==e.getAttribute("prefetch")&&!o&&m(e.href);s("π Link evaluation:",{href:e.href,prefetchAttr:e.getAttribute("prefetch"),isInternal:m(e.href),willObserve:t}),t&&(r.observe(e),s("π Now observing link:",e.href))})})(r),a()};"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>e()):e()})();
|
package/package.json
CHANGED
package/src/blog.html
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<html lang="en">
|
|
2
|
+
<head>
|
|
3
|
+
<meta charset="utf-8" />
|
|
4
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
5
|
+
<title>Router manual test Blog</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body style="background-color: bisque">
|
|
8
|
+
<menu class="flex m-0 p-0"></menu>
|
|
9
|
+
<a prefetch="onHover" href="/">Home</a><a href="/about.html">About</a><a href="/admin" prefetch="onHover">Admin</a
|
|
10
|
+
><a href="/irmfirmiror" prefetch="onHover">404</a> <a href="https://andreafuturi.com" prefetch="onHover">Google Test</a>
|
|
11
|
+
</menu>
|
|
12
|
+
BLOG
|
|
13
|
+
<script type="module">
|
|
14
|
+
import { startRouter } from "./router.js";
|
|
15
|
+
// Initialize the router with a simple onRouteChange callback to test functionality
|
|
16
|
+
startRouter({
|
|
17
|
+
onRouteChange: currentRoute => {
|
|
18
|
+
console.log("Route changed:", currentRoute);
|
|
19
|
+
currentRoute.classList.add("fade-in"); // Example: simple CSS class for animation (define it in CSS if needed)
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
package/src/home.html
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<html lang="en">
|
|
2
|
+
<head>
|
|
3
|
+
<meta charset="utf-8" />
|
|
4
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
5
|
+
<title>Router manual test Index</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<menu class="flex m-0 p-0"></menu>
|
|
9
|
+
<a prefetch="onHover" href="/home.html">Home</a><a preFetch="onHover" href="/about.html">About</a><a href="/admin" prefetch="onHover">Admin</a
|
|
10
|
+
><a href="/irmfirmiror" prefetch="onHover">404</a> <a href="https://andreafuturi.com" prefetch="onHover">Google Test</a>
|
|
11
|
+
<a href="/">Landingpage</a>
|
|
12
|
+
</menu>
|
|
13
|
+
HOME
|
|
14
|
+
<script type="module">
|
|
15
|
+
import { startRouter } from "./router.js";
|
|
16
|
+
// Initialize the router with a simple onRouteChange callback to test functionality
|
|
17
|
+
startRouter({
|
|
18
|
+
onRouteChange: currentRoute => {
|
|
19
|
+
console.log("Route changed:", currentRoute);
|
|
20
|
+
document.querySelector(`route[path='${currentRoute}']`).classList.add("fade-in"); // Example: simple CSS class for animation (define it in CSS if needed)
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
</script>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
package/src/index.html
CHANGED
|
@@ -1,22 +1,175 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!-- Serve this file so the URL path is /landingpage.html (e.g. npx serve src). If your path is /src/landingpage.html, update route path="/landingpage.html" and the Hero nav href to match. -->
|
|
1
3
|
<html lang="en">
|
|
2
4
|
<head>
|
|
3
5
|
<meta charset="utf-8" />
|
|
4
6
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
5
|
-
<title>
|
|
7
|
+
<title>Scroll routes β landing demo</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #0f1419;
|
|
11
|
+
--surface: #1a2332;
|
|
12
|
+
--text: #e8eef5;
|
|
13
|
+
--muted: #8b9cb3;
|
|
14
|
+
--accent: #3dd6c3;
|
|
15
|
+
--nav-h: 3.25rem;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
* {
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
body {
|
|
23
|
+
margin: 0;
|
|
24
|
+
font-family: system-ui, sans-serif;
|
|
25
|
+
background: var(--bg);
|
|
26
|
+
color: var(--text);
|
|
27
|
+
line-height: 1.5;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.site-nav {
|
|
31
|
+
position: sticky;
|
|
32
|
+
top: 0;
|
|
33
|
+
z-index: 10;
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-wrap: wrap;
|
|
36
|
+
align-items: center;
|
|
37
|
+
gap: 0.5rem 1rem;
|
|
38
|
+
min-height: var(--nav-h);
|
|
39
|
+
padding: 0.5rem 1rem;
|
|
40
|
+
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
|
41
|
+
backdrop-filter: blur(8px);
|
|
42
|
+
border-bottom: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.site-nav a {
|
|
46
|
+
color: var(--accent);
|
|
47
|
+
text-decoration: none;
|
|
48
|
+
font-weight: 600;
|
|
49
|
+
font-size: 0.9rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.site-nav a:hover,
|
|
53
|
+
.site-nav a:focus-visible {
|
|
54
|
+
text-decoration: underline;
|
|
55
|
+
outline: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.site-nav .label {
|
|
59
|
+
color: var(--muted);
|
|
60
|
+
font-size: 0.75rem;
|
|
61
|
+
margin-right: 0.25rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
route[scroll] {
|
|
65
|
+
display: block;
|
|
66
|
+
min-height: 100vh;
|
|
67
|
+
scroll-margin-top: calc(var(--nav-h) + 0.75rem);
|
|
68
|
+
padding: 2rem 1.25rem 4rem;
|
|
69
|
+
border-bottom: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
route[scroll]:nth-of-type(odd) {
|
|
73
|
+
background: linear-gradient(165deg, var(--surface) 0%, var(--bg) 100%);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
route[scroll]:nth-of-type(even) {
|
|
77
|
+
background: linear-gradient(-5deg, #121a24 0%, var(--surface) 100%);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.section-inner {
|
|
81
|
+
max-width: 42rem;
|
|
82
|
+
margin: 0 auto;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.eyebrow {
|
|
86
|
+
font-size: 0.75rem;
|
|
87
|
+
letter-spacing: 0.12em;
|
|
88
|
+
text-transform: uppercase;
|
|
89
|
+
color: var(--muted);
|
|
90
|
+
margin: 0 0 0.5rem;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
h1,
|
|
94
|
+
h2 {
|
|
95
|
+
margin: 0 0 1rem;
|
|
96
|
+
font-weight: 700;
|
|
97
|
+
letter-spacing: -0.02em;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
h1 {
|
|
101
|
+
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
h2 {
|
|
105
|
+
font-size: clamp(1.35rem, 3vw, 1.75rem);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
p {
|
|
109
|
+
margin: 0 0 1rem;
|
|
110
|
+
color: color-mix(in srgb, var(--text) 88%, var(--muted));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.pill {
|
|
114
|
+
display: inline-block;
|
|
115
|
+
padding: 0.2rem 0.6rem;
|
|
116
|
+
border-radius: 999px;
|
|
117
|
+
font-size: 0.7rem;
|
|
118
|
+
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
|
119
|
+
color: var(--accent);
|
|
120
|
+
margin-bottom: 1rem;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.loading body {
|
|
124
|
+
opacity: 0.85;
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
6
127
|
</head>
|
|
7
128
|
<body>
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
129
|
+
<nav class="site-nav" aria-label="Scroll sections">
|
|
130
|
+
<span class="label">Jump</span>
|
|
131
|
+
<a href="/">Hero</a>
|
|
132
|
+
<a href="/landing-features">Features</a>
|
|
133
|
+
<a href="/pricing.html">Pricing</a>
|
|
134
|
+
<a href="/blog">Blog</a>
|
|
135
|
+
<span class="label" style="margin-left: auto"><a href="index.html">β Index</a></span>
|
|
136
|
+
</nav>
|
|
137
|
+
|
|
138
|
+
<router>
|
|
139
|
+
<route scroll path="/">
|
|
140
|
+
<div class="section-inner">
|
|
141
|
+
<p class="eyebrow">Scroll mode</p>
|
|
142
|
+
<h1>Landing hero</h1>
|
|
143
|
+
<p>Each <code><route scroll></code> is a tall section. Internal links smooth-scroll; all sections stay in the DOM.</p>
|
|
144
|
+
<p class="pill">min-height: 100vh</p>
|
|
145
|
+
</div>
|
|
146
|
+
</route>
|
|
147
|
+
|
|
148
|
+
<route scroll path="/landing-features">
|
|
149
|
+
<div class="section-inner">
|
|
150
|
+
<p class="eyebrow">Section 2</p>
|
|
151
|
+
<h2>Features</h2>
|
|
152
|
+
<p>Lazy content loads when this block is near the viewport (IntersectionObserver + 200px) or when you hover a linkβsame as the rest of the router.</p>
|
|
153
|
+
<p class="pill">min-height: 100vh</p>
|
|
154
|
+
</div>
|
|
155
|
+
</route>
|
|
156
|
+
|
|
157
|
+
<route scroll path="/pricing.html">
|
|
158
|
+
<div class="section-inner">
|
|
159
|
+
<p class="eyebrow">Section 3</p>
|
|
160
|
+
<h2>Pricing</h2>
|
|
161
|
+
<p>Empty routes fetch HTML from the server when you land here; inline placeholders skip the network.</p>
|
|
162
|
+
<p class="pill">min-height: 100vh</p>
|
|
163
|
+
</div>
|
|
164
|
+
</route>
|
|
165
|
+
</router>
|
|
166
|
+
|
|
13
167
|
<script type="module">
|
|
14
168
|
import { startRouter } from "./router.js";
|
|
15
|
-
// Initialize the router with a simple onRouteChange callback to test functionality
|
|
16
169
|
startRouter({
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
170
|
+
debug: true,
|
|
171
|
+
onRouteChange: path => {
|
|
172
|
+
console.log(`π Route (scroll demo): ${path}`);
|
|
20
173
|
},
|
|
21
174
|
});
|
|
22
175
|
</script>
|
package/src/pricing.html
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!-- Serve this file so the URL path is /landingpage.html (e.g. npx serve src). If your path is /src/landingpage.html, update route path="/landingpage.html" and the Hero nav href to match. -->
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
7
|
+
<title>Pricing</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #0f1419;
|
|
11
|
+
--surface: #1a2332;
|
|
12
|
+
--text: #e8eef5;
|
|
13
|
+
--muted: #8b9cb3;
|
|
14
|
+
--accent: #3dd6c3;
|
|
15
|
+
--nav-h: 3.25rem;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
* {
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
body {
|
|
23
|
+
margin: 0;
|
|
24
|
+
font-family: system-ui, sans-serif;
|
|
25
|
+
background: var(--bg);
|
|
26
|
+
color: var(--text);
|
|
27
|
+
line-height: 1.5;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.site-nav {
|
|
31
|
+
position: sticky;
|
|
32
|
+
top: 0;
|
|
33
|
+
z-index: 10;
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-wrap: wrap;
|
|
36
|
+
align-items: center;
|
|
37
|
+
gap: 0.5rem 1rem;
|
|
38
|
+
min-height: var(--nav-h);
|
|
39
|
+
padding: 0.5rem 1rem;
|
|
40
|
+
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
|
41
|
+
backdrop-filter: blur(8px);
|
|
42
|
+
border-bottom: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.site-nav a {
|
|
46
|
+
color: var(--accent);
|
|
47
|
+
text-decoration: none;
|
|
48
|
+
font-weight: 600;
|
|
49
|
+
font-size: 0.9rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.site-nav a:hover,
|
|
53
|
+
.site-nav a:focus-visible {
|
|
54
|
+
text-decoration: underline;
|
|
55
|
+
outline: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.site-nav .label {
|
|
59
|
+
color: var(--muted);
|
|
60
|
+
font-size: 0.75rem;
|
|
61
|
+
margin-right: 0.25rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
route[scroll] {
|
|
65
|
+
display: block;
|
|
66
|
+
min-height: 100vh;
|
|
67
|
+
scroll-margin-top: calc(var(--nav-h) + 0.75rem);
|
|
68
|
+
padding: 2rem 1.25rem 4rem;
|
|
69
|
+
border-bottom: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
route[scroll]:nth-of-type(odd) {
|
|
73
|
+
background: linear-gradient(165deg, var(--surface) 0%, var(--bg) 100%);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
route[scroll]:nth-of-type(even) {
|
|
77
|
+
background: linear-gradient(-5deg, #121a24 0%, var(--surface) 100%);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.section-inner {
|
|
81
|
+
max-width: 42rem;
|
|
82
|
+
margin: 0 auto;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.eyebrow {
|
|
86
|
+
font-size: 0.75rem;
|
|
87
|
+
letter-spacing: 0.12em;
|
|
88
|
+
text-transform: uppercase;
|
|
89
|
+
color: var(--muted);
|
|
90
|
+
margin: 0 0 0.5rem;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
h1,
|
|
94
|
+
h2 {
|
|
95
|
+
margin: 0 0 1rem;
|
|
96
|
+
font-weight: 700;
|
|
97
|
+
letter-spacing: -0.02em;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
h1 {
|
|
101
|
+
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
h2 {
|
|
105
|
+
font-size: clamp(1.35rem, 3vw, 1.75rem);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
p {
|
|
109
|
+
margin: 0 0 1rem;
|
|
110
|
+
color: color-mix(in srgb, var(--text) 88%, var(--muted));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.pill {
|
|
114
|
+
display: inline-block;
|
|
115
|
+
padding: 0.2rem 0.6rem;
|
|
116
|
+
border-radius: 999px;
|
|
117
|
+
font-size: 0.7rem;
|
|
118
|
+
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
|
119
|
+
color: var(--accent);
|
|
120
|
+
margin-bottom: 1rem;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.loading body {
|
|
124
|
+
opacity: 0.85;
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
127
|
+
</head>
|
|
128
|
+
<body>
|
|
129
|
+
<nav class="site-nav" aria-label="Scroll sections">
|
|
130
|
+
<span class="label">Jump</span>
|
|
131
|
+
<a href="/">Hero</a>
|
|
132
|
+
<a href="/landing-features">Features</a>
|
|
133
|
+
<a href="/pricing.html">Pricing</a>
|
|
134
|
+
<a href="/blog">Blog</a>
|
|
135
|
+
<span class="label" style="margin-left: auto"><a href="index.html" data-router-ignore>β Index</a></span>
|
|
136
|
+
</nav>
|
|
137
|
+
|
|
138
|
+
<router>
|
|
139
|
+
<route scroll path="/">
|
|
140
|
+
<div class="section-inner">
|
|
141
|
+
<p class="eyebrow">Scroll mode</p>
|
|
142
|
+
<h1>Landing hero</h1>
|
|
143
|
+
<p>Each <code><route scroll></code> is a tall section. Internal links smooth-scroll; all sections stay in the DOM.</p>
|
|
144
|
+
<p class="pill">min-height: 100vh</p>
|
|
145
|
+
</div>
|
|
146
|
+
</route>
|
|
147
|
+
|
|
148
|
+
<route scroll path="/landing-features">
|
|
149
|
+
<div class="section-inner">
|
|
150
|
+
<p class="eyebrow">Section 2</p>
|
|
151
|
+
<h2>Features</h2>
|
|
152
|
+
<p>Lazy content loads when this block is near the viewport (IntersectionObserver + 200px) or when you hover a linkβsame as the rest of the router.</p>
|
|
153
|
+
<p class="pill">min-height: 100vh</p>
|
|
154
|
+
</div>
|
|
155
|
+
</route>
|
|
156
|
+
|
|
157
|
+
<route scroll path="/pricing.html">
|
|
158
|
+
<div class="section-inner">
|
|
159
|
+
<p class="eyebrow">Section 3</p>
|
|
160
|
+
<h2>Pricing</h2>
|
|
161
|
+
<p>Empty routes fetch HTML from the server when you land here; inline placeholders skip the network.</p>
|
|
162
|
+
<p class="pill">min-height: 100vh</p>
|
|
163
|
+
</div>
|
|
164
|
+
</route>
|
|
165
|
+
</router>
|
|
166
|
+
|
|
167
|
+
<script type="module">
|
|
168
|
+
import { startRouter } from "./router.js";
|
|
169
|
+
startRouter({
|
|
170
|
+
debug: true,
|
|
171
|
+
onRouteChange: path => {
|
|
172
|
+
console.log(`π Route (scroll demo): ${path}`);
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
</script>
|
|
176
|
+
</body>
|
|
177
|
+
</html>
|
package/src/router.js
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
let linkData = {};
|
|
2
2
|
let debugMode = false;
|
|
3
3
|
const log = (...args) => debugMode && console.log("π¦ Router:", ...args);
|
|
4
|
+
|
|
5
|
+
/** @type {'auto' | 'smooth'} */
|
|
6
|
+
let pendingScrollBehavior = "auto";
|
|
7
|
+
let scrollUrlObserver = null;
|
|
8
|
+
let suppressScrollUrlSyncUntil = 0;
|
|
9
|
+
|
|
10
|
+
const normalizePath = p => {
|
|
11
|
+
const s = (p ?? "").replace(/\/$/, "");
|
|
12
|
+
return s;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** @param {Element} router */
|
|
16
|
+
const pathToScrollRoute = (router, path) => {
|
|
17
|
+
const n = normalizePath(path);
|
|
18
|
+
for (const r of router.querySelectorAll("route[scroll]")) {
|
|
19
|
+
if (normalizePath(r.getAttribute("path")) === n) return r;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const teardownScrollUrlObserver = () => {
|
|
25
|
+
scrollUrlObserver?.disconnect();
|
|
26
|
+
scrollUrlObserver = null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** @param {Element} router */
|
|
30
|
+
const setupScrollUrlObserver = router => {
|
|
31
|
+
teardownScrollUrlObserver();
|
|
32
|
+
const scrollRoutes = [...router.querySelectorAll("route[scroll]")];
|
|
33
|
+
if (!scrollRoutes.length) return;
|
|
34
|
+
|
|
35
|
+
scrollUrlObserver = new IntersectionObserver(
|
|
36
|
+
entries => {
|
|
37
|
+
if (Date.now() < suppressScrollUrlSyncUntil) return;
|
|
38
|
+
const visible = entries.filter(e => e.isIntersecting);
|
|
39
|
+
if (!visible.length) return;
|
|
40
|
+
visible.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
|
41
|
+
const best = visible[0].target;
|
|
42
|
+
const pathAttr = best.getAttribute("path");
|
|
43
|
+
if (!pathAttr) return;
|
|
44
|
+
const nextPath = normalizePath(pathAttr);
|
|
45
|
+
const here = normalizePath(globalThis.location.pathname);
|
|
46
|
+
if (nextPath === here) return;
|
|
47
|
+
const u = new URL(globalThis.location.href);
|
|
48
|
+
u.pathname = pathAttr.startsWith("/") ? pathAttr : `/${pathAttr}`;
|
|
49
|
+
globalThis.history.replaceState(null, "", u.pathname + u.search + u.hash);
|
|
50
|
+
log("π Scroll URL sync:", nextPath);
|
|
51
|
+
if (onRouteChange) onRouteChange(nextPath);
|
|
52
|
+
},
|
|
53
|
+
{ root: null, rootMargin: "-38% 0px -38% 0px", threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] },
|
|
54
|
+
);
|
|
55
|
+
scrollRoutes.forEach(r => scrollUrlObserver.observe(r));
|
|
56
|
+
};
|
|
57
|
+
|
|
4
58
|
const handlePopState = async () => {
|
|
5
59
|
log("Navigation triggered to:", globalThis.location.pathname);
|
|
6
60
|
document.body.classList.add("loading");
|
|
@@ -54,12 +108,38 @@ const handlePopState = async () => {
|
|
|
54
108
|
}
|
|
55
109
|
}
|
|
56
110
|
|
|
57
|
-
|
|
58
|
-
router.querySelectorAll("route
|
|
59
|
-
|
|
111
|
+
const scrollRoutes = [...router.querySelectorAll("route[scroll]")];
|
|
112
|
+
const nonScrollRoutes = [...router.querySelectorAll("route:not([scroll])")];
|
|
113
|
+
const scrollTarget = pathToScrollRoute(router, currentPath);
|
|
114
|
+
const inScrollMode = Boolean(scrollTarget);
|
|
115
|
+
|
|
116
|
+
if (inScrollMode) {
|
|
117
|
+
scrollRoutes.forEach(r => {
|
|
118
|
+
r.style.display = "";
|
|
119
|
+
});
|
|
120
|
+
nonScrollRoutes.forEach(r => {
|
|
121
|
+
r.style.display = "none";
|
|
122
|
+
});
|
|
123
|
+
const behavior = pendingScrollBehavior;
|
|
124
|
+
pendingScrollBehavior = "auto";
|
|
125
|
+
if (behavior === "smooth") {
|
|
126
|
+
suppressScrollUrlSyncUntil = Date.now() + 550;
|
|
127
|
+
}
|
|
128
|
+
requestAnimationFrame(() => {
|
|
129
|
+
scrollTarget.scrollIntoView({ behavior, block: "start" });
|
|
130
|
+
requestAnimationFrame(() => setupScrollUrlObserver(router));
|
|
131
|
+
});
|
|
132
|
+
} else {
|
|
133
|
+
teardownScrollUrlObserver();
|
|
134
|
+
scrollRoutes.forEach(r => {
|
|
135
|
+
r.style.display = "none";
|
|
136
|
+
});
|
|
137
|
+
router.querySelectorAll("route").forEach(route => (route.style.display = "none"));
|
|
138
|
+
currentRoute.style.display = "contents";
|
|
139
|
+
window.scrollTo(0, 0);
|
|
140
|
+
}
|
|
60
141
|
|
|
61
142
|
document.body.classList.remove("loading");
|
|
62
|
-
window.scrollTo(0, 0);
|
|
63
143
|
|
|
64
144
|
// Call the route change handler if it's set
|
|
65
145
|
if (onRouteChange) onRouteChange(currentPath);
|
|
@@ -110,6 +190,17 @@ const handleLinkClick = e => {
|
|
|
110
190
|
log("Invalid link click:", link?.href);
|
|
111
191
|
return;
|
|
112
192
|
}
|
|
193
|
+
const router = document.querySelector("router");
|
|
194
|
+
const url = new URL(link.href);
|
|
195
|
+
const targetPath = url.pathname.replace(/\/$/, "");
|
|
196
|
+
if (router && pathToScrollRoute(router, targetPath)) {
|
|
197
|
+
log("π Scroll-route link β smooth scroll:", link.href);
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
pendingScrollBehavior = "smooth";
|
|
200
|
+
globalThis.history.pushState(null, null, link.href);
|
|
201
|
+
globalThis.dispatchEvent(new Event("popstate"));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
113
204
|
log("Internal link clicked:", link.href);
|
|
114
205
|
e.preventDefault();
|
|
115
206
|
globalThis.history.pushState(null, null, link.href);
|
|
@@ -244,6 +335,8 @@ const startRouter = (options = {}) => {
|
|
|
244
335
|
});
|
|
245
336
|
|
|
246
337
|
observeLinks(observer);
|
|
338
|
+
|
|
339
|
+
void handlePopState();
|
|
247
340
|
};
|
|
248
341
|
|
|
249
342
|
export { startRouter };
|