Skip to content

Commit 7ef595f

Browse files
committed
Add first pass at <hyperkit-link />
1 parent 3cdd410 commit 7ef595f

File tree

8 files changed

+241
-6
lines changed

8 files changed

+241
-6
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ A collection of unstyled, headless custom elements designed to make building ric
55
## Overview
66

77
**Hyperkit**
8-
Version: 0.0.2
8+
Version: 0.0.3
99
[Github](https://github.com/hyperlaunch/hyperkit)
1010

1111
**Headless Elements, Supercharged UIs**

packages/docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hyperkitxyz/docs",
3-
"version": "0.0.2",
3+
"version": "0.0.3",
44
"dependencies": {
55
"@fontsource/poppins": "^5.1.0",
66
"@hyperkitxyz/elements": "workspace:*",

packages/docs/src/astro-components/Layout.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const pageTitle = [title, name].filter(Boolean).join(" — ");
7373
<span
7474
class="px-2 py-1 text-xs font-medium shadow-sm rounded-full bg-zinc-300 dark:bg-zinc-700 text-zinc-800 dark:text-zinc-200"
7575
>
76-
0.0.2
76+
0.0.3
7777
</span>
7878
</h1>
7979

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
name: Link
3+
tagline: Fetch pages via Ajax, manage navigation, and enhance perceived speed.
4+
thumbnail: "./thumbnails/modal.png"
5+
---
6+
7+
The `<hyperkit-link />` element provides efficient, Ajax-based page navigation, replacing the body content while maintaining back/forward button support. When wrapped around an anchor tag, it fetches the linked page via Ajax on navigation and updates the document`s `<body>` without a full page reload. This makes multi-page apps feel as responsive as SPAs by enabling faster transitions, prefetching for improved user experience, and graceful fallbacks for non-200 responses.
8+
9+
Key features include prefetching content on mouseover, initiating navigation on `mousedown` or `touchstart` for reduced perceived latency, and handling all back/forward navigation actions. It also supports a default timeout of 5 seconds, which can be overridden with the `timeout` attribute.
10+
11+
Links must be explicitly wrapped in `<hyperkit-link>` to inherit this functionality, aligning with Hyperkit’s goal of explicit behaviours over implicit assumptions.
12+
13+
## Usage
14+
15+
Import the JS:
16+
```js
17+
import "@hyperkitxyz/elements/link";
18+
```
19+
20+
Tag:
21+
```html
22+
<hyperkit-link>
23+
<a href="/path">Go to Path</a>
24+
</hyperkit-link>
25+
```
26+
27+
### Options
28+
29+
{% table %}
30+
* Attribute
31+
* Value
32+
* Description
33+
---
34+
* `timeout`
35+
* Number (milliseconds)
36+
* Optional. Sets the maximum duration to wait for the page load before falling back. Default: 5000ms.
37+
{% /table %}
38+
39+
### Behaviour
40+
41+
- **Ajax-based Navigation**: Fetches linked pages via Ajax, replacing the body content while updating the address bar URL. If the response isn`t a 200 status, it falls back to standard browser behaviour.
42+
43+
- **Prefetching**: On `mouseover`, the linked page content is prefetched to enhance load speed on navigation. Pages larger than 100KB are not cached, and a FIFO cache limit of 5 pages is enforced to avoid excessive memory use.
44+
45+
- **Enhanced Perceived Speed**: Binds to `mousedown` and `touchstart` events to initiate navigation, creating a faster perceived response on click/tap.
46+
47+
- **Back/Forward Navigation**: Listens for `popstate` events to load the appropriate page content when navigating history. The element restores scroll position where possible for a smoother experience.
48+
49+
- **Graceful Fallbacks**: For non-200 responses or if fetch fails, it falls back to standard navigation to ensure reliability.
50+
51+
- **Timeout Control**: The `timeout` attribute allows fine-tuning for delayed network responses. Defaults to 5000ms if unset.
52+
53+
By requiring explicit wrapping in `<hyperkit-link>`, Hyperkit ensures that any added Ajax-driven navigation is intentional, transparent, and compatible with browser standards and accessibility.
54+
55+
### Final Note
56+
57+
If you're working with frameworks like Astro, be aware that `<hyperkit-link />` might not be necessary, as these frameworks often have their own optimised methods for handling client-side navigation and page transitions. Using `<hyperkit-link />` in such contexts could lead to redundant functionality or conflict with native navigation handling provided by the framework.

packages/docs/src/pages/[element].astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const { name, tagline } = entry.data;
1919
const { Content } = await entry.render();
2020
---
2121

22-
<ComponentDocsLayout>
22+
<ComponentDocsLayout title={entry.data.name}>
2323
<div
2424
class="hidden lg:block sticky lg:top-4 xl:top-6 2xl:top-8 max-h-[100svh] overflow-auto flex-shrink w-64"
2525
>

packages/docs/src/utils/component-tree.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export async function getComponentTree() {
1111
"calendar",
1212
"masked-input",
1313
],
14-
Primitives: ["transition", "arrow-nav"],
14+
Primitives: ["transition", "link", "arrow-nav"],
1515
};
1616

1717
return await Promise.all(

packages/elements/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@hyperkitxyz/elements",
33
"main": "./src/index.ts",
4-
"version": "0.0.2",
4+
"version": "0.0.3",
55
"repository": {
66
"type": "git",
77
"url": "git+https://github.com/hyperlaunch/hyperkit.git"

packages/elements/src/link.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { HyperkitElement } from "./hyperkit-element";
2+
3+
4+
class HyperkitLink extends HyperkitElement<{
5+
propTypes: { timeout: "number" };
6+
}> {
7+
static isPopstateBound = false;
8+
loading = false;
9+
propTypes = { timeout: "number" } as const;
10+
11+
requiredChildren = ["a[href]"];
12+
cacheLimit = 5;
13+
maxCacheSize = 100 * 1024;
14+
15+
connectedCallback() {
16+
super.connectedCallback();
17+
window.history.scrollRestoration = "manual";
18+
19+
requestAnimationFrame(() => {
20+
this.anchor?.addEventListener("mousedown", (event) =>
21+
this.handleNavigation(event),
22+
);
23+
this.anchor?.addEventListener("touchstart", (event) =>
24+
this.handleNavigation(event),
25+
);
26+
this.anchor?.addEventListener("mouseover", () => this.prefetchContent());
27+
});
28+
29+
if (!HyperkitLink.isPopstateBound) {
30+
window.addEventListener("popstate", () => this.handlePopstate());
31+
HyperkitLink.isPopstateBound = true;
32+
}
33+
}
34+
35+
get anchor() {
36+
return this.querySelector<HTMLAnchorElement>("a");
37+
}
38+
39+
get href() {
40+
return String(this.anchor?.getAttribute("href"));
41+
}
42+
43+
get timeout() {
44+
return this.prop("timeout") || 5000;
45+
}
46+
47+
async handleNavigation(event: Event) {
48+
event.preventDefault();
49+
50+
sessionStorage.setItem(
51+
`scrollPosition:${document.location.href}`,
52+
String(window.scrollY),
53+
);
54+
55+
await this.startViewTransition();
56+
await this.loadBodyContent();
57+
58+
history.pushState(null, "", this.href);
59+
60+
requestAnimationFrame(() =>
61+
window.scrollTo({ top: 0, left: 0, behavior: "smooth" }),
62+
);
63+
}
64+
65+
async prefetchContent() {
66+
const href = this.href;
67+
68+
if (sessionStorage.getItem(`prefetchedContent:${href}`)) return;
69+
70+
try {
71+
const html = String(await this.fetch({ href }));
72+
73+
if (new Blob([html]).size <= this.maxCacheSize) {
74+
this.cachePage(href, html);
75+
}
76+
} catch (error) {
77+
console.warn("Prefetch failed:", error);
78+
}
79+
}
80+
81+
cachePage(href: string, html: string) {
82+
const cacheOrder = JSON.parse(sessionStorage.getItem("cacheOrder") || "[]");
83+
84+
if (cacheOrder.length >= this.cacheLimit) {
85+
const oldestHref = cacheOrder.shift();
86+
sessionStorage.removeItem(`prefetchedContent:${oldestHref}`);
87+
}
88+
89+
sessionStorage.setItem(`prefetchedContent:${href}`, html);
90+
91+
cacheOrder.push(href);
92+
sessionStorage.setItem("cacheOrder", JSON.stringify(cacheOrder));
93+
}
94+
95+
async loadBodyContent({ href }: { href?: string } = {}) {
96+
if (this.loading) return;
97+
98+
this.loading = true;
99+
document.body.setAttribute("aria-busy", "true");
100+
101+
const hrefToFetch = href || this.href;
102+
103+
const cachedHtml = sessionStorage.getItem(
104+
`prefetchedContent:${hrefToFetch}`,
105+
);
106+
const html = String(
107+
cachedHtml || (await this.fetch({ href: hrefToFetch })),
108+
);
109+
110+
if (cachedHtml)
111+
sessionStorage.removeItem(`prefetchedContent:${hrefToFetch}`);
112+
113+
await this.insertContent({ html });
114+
115+
this.loading = false;
116+
document.body.setAttribute("aria-busy", "false");
117+
}
118+
119+
async insertContent({ html }: { html: string }) {
120+
const parser = new DOMParser();
121+
const doc = parser.parseFromString(html, "text/html");
122+
document.body.innerHTML = doc.body.innerHTML;
123+
}
124+
125+
async handlePopstate() {
126+
const href = document.location.href;
127+
128+
if (!href) {
129+
location.reload();
130+
return;
131+
}
132+
133+
try {
134+
await this.startViewTransition();
135+
await this.loadBodyContent({ href });
136+
137+
const savedScroll = sessionStorage.getItem(`scrollPosition:${href}`);
138+
if (savedScroll !== null) {
139+
requestAnimationFrame(() => {
140+
window.scrollTo({
141+
top: Number(savedScroll, 10),
142+
behavior: "smooth",
143+
});
144+
});
145+
}
146+
} catch (error) {
147+
console.error("Failed to load content on navigation:", error);
148+
location.reload();
149+
}
150+
}
151+
152+
startViewTransition() {
153+
return document.startViewTransition
154+
? new Promise((resolve) =>
155+
document.startViewTransition(() => resolve(true)),
156+
)
157+
: true;
158+
}
159+
160+
async fetch({ href }: { href: string }) {
161+
const controller = new AbortController();
162+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
163+
164+
try {
165+
const response = await fetch(href, { signal: controller.signal });
166+
if (!response.ok) throw new Error(`Non-2xx response: ${response.status}`);
167+
return await response.text();
168+
} catch (error) {
169+
console.warn("Fetch failed or timed out", error);
170+
window.location.href = href;
171+
} finally {
172+
clearTimeout(timeoutId);
173+
}
174+
}
175+
}
176+
177+
if (!customElements.get("hyperkit-link"))
178+
customElements.define("hyperkit-link", HyperkitLink);

0 commit comments

Comments
 (0)