a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 4.2 kB view raw
1/** 2 * DOM utility functions 3 */ 4 5/** 6 * Walk the DOM tree and collect all elements with data-volt-* attributes in document order (parent before children). 7 * Skips children of elements with data-volt-for or data-volt-if since those will be processed when the parent element is cloned and mounted. 8 * 9 * @param root - The root element to start walking from 10 * @returns Array of elements with data-volt-* attributes 11 */ 12export function walkDOM(root: Element): Element[] { 13 const elements: Element[] = []; 14 15 function walk(element: Element): void { 16 if (hasVoltAttr(element)) { 17 elements.push(element); 18 19 if ( 20 Object.hasOwn((element as HTMLElement).dataset, "voltFor") 21 || Object.hasOwn((element as HTMLElement).dataset, "voltIf") 22 ) { 23 return; 24 } 25 } 26 27 for (const child of element.children) { 28 walk(child); 29 } 30 } 31 32 walk(root); 33 34 return elements; 35} 36 37/** 38 * Check if an element has any data-volt-* attributes. 39 * 40 * @param el - Element to check 41 * @returns true if element has any Volt attributes 42 */ 43export function hasVoltAttr(el: Element): boolean { 44 return [...el.attributes].some((attribute) => attribute.name.startsWith("data-volt-")); 45} 46 47/** 48 * Get all data-volt-* attributes from an element. 49 * Excludes charge metadata attributes (state, computed:*) that are processed separately. 50 * 51 * @param el - Element to get attributes from 52 * @returns Map of attribute names to values (without the data-volt- prefix) 53 */ 54export function getVoltAttrs(el: Element): Map<string, string> { 55 const attrs = new Map<string, string>(); 56 57 for (const attr of el.attributes) { 58 if (attr.name.startsWith("data-volt-")) { 59 const name = attr.name.slice(10); 60 61 if (name === "state" || name.startsWith("computed:")) { 62 continue; 63 } 64 65 attrs.set(name, attr.value); 66 } 67 } 68 return attrs; 69} 70 71/** 72 * Set the text content of an element safely. 73 * 74 * @param el - Element to update 75 * @param value - Text value to set 76 */ 77export function setText(el: Element, value: unknown): void { 78 el.textContent = String(value ?? ""); 79} 80 81/** 82 * Set the HTML content of an element safely. 83 * Note: This trusts the input HTML and should only be used with sanitized content. 84 * 85 * @param el - Element to update 86 * @param value - HTML string to set 87 */ 88export function setHTML(el: Element, value: string): void { 89 el.innerHTML = value; 90} 91 92/** 93 * Add or remove a CSS class from an element. 94 * 95 * @param el - Element to update 96 * @param cls - Class name to toggle 97 * @param add - Whether to add (true) or remove (false) the class 98 */ 99export function toggleClass(el: Element, cls: string, add: boolean): void { 100 el.classList.toggle(cls, add); 101} 102 103/** 104 * Check if value is a wrapped signal (from wrapSignal in evaluator) 105 */ 106function isWrappedSignal(value: unknown): boolean { 107 return (value !== null 108 && typeof value === "object" 109 && typeof (value as { get?: unknown }).get === "function" 110 && typeof (value as { subscribe?: unknown }).subscribe === "function"); 111} 112 113/** 114 * Unwrap a value if it's a signal or wrapped signal 115 */ 116function unwrapIfSignal(value: unknown): unknown { 117 if (isWrappedSignal(value)) { 118 return (value as { get: () => unknown }).get(); 119 } 120 return value; 121} 122 123/** 124 * Parse a class binding expression. 125 * Supports string values ("active"), object notation ({active: true}), 126 * and other primitives (true, false, numbers) which are converted to strings. 127 * 128 * @param value - The class value or object 129 * @returns Map of class names to boolean values 130 */ 131export function parseClassBinding(value: unknown): Map<string, boolean> { 132 const classes = new Map<string, boolean>(); 133 switch (typeof value) { 134 case "string": { 135 for (const cls of value.split(/\s+/).filter(Boolean)) { 136 classes.set(cls, true); 137 } 138 break; 139 } 140 case "object": { 141 if (value !== null) { 142 for (const [key, value_] of Object.entries(value)) { 143 const unwrapped = unwrapIfSignal(value_); 144 classes.set(key, Boolean(unwrapped)); 145 } 146 } 147 break; 148 } 149 case "boolean": 150 case "number": { 151 classes.set(String(value), true); 152 break; 153 } 154 } 155 156 return classes; 157}