a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 5.6 kB view raw
1/** 2 * Scroll plugin for managing scroll behavior 3 * Supports position restoration, scroll-to, scroll spy, and smooth scrolling 4 */ 5 6import type { PluginContext, Signal } from "$types/volt"; 7 8/** 9 * Scroll plugin handler to manage various scroll-related behaviors. 10 * 11 * Syntax: data-volt-scroll="mode:signalPath" or data-volt-scroll="mode" 12 * Modes: 13 * - restore:signalPath - Save/restore scroll position 14 * - scrollTo:signalPath - Scroll to element when signal changes 15 * - spy:signalPath - Update signal when element is visible 16 * - smooth:signalPath - Enable smooth scrolling behavior 17 * - history - Integrate with navigation history (auto save/restore on navigation) 18 */ 19export function scrollPlugin(ctx: PluginContext, value: string): void { 20 if (value === "history") { 21 handleScrollHistory(ctx); 22 return; 23 } 24 25 const parts = value.split(":"); 26 if (parts.length !== 2) { 27 console.error(`Invalid scroll binding: "${value}". Expected format: "mode:signalPath" or "history"`); 28 return; 29 } 30 31 const [mode, signalPath] = parts.map((p) => p.trim()); 32 33 switch (mode) { 34 case "restore": { 35 handleScrollRestore(ctx, signalPath); 36 break; 37 } 38 case "scrollTo": { 39 handleScrollTo(ctx, signalPath); 40 break; 41 } 42 case "spy": { 43 handleScrollSpy(ctx, signalPath); 44 break; 45 } 46 case "smooth": { 47 handleSmoothScroll(ctx, signalPath); 48 break; 49 } 50 default: { 51 console.error(`Unknown scroll mode: "${mode}"`); 52 } 53 } 54} 55 56/** 57 * Saves current scroll position to signal on scroll events; Restores scroll position from signal on mount. 58 */ 59function handleScrollRestore(ctx: PluginContext, signalPath: string): void { 60 const signal = ctx.findSignal(signalPath); 61 if (!signal) { 62 console.error(`Signal "${signalPath}" not found for scroll restore`); 63 return; 64 } 65 66 const element = ctx.element as HTMLElement; 67 const savedPosition = signal.get(); 68 if (typeof savedPosition === "number") { 69 element.scrollTop = savedPosition; 70 } 71 72 const savePosition = () => { 73 (signal as Signal<number>).set(element.scrollTop); 74 }; 75 76 element.addEventListener("scroll", savePosition, { passive: true }); 77 78 ctx.addCleanup(() => { 79 element.removeEventListener("scroll", savePosition); 80 }); 81} 82 83/** 84 * Scroll to element when signal value matches element's ID or selector. 85 * Listens for changes to the target signal to determine position 86 */ 87function handleScrollTo(ctx: PluginContext, signalPath: string): void { 88 const signal = ctx.findSignal(signalPath); 89 if (!signal) { 90 console.error(`Signal "${signalPath}" not found for scrollTo`); 91 return; 92 } 93 94 const element = ctx.element as HTMLElement; 95 const elementId = element.id; 96 97 const checkAndScroll = (target: unknown) => { 98 if (target === elementId || target === `#${elementId}`) { 99 element.scrollIntoView({ behavior: "smooth", block: "start" }); 100 } 101 }; 102 103 checkAndScroll(signal.get()); 104 105 const unsubscribe = signal.subscribe(checkAndScroll); 106 ctx.addCleanup(unsubscribe); 107} 108 109/** 110 * Update signal when element enters or exits viewport. 111 * Uses {@link IntersectionObserver} to track visibility. 112 */ 113function handleScrollSpy(ctx: PluginContext, signalPath: string): void { 114 const signal = ctx.findSignal(signalPath); 115 if (!signal) { 116 console.error(`Signal "${signalPath}" not found for scroll spy`); 117 return; 118 } 119 120 const element = ctx.element as HTMLElement; 121 122 const observer = new IntersectionObserver((entries) => { 123 for (const entry of entries) { 124 if (entry.target === element) { 125 (signal as Signal<boolean>).set(entry.isIntersecting); 126 } 127 } 128 }, { threshold: 0.1 }); 129 130 observer.observe(element); 131 132 ctx.addCleanup(() => { 133 observer.disconnect(); 134 }); 135} 136 137/** 138 * Enable smooth scrolling behavior and apply based on signal value. 139 */ 140function handleSmoothScroll(ctx: PluginContext, signalPath: string): void { 141 const signal = ctx.findSignal(signalPath); 142 if (!signal) { 143 console.error(`Signal "${signalPath}" not found for smooth scroll`); 144 return; 145 } 146 147 const element = ctx.element as HTMLElement; 148 149 const applyBehavior = (value: unknown) => { 150 if (value === true || value === "smooth") { 151 element.style.scrollBehavior = "smooth"; 152 } else if (value === false || value === "auto") { 153 element.style.scrollBehavior = "auto"; 154 } 155 }; 156 157 applyBehavior(signal.get()); 158 159 const unsubscribe = signal.subscribe(applyBehavior); 160 161 ctx.addCleanup(() => { 162 unsubscribe(); 163 element.style.scrollBehavior = ""; 164 }); 165} 166 167/** 168 * Integrate scroll position with browser history 169 * Automatically saves and restores scroll position on navigation 170 * Works with volt:navigate and volt:popstate events 171 */ 172function handleScrollHistory(ctx: PluginContext): void { 173 const element = ctx.element as HTMLElement; 174 const scrollPositions = new Map<string, number>(); 175 176 const handleNavigate = () => { 177 const key = `${globalThis.location.pathname}${globalThis.location.search}`; 178 scrollPositions.set(key, element.scrollTop); 179 }; 180 181 const handlePopstate = () => { 182 const key = `${globalThis.location.pathname}${globalThis.location.search}`; 183 const savedPosition = scrollPositions.get(key); 184 185 if (savedPosition !== undefined) { 186 requestAnimationFrame(() => { 187 element.scrollTop = savedPosition; 188 }); 189 } 190 }; 191 192 globalThis.addEventListener("volt:navigate", handleNavigate); 193 globalThis.addEventListener("volt:popstate", handlePopstate); 194 195 ctx.addCleanup(() => { 196 globalThis.removeEventListener("volt:navigate", handleNavigate); 197 globalThis.removeEventListener("volt:popstate", handlePopstate); 198 }); 199}