a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1/**
2 * Navigate plugin for client-side navigation with History API
3 *
4 * Intercepts link clicks and form submissions. Integrates with the History API and View Transition API for smooth page transitions.
5 */
6
7import { registerDirective } from "$core/binder";
8import { hasModifier } from "$core/modifiers";
9import { startViewTransition } from "$core/view-transitions";
10import type { Optional } from "$types/helpers";
11import type { BindingContext, Modifier } from "$types/volt";
12
13type NavigationState = { scrollPosition?: { x: number; y: number }; focusSelector?: string; timestamp: number };
14
15type NavigationOpts = { replace?: boolean; transition?: boolean; transitionName?: string };
16
17type RouterMode = "history" | "hash";
18
19const scrollPositions = new Map<string, { x: number; y: number }>();
20const focusSelectors = new Map<string, string>();
21
22/**
23 * Current router mode (history or hash)
24 * Defaults to history mode for backwards compatibility
25 */
26let currentRouterMode: RouterMode = "history";
27
28/**
29 * Set the router mode for navigation
30 *
31 * @param mode - Router mode to use ("history" or "hash")
32 *
33 * @example
34 * ```typescript
35 * import { setRouterMode } from 'voltx.js';
36 *
37 * // Use hash routing in production
38 * setRouterMode(import.meta.env.DEV ? 'history' : 'hash');
39 * ```
40 */
41export function setRouterMode(mode: RouterMode): void {
42 currentRouterMode = mode;
43}
44
45/**
46 * Get the current router mode
47 */
48export function getRouterMode(): RouterMode {
49 return currentRouterMode;
50}
51
52/**
53 * Get the current location key for storing scroll positions
54 */
55function getLocationKey(): string {
56 if (currentRouterMode === "hash") {
57 return globalThis.location.hash.slice(1) || "/";
58 }
59 return `${globalThis.location.pathname}${globalThis.location.search}`;
60}
61
62/**
63 * Navigate directive handler for client-side navigation
64 *
65 * Syntax: data-volt-navigate[.modifiers]="url" or data-volt-navigate[.modifiers] (uses href)
66 *
67 * Modifiers:
68 * - .replace - Use replaceState instead of pushState
69 * - .prefetch - Prefetch resources on hover/idle
70 * - .notransition - Disable view transitions
71 *
72 * @example
73 * ```html
74 * <a href="/about" data-volt-navigate>About</a>
75 * <a href="/home" data-volt-navigate-replace>Home</a>
76 * <a href="/blog" data-volt-navigate-prefetch>Blog</a>
77 * <a href="/settings" data-volt-navigate-notransition>Settings</a>
78 * ```
79 */
80export function bindNavigate(ctx: BindingContext, value: string, modifiers: Modifier[] = []): void {
81 const element = ctx.element;
82
83 if (element instanceof HTMLAnchorElement) {
84 handleLinkNavigation(ctx, value, modifiers);
85 } else if (element instanceof HTMLFormElement) {
86 handleFormNavigation(ctx, value, modifiers);
87 } else {
88 console.warn("data-volt-navigate only works on <a> and <form> elements");
89 }
90}
91
92function handleLinkNavigation(ctx: BindingContext, value: string, modifiers: Modifier[]): void {
93 const link = ctx.element as HTMLAnchorElement;
94 const targetUrl = value || link.getAttribute("href");
95
96 if (!targetUrl) {
97 console.warn("data-volt-navigate: no URL specified and no href found");
98 return;
99 }
100
101 if (hasModifier(modifiers, "prefetch")) {
102 const viewportPrefetch = hasModifier(modifiers, "viewport");
103 setupPrefetch(link, targetUrl, { viewport: viewportPrefetch });
104 }
105
106 const clickHandler = async (event: MouseEvent) => {
107 if (event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0) {
108 return;
109 }
110
111 if (isExternalLink(targetUrl)) {
112 return;
113 }
114
115 event.preventDefault();
116
117 const useReplace = hasModifier(modifiers, "replace");
118 const useTransition = !hasModifier(modifiers, "notransition");
119
120 await navigateTo(targetUrl, { replace: useReplace, transition: useTransition, transitionName: "page-transition" });
121 };
122
123 link.addEventListener("click", clickHandler);
124 ctx.cleanups.push(() => link.removeEventListener("click", clickHandler));
125}
126
127function handleFormNavigation(ctx: BindingContext, value: string, modifiers: Modifier[]): void {
128 const form = ctx.element as HTMLFormElement;
129 const targetUrl = value || form.getAttribute("action") || globalThis.location.pathname;
130
131 const submitHandler = async (event: SubmitEvent) => {
132 event.preventDefault();
133
134 const formData = new FormData(form);
135 const method = form.method.toLowerCase();
136 const useReplace = hasModifier(modifiers, "replace");
137 const useTransition = !hasModifier(modifiers, "notransition");
138
139 if (method === "get") {
140 // TODO: serialize FormData
141 const params = new URLSearchParams(formData as any);
142 const url = `${targetUrl}?${params.toString()}`;
143 await navigateTo(url, { replace: useReplace, transition: useTransition, transitionName: "page-transition" });
144 } else {
145 console.warn("data-volt-navigate: POST/PUT/PATCH forms should use data-volt-post/put/patch");
146 }
147 };
148
149 form.addEventListener("submit", submitHandler);
150 ctx.cleanups.push(() => form.removeEventListener("submit", submitHandler));
151}
152
153async function navigateTo(url: string, options: NavigationOpts = {}): Promise<void> {
154 const { replace = false, transition = true, transitionName = "page-transition" } = options;
155 const currentKey = getLocationKey();
156 scrollPositions.set(currentKey, { x: window.scrollX, y: window.scrollY });
157
158 const activeElement = document.activeElement;
159 const focusSelector = activeElement && activeElement !== document.body
160 ? getElementSelector(activeElement)
161 : undefined;
162 if (focusSelector) {
163 focusSelectors.set(currentKey, focusSelector);
164 }
165
166 const state: NavigationState = {
167 scrollPosition: { x: window.scrollX, y: window.scrollY },
168 focusSelector,
169 timestamp: Date.now(),
170 };
171
172 const performNavigation = async () => {
173 if (currentRouterMode === "hash") {
174 const hash = url.startsWith("#") ? url : `#${url}`;
175 if (replace) {
176 globalThis.history.replaceState(state, "", hash);
177 } else {
178 globalThis.location.hash = hash;
179 }
180 } else {
181 if (replace) {
182 globalThis.history.replaceState(state, "", url);
183 } else {
184 globalThis.history.pushState(state, "", url);
185 }
186 }
187
188 globalThis.dispatchEvent(
189 new CustomEvent("volt:navigate", { detail: { url, replace }, bubbles: true, cancelable: false }),
190 );
191
192 window.scrollTo(0, 0);
193
194 resetFocusAfterNavigation();
195 };
196
197 if (transition && typeof transitionName === "string") {
198 await startViewTransition(performNavigation, { name: transitionName });
199 } else {
200 await performNavigation();
201 }
202}
203
204/**
205 * Generate a unique selector for an element (for focus restoration)
206 * Tries id, then name, then data attributes, then position-based selector
207 */
208function getElementSelector(element: Element): Optional<string> {
209 if (element.id) {
210 return `#${element.id}`;
211 }
212
213 if (element.hasAttribute("name")) {
214 const name = element.getAttribute("name");
215 const tag = element.tagName.toLowerCase();
216 return `${tag}[name="${name}"]`;
217 }
218
219 for (const attr of element.attributes) {
220 if (attr.name.startsWith("data-volt-")) {
221 return `[${attr.name}="${attr.value}"]`;
222 }
223 }
224
225 if (element.hasAttribute("aria-label")) {
226 const label = element.getAttribute("aria-label");
227 return `[aria-label="${label}"]`;
228 }
229
230 const parent = element.parentElement;
231 if (!parent) return undefined;
232
233 const siblings = [...parent.children];
234 const index = siblings.indexOf(element);
235 const tag = element.tagName.toLowerCase();
236
237 return `${tag}:nth-child(${index + 1})`;
238}
239
240/**
241 * Reset focus to a sensible location after navigation
242 * Tries to focus main content area or first focusable element
243 */
244function resetFocusAfterNavigation(): void {
245 const main = document.querySelector("main, [role='main'], #main-content");
246 if (main instanceof HTMLElement && main.tabIndex < 0) {
247 main.tabIndex = -1;
248 }
249
250 if (main instanceof HTMLElement) {
251 main.focus({ preventScroll: true });
252 return;
253 }
254
255 const firstHeading = document.querySelector("h1");
256 if (firstHeading instanceof HTMLElement) {
257 if (firstHeading.tabIndex < 0) {
258 firstHeading.tabIndex = -1;
259 }
260 firstHeading.focus({ preventScroll: true });
261 return;
262 }
263
264 document.body.focus({ preventScroll: true });
265}
266
267/**
268 * Restore focus to the previously focused element (for back/forward navigation)
269 */
270function restoreFocus(selector: string): boolean {
271 try {
272 const element = document.querySelector(selector);
273 if (element instanceof HTMLElement) {
274 element.focus({ preventScroll: true });
275 return true;
276 }
277 } catch (error) {
278 console.warn(`Could not restore focus to selector: ${selector}`, error);
279 }
280 return false;
281}
282
283function isExternalLink(url: string): boolean {
284 try {
285 const target = new URL(url, globalThis.location.origin);
286 return target.origin !== globalThis.location.origin;
287 } catch {
288 return false;
289 }
290}
291
292/**
293 * Setup resource prefetching for a link
294 *
295 * By default, prefetches on hover/focus (interaction-based).
296 * With viewport option, prefetches when element enters viewport (IntersectionObserver).
297 */
298function setupPrefetch(element: HTMLElement, url: string, opts: { viewport?: boolean } = {}): void {
299 const { viewport = false } = opts;
300 let prefetched = false;
301
302 const prefetch = () => {
303 if (prefetched) return;
304 prefetched = true;
305
306 fetch(url, { method: "GET", priority: "low", credentials: "same-origin" } as RequestInit).catch(() => {
307 const link = document.createElement("link");
308 link.rel = "prefetch";
309 link.href = url;
310 document.head.append(link);
311 });
312 };
313
314 if (viewport) {
315 const observer = new IntersectionObserver((entries) => {
316 for (const entry of entries) {
317 if (entry.isIntersecting) {
318 prefetch();
319 observer.disconnect();
320 }
321 }
322 }, { rootMargin: "50px" });
323
324 observer.observe(element);
325 } else {
326 element.addEventListener("mouseenter", prefetch, { once: true, passive: true });
327 element.addEventListener("focus", prefetch, { once: true, passive: true });
328 }
329}
330
331/**
332 * Initialize navigation listeners for back/forward navigation
333 * Should be called once on app initialization
334 * Handles both popstate (history mode) and hashchange (hash mode) events
335 */
336export function initNavigationListener(): () => void {
337 const handleNavigation = (state: NavigationState | null = null) => {
338 const key = getLocationKey();
339 const savedPosition = scrollPositions.get(key);
340 const savedFocus = focusSelectors.get(key);
341
342 if (savedPosition) {
343 window.scrollTo(savedPosition.x, savedPosition.y);
344 } else if (state?.scrollPosition) {
345 window.scrollTo(state.scrollPosition.x, state.scrollPosition.y);
346 }
347
348 if (savedFocus) {
349 restoreFocus(savedFocus);
350 } else if (state?.focusSelector) {
351 restoreFocus(state.focusSelector);
352 } else {
353 resetFocusAfterNavigation();
354 }
355
356 globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state }, bubbles: true, cancelable: false }));
357 };
358
359 const handlePopState = (event: PopStateEvent) => {
360 const state = event.state as NavigationState | null;
361 handleNavigation(state);
362 };
363
364 const handleHashChange = () => {
365 if (currentRouterMode === "hash") {
366 handleNavigation();
367 }
368 };
369
370 globalThis.addEventListener("popstate", handlePopState);
371 globalThis.addEventListener("hashchange", handleHashChange);
372
373 return () => {
374 globalThis.removeEventListener("popstate", handlePopState);
375 globalThis.removeEventListener("hashchange", handleHashChange);
376 };
377}
378
379/**
380 * Programmatic navigation helper
381 *
382 * @param url - URL to navigate to
383 * @param options - Navigation options
384 *
385 * @example
386 * ```typescript
387 * import { navigate } from 'voltx.js';
388 *
389 * navigate('/dashboard', { replace: true });
390 * ```
391 */
392export function navigate(url: string, options?: NavigationOpts): Promise<void> {
393 return navigateTo(url, options);
394}
395
396/**
397 * Go back in history
398 */
399export function goBack(): void {
400 globalThis.history.back();
401}
402
403/**
404 * Go forward in history
405 */
406export function goForward(): void {
407 globalThis.history.forward();
408}
409
410/**
411 * Redirect to a URL (alias for navigate with replace: true)
412 */
413export function redirect(url: string): Promise<void> {
414 return navigateTo(url, { replace: true });
415}
416
417registerDirective("navigate", bindNavigate);