a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { mount } from "$core/binder";
2import { signal } from "$core/signal";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
5describe("event modifiers", () => {
6 beforeEach(() => {
7 vi.useFakeTimers();
8 });
9
10 afterEach(() => {
11 vi.restoreAllMocks();
12 });
13
14 describe(".prevent modifier", () => {
15 it("calls preventDefault on the event", () => {
16 const form = document.createElement("form");
17 form.dataset.voltOnSubmit = "handleSubmit";
18 form.dataset.voltOnSubmitPrevent = "";
19
20 const handleSubmit = vi.fn();
21 mount(form, { handleSubmit });
22
23 const submitEvent = new Event("submit", { cancelable: true });
24 form.dispatchEvent(submitEvent);
25
26 expect(submitEvent.defaultPrevented).toBe(true);
27 expect(handleSubmit).toHaveBeenCalled();
28 });
29
30 it("works with inline expressions", () => {
31 const button = document.createElement("button");
32 button.dataset.voltOnClickPrevent = "count.set(count.get() + 1)";
33
34 const count = signal(0);
35 mount(button, { count });
36
37 const clickEvent = new MouseEvent("click", { cancelable: true });
38 button.dispatchEvent(clickEvent);
39
40 expect(clickEvent.defaultPrevented).toBe(true);
41 expect(count.get()).toBe(1);
42 });
43 });
44
45 describe(".stop modifier", () => {
46 it("calls stopPropagation on the event", () => {
47 const div = document.createElement("div");
48 const button = document.createElement("button");
49 button.dataset.voltOnClickStop = "handleClick";
50 div.append(button);
51
52 const handleClick = vi.fn();
53 const handleDivClick = vi.fn();
54
55 mount(button, { handleClick });
56 div.addEventListener("click", handleDivClick);
57
58 button.click();
59
60 expect(handleClick).toHaveBeenCalled();
61 expect(handleDivClick).not.toHaveBeenCalled();
62 });
63 });
64
65 describe(".self modifier", () => {
66 it("only triggers when event.target is the bound element", () => {
67 const div = document.createElement("div");
68 const span = document.createElement("span");
69 div.dataset.voltOnClickSelf = "handleClick";
70 div.append(span);
71
72 const handleClick = vi.fn();
73 mount(div, { handleClick });
74
75 span.click();
76 expect(handleClick).not.toHaveBeenCalled();
77
78 div.click();
79 expect(handleClick).toHaveBeenCalled();
80 });
81 });
82
83 describe(".once modifier", () => {
84 it("only triggers the handler once", () => {
85 const button = document.createElement("button");
86 button.dataset.voltOnClickOnce = "handleClick";
87
88 const handleClick = vi.fn();
89 mount(button, { handleClick });
90
91 button.click();
92 expect(handleClick).toHaveBeenCalledTimes(1);
93
94 button.click();
95 expect(handleClick).toHaveBeenCalledTimes(1);
96 });
97 });
98
99 describe(".passive modifier", () => {
100 it("adds passive event listener", () => {
101 const div = document.createElement("div");
102 div.dataset.voltOnScrollPassive = "handleScroll";
103
104 const handleScroll = vi.fn();
105 const addEventListenerSpy = vi.spyOn(div, "addEventListener");
106
107 mount(div, { handleScroll });
108
109 expect(addEventListenerSpy).toHaveBeenCalledWith("scroll", expect.any(Function), { passive: true });
110 });
111 });
112
113 describe(".window modifier", () => {
114 it("attaches listener to window", () => {
115 const button = document.createElement("button");
116 button.dataset.voltOnResizeWindow = "handleResize";
117
118 const handleResize = vi.fn();
119 const cleanup = mount(button, { handleResize });
120
121 globalThis.dispatchEvent(new Event("resize"));
122 expect(handleResize).toHaveBeenCalled();
123
124 cleanup();
125 });
126
127 it("still provides $el context", () => {
128 const button = document.createElement("button");
129 button.id = "test-button";
130 button.dataset.voltOnClickWindow = "elementId.set($el.id)";
131
132 const elementId = signal("");
133 const cleanup = mount(button, { elementId });
134
135 globalThis.dispatchEvent(new MouseEvent("click"));
136 expect(elementId.get()).toBe("test-button");
137
138 cleanup();
139 });
140 });
141
142 describe(".document modifier", () => {
143 it("attaches listener to document", () => {
144 const button = document.createElement("button");
145 button.dataset.voltOnClickDocument = "handleClick";
146
147 const handleClick = vi.fn();
148 const cleanup = mount(button, { handleClick });
149
150 document.dispatchEvent(new MouseEvent("click"));
151 expect(handleClick).toHaveBeenCalled();
152
153 cleanup();
154 });
155
156 it("still provides $el context", () => {
157 const button = document.createElement("button");
158 button.id = "doc-button";
159 button.dataset.voltOnKeydownDocument = "elementId.set($el.id)";
160
161 const elementId = signal("");
162 const cleanup = mount(button, { elementId });
163
164 document.dispatchEvent(new KeyboardEvent("keydown"));
165 expect(elementId.get()).toBe("doc-button");
166
167 cleanup();
168 });
169 });
170
171 describe(".debounce modifier", () => {
172 it("debounces handler with default delay (300ms)", () => {
173 const input = document.createElement("input");
174 input.dataset.voltOnInputDebounce = "handleInput";
175
176 const handleInput = vi.fn();
177 mount(input, { handleInput });
178
179 input.dispatchEvent(new Event("input"));
180 expect(handleInput).not.toHaveBeenCalled();
181
182 vi.advanceTimersByTime(299);
183 expect(handleInput).not.toHaveBeenCalled();
184
185 vi.advanceTimersByTime(1);
186 expect(handleInput).toHaveBeenCalledTimes(1);
187 });
188
189 it("supports custom debounce delay", () => {
190 const input = document.createElement("input");
191 input.dataset.voltOnInputDebounce500 = "handleInput";
192
193 const handleInput = vi.fn();
194 mount(input, { handleInput });
195
196 input.dispatchEvent(new Event("input"));
197 vi.advanceTimersByTime(499);
198 expect(handleInput).not.toHaveBeenCalled();
199
200 vi.advanceTimersByTime(1);
201 expect(handleInput).toHaveBeenCalledTimes(1);
202 });
203
204 it("resets timer on subsequent events", () => {
205 const input = document.createElement("input");
206 input.dataset.voltOnInputDebounce100 = "handleInput";
207
208 const handleInput = vi.fn();
209 mount(input, { handleInput });
210
211 input.dispatchEvent(new Event("input"));
212 vi.advanceTimersByTime(50);
213
214 input.dispatchEvent(new Event("input"));
215 vi.advanceTimersByTime(50);
216 expect(handleInput).not.toHaveBeenCalled();
217
218 vi.advanceTimersByTime(50);
219 expect(handleInput).toHaveBeenCalledTimes(1);
220 });
221
222 it("cancels pending debounced calls on cleanup", () => {
223 const input = document.createElement("input");
224 input.dataset.voltOnInputDebounce100 = "handleInput";
225
226 const handleInput = vi.fn();
227 const cleanup = mount(input, { handleInput });
228
229 input.dispatchEvent(new Event("input"));
230 vi.advanceTimersByTime(50);
231
232 cleanup();
233
234 vi.advanceTimersByTime(100);
235 expect(handleInput).not.toHaveBeenCalled();
236 });
237 });
238
239 describe(".throttle modifier", () => {
240 it("throttles handler with default delay (300ms)", () => {
241 const button = document.createElement("button");
242 button.dataset.voltOnClickThrottle = "handleClick";
243
244 const handleClick = vi.fn();
245 mount(button, { handleClick });
246
247 button.click();
248 expect(handleClick).toHaveBeenCalledTimes(1);
249
250 button.click();
251 expect(handleClick).toHaveBeenCalledTimes(1);
252
253 vi.advanceTimersByTime(300);
254 expect(handleClick).toHaveBeenCalledTimes(2);
255 });
256
257 it("supports custom throttle delay", () => {
258 const button = document.createElement("button");
259 button.dataset.voltOnClickThrottle100 = "handleClick";
260
261 const handleClick = vi.fn();
262 mount(button, { handleClick });
263
264 button.click();
265 expect(handleClick).toHaveBeenCalledTimes(1);
266
267 button.click();
268 vi.advanceTimersByTime(100);
269 expect(handleClick).toHaveBeenCalledTimes(2);
270 });
271
272 it("executes immediately on first call", () => {
273 const button = document.createElement("button");
274 button.dataset.voltOnClickThrottle100 = "handleClick";
275
276 const handleClick = vi.fn();
277 mount(button, { handleClick });
278
279 button.click();
280 expect(handleClick).toHaveBeenCalledTimes(1);
281 });
282
283 it("cancels pending throttled calls on cleanup", () => {
284 const button = document.createElement("button");
285 button.dataset.voltOnClickThrottle100 = "handleClick";
286
287 const handleClick = vi.fn();
288 const cleanup = mount(button, { handleClick });
289
290 button.click();
291 expect(handleClick).toHaveBeenCalledTimes(1);
292
293 button.click();
294 cleanup();
295
296 vi.advanceTimersByTime(100);
297 expect(handleClick).toHaveBeenCalledTimes(1);
298 });
299 });
300
301 describe("modifier combinations", () => {
302 it("combines .prevent and .stop", () => {
303 const form = document.createElement("form");
304 const button = document.createElement("button");
305 button.type = "submit";
306 button.dataset.voltOnClickPreventStop = "handleClick";
307 form.append(button);
308
309 const handleClick = vi.fn();
310 const handleFormSubmit = vi.fn();
311
312 mount(button, { handleClick });
313 form.addEventListener("click", handleFormSubmit);
314
315 const clickEvent = new MouseEvent("click", { cancelable: true, bubbles: true });
316 button.dispatchEvent(clickEvent);
317
318 expect(clickEvent.defaultPrevented).toBe(true);
319 expect(handleClick).toHaveBeenCalled();
320 expect(handleFormSubmit).not.toHaveBeenCalled();
321 });
322
323 it("combines .self and .prevent", () => {
324 const div = document.createElement("div");
325 const span = document.createElement("span");
326 div.dataset.voltOnClickSelfPrevent = "handleClick";
327 div.append(span);
328
329 const handleClick = vi.fn();
330 mount(div, { handleClick });
331
332 const spanEvent = new MouseEvent("click", { cancelable: true, bubbles: true });
333 span.dispatchEvent(spanEvent);
334 expect(handleClick).not.toHaveBeenCalled();
335 expect(spanEvent.defaultPrevented).toBe(false);
336
337 const divEvent = new MouseEvent("click", { cancelable: true });
338 div.dispatchEvent(divEvent);
339 expect(handleClick).toHaveBeenCalled();
340 expect(divEvent.defaultPrevented).toBe(true);
341 });
342
343 it("combines .debounce with .prevent", () => {
344 const form = document.createElement("form");
345 form.dataset.voltOnSubmitDebounce100Prevent = "handleSubmit";
346
347 const handleSubmit = vi.fn();
348 mount(form, { handleSubmit });
349
350 const submitEvent = new Event("submit", { cancelable: true });
351 form.dispatchEvent(submitEvent);
352
353 expect(submitEvent.defaultPrevented).toBe(true);
354 expect(handleSubmit).not.toHaveBeenCalled();
355
356 vi.advanceTimersByTime(100);
357 expect(handleSubmit).toHaveBeenCalledTimes(1);
358 });
359 });
360
361 describe("cleanup", () => {
362 it("removes event listeners on unmount", () => {
363 const button = document.createElement("button");
364 button.dataset.voltOnClick = "handleClick";
365
366 const handleClick = vi.fn();
367 const cleanup = mount(button, { handleClick });
368
369 button.click();
370 expect(handleClick).toHaveBeenCalledTimes(1);
371
372 cleanup();
373
374 button.click();
375 expect(handleClick).toHaveBeenCalledTimes(1);
376 });
377
378 it("removes window event listeners on unmount", () => {
379 const button = document.createElement("button");
380 button.dataset.voltOnResizeWindow = "handleResize";
381
382 const handleResize = vi.fn();
383 const cleanup = mount(button, { handleResize });
384
385 globalThis.dispatchEvent(new Event("resize"));
386 expect(handleResize).toHaveBeenCalledTimes(1);
387
388 cleanup();
389
390 globalThis.dispatchEvent(new Event("resize"));
391 expect(handleResize).toHaveBeenCalledTimes(1);
392 });
393
394 it("removes document event listeners on unmount", () => {
395 const button = document.createElement("button");
396 button.dataset.voltOnClickDocument = "handleClick";
397
398 const handleClick = vi.fn();
399 const cleanup = mount(button, { handleClick });
400
401 document.dispatchEvent(new MouseEvent("click"));
402 expect(handleClick).toHaveBeenCalledTimes(1);
403
404 cleanup();
405
406 document.dispatchEvent(new MouseEvent("click"));
407 expect(handleClick).toHaveBeenCalledTimes(1);
408 });
409 });
410});