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