a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1/**
2 * Modifier utilities for event and input bindings
3 *
4 * Provides parsing and application of modifiers like .prevent, .stop, .debounce, etc.
5 */
6
7import type { Optional, Timer } from "$types/helpers";
8import type { Modifier, ParsedAttribute, TimedFunction } from "$types/volt";
9
10/**
11 * Parse attribute name to extract base name and modifiers.
12 *
13 * Modifiers are separated by dashes only when the entire string uses dash-case (e.g., from dataset).
14 * This allows attribute names to contain dashes (like aria-label) while still supporting modifiers.
15 *
16 * Examples:
17 * - "click-prevent-stop" -> {baseName: "click", modifiers: [{name: "prevent"}, {name: "stop"}]}
18 * - "aria-label" -> {baseName: "aria-label", modifiers: []} (no modifiers detected)
19 * - "input-debounce500" -> {baseName: "input", modifiers: [{name: "debounce", value: 500}]}
20 *
21 * @param attrName - The attribute name with potential modifiers
22 * @returns Parsed attribute with base name and modifiers array
23 */
24export function parseModifiers(attrName: string): ParsedAttribute {
25 const parts = attrName.split("-");
26
27 if (parts.length === 1) {
28 return { baseName: attrName, modifiers: [] };
29 }
30
31 const baseName = parts[0];
32 const modifiers: Modifier[] = [];
33 const KNOWN_MODIFIERS = new Set([
34 "prevent",
35 "stop",
36 "self",
37 "window",
38 "document",
39 "once",
40 "debounce",
41 "throttle",
42 "passive",
43 "number",
44 "trim",
45 "lazy",
46 "replace",
47 "prefetch",
48 "notransition",
49 ]);
50
51 let i = 1;
52 while (i < parts.length) {
53 const part = parts[i];
54
55 const numMatch = /^([a-zA-Z]+)(\d+)$/.exec(part);
56 if (numMatch && KNOWN_MODIFIERS.has(numMatch[1])) {
57 modifiers.push({ name: numMatch[1], value: Number(numMatch[2]) });
58 i++;
59 } else if (KNOWN_MODIFIERS.has(part)) {
60 if (i + 1 < parts.length) {
61 const numValue = Number(parts[i + 1]);
62 if (!Number.isNaN(numValue)) {
63 modifiers.push({ name: part, value: numValue });
64 i += 2;
65 continue;
66 }
67 }
68 modifiers.push({ name: part });
69 i++;
70 } else {
71 break;
72 }
73 }
74
75 if (modifiers.length === 0) {
76 return { baseName: attrName, modifiers: [] };
77 }
78
79 return { baseName, modifiers };
80}
81
82/**
83 * Check if a modifier is present in the modifiers array
84 */
85export function hasModifier(modifiers: Modifier[], name: string): boolean {
86 return modifiers.some((m) => m.name === name);
87}
88
89/**
90 * Get a modifier's value or return a default
91 */
92export function getModifierValue(modifiers: Modifier[], name: string, defaultValue: number): number {
93 const modifier = modifiers.find((m) => m.name === name);
94 return modifier?.value ?? defaultValue;
95}
96
97/**
98 * Create a debounced version of a function.
99 * Delays execution until after the specified wait time has elapsed since the last call.
100 *
101 * @param fn - Function to debounce
102 * @param wait - Milliseconds to wait before executing
103 * @returns Debounced function with cleanup method
104 */
105export function debounce<T extends unknown[], R>(fn: (...args: T) => R, wait: number): TimedFunction<T> {
106 let timeoutId: Optional<Timer>;
107
108 const debounced = function(this: unknown, ...args: T) {
109 if (timeoutId !== undefined) {
110 clearTimeout(timeoutId);
111 }
112
113 timeoutId = setTimeout(() => {
114 timeoutId = undefined;
115 fn.apply(this, args);
116 }, wait);
117 };
118
119 debounced.cancel = () => {
120 if (timeoutId !== undefined) {
121 clearTimeout(timeoutId);
122 timeoutId = undefined;
123 }
124 };
125
126 return debounced;
127}
128
129/**
130 * Create a throttled version of a function.
131 * Limits execution to at most once per specified wait time.
132 *
133 * @param fn - Function to throttle
134 * @param wait - Milliseconds to wait between executions
135 * @returns Throttled function with cleanup method
136 */
137export function throttle<T extends unknown[], R>(fn: (...args: T) => R, wait: number): TimedFunction<T> {
138 let timeoutId: Optional<Timer>;
139 let lastExecutionTime = 0;
140 let pendingArgs: Optional<T>;
141 let pendingThis: unknown;
142
143 const throttled = function(this: unknown, ...args: T) {
144 const now = Date.now();
145 const timeSinceLastExecution = now - lastExecutionTime;
146
147 pendingArgs = args;
148 // eslint-disable-next-line unicorn/no-this-assignment
149 pendingThis = this;
150
151 if (timeSinceLastExecution >= wait) {
152 lastExecutionTime = now;
153 fn.apply(this, args);
154 pendingArgs = undefined;
155 pendingThis = undefined;
156 } else if (timeoutId === undefined) {
157 const remainingTime = wait - timeSinceLastExecution;
158 timeoutId = setTimeout(() => {
159 timeoutId = undefined;
160 lastExecutionTime = Date.now();
161 if (pendingArgs !== undefined) {
162 fn.apply(pendingThis, pendingArgs);
163 pendingArgs = undefined;
164 pendingThis = undefined;
165 }
166 }, remainingTime);
167 }
168 };
169
170 throttled.cancel = () => {
171 if (timeoutId !== undefined) {
172 clearTimeout(timeoutId);
173 timeoutId = undefined;
174 }
175 pendingArgs = undefined;
176 pendingThis = undefined;
177 };
178
179 return throttled;
180}