a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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}