a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 417 lines 12 kB view raw
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);