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