a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add basic documentation

* fix eslint parser options
* reactivity improvement todos

+1114 -89
+23
ROADMAP.md
··· 94 94 95 95 ## To-Do 96 96 97 + ### Proxy-Based Reactivity Enhancements 98 + 99 + **Goal:** Leverage JavaScript Proxies to improve reactivity ergonomics and automatic dependency tracking. 100 + **Outcome:** More intuitive API with automatic dependency tracking and optional deep reactivity for objects/arrays. 101 + **Deliverables:** 102 + - Automatic dependency tracking for `computed()` 103 + - Eliminate manual dependency arrays via proxy-based tracking 104 + - Auto-detect signal access during computation 105 + - Track nested property access for fine-grained updates 106 + - `reactive()` primitive for deep object reactivity (optional, alongside `signal()`) 107 + - Nested property changes trigger updates automatically 108 + - Proxy-wrapped objects with transparent reactivity 109 + - Array reactivity improvements 110 + - Reactive array methods (push, pop, shift, unshift, splice, etc.) 111 + - Automatic updates on array mutations 112 + - Efficient tracking of index-based changes 113 + - Lazy signal initialization 114 + - Create signals on-demand when properties are accessed 115 + - Expose debugging utilities 116 + **Notes:** 117 + - Separate reactive() function for objects/arrays to gives users choice 118 + - Keep .get()/.set() - explicitness is valuable for understanding reactivity (include in docs) 119 + 97 120 ### Streaming & Patch Engine 98 121 99 122 **Goal:** Enable real-time updates via SSE/WebSocket streaming with intelligent DOM patching.
+26 -39
dev/eslint.config.js
··· 1 1 import { includeIgnoreFile } from "@eslint/compat"; 2 2 import js from "@eslint/js"; 3 3 import unicorn from "eslint-plugin-unicorn"; 4 + import { defineConfig } from "eslint/config"; 4 5 import globals from "globals"; 5 6 import { fileURLToPath } from "node:url"; 6 7 import ts from "typescript-eslint"; 7 8 8 - const gitignorePath = fileURLToPath( 9 - new globalThis.URL("./.gitignore", import.meta.url) 10 - ); 9 + const gitignorePath = fileURLToPath(new globalThis.URL("./.gitignore", import.meta.url)); 11 10 12 11 /** @type {import('eslint').Linter.Config} */ 13 - export default ts.config( 14 - includeIgnoreFile(gitignorePath), 15 - js.configs.recommended, 16 - unicorn.configs.recommended, 17 - ...ts.configs.recommended, 18 - { 19 - languageOptions: { 20 - globals: { ...globals.browser, ...globals.node }, 21 - parserOptions: { 22 - project: ["./tsconfig.json"], 23 - tsconfigRootDir: import.meta.dirname, 24 - }, 25 - }, 26 - ignores: ["eslint.config.js", "tsdown.config.ts"], 27 - rules: { 28 - "no-undef": "off", 29 - "@typescript-eslint/no-unused-vars": [ 30 - "warn", 31 - { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 32 - ], 33 - "@typescript-eslint/no-explicit-any": "off", 34 - "unicorn/prefer-ternary": "off", 35 - "no-console": "off", 36 - "unicorn/filename-case": [ 37 - "warn", 38 - { 39 - cases: { pascalCase: true, kebabCase: true }, 40 - multipleFileExtensions: false, 41 - }, 42 - ], 43 - "unicorn/no-null": "off", 44 - "unicorn/prevent-abbreviations": "off", 45 - }, 12 + export default defineConfig( 13 + includeIgnoreFile(gitignorePath), 14 + js.configs.recommended, 15 + unicorn.configs.recommended, 16 + ...ts.configs.recommended, 17 + { 18 + languageOptions: { 19 + globals: { ...globals.browser, ...globals.node }, 20 + parserOptions: { project: "./tsconfig.json", tsconfigRootDir: import.meta.dirname }, 21 + }, 22 + rules: { 23 + "no-undef": "off", 24 + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], 25 + "@typescript-eslint/no-explicit-any": "off", 26 + "unicorn/prefer-ternary": "off", 27 + "no-console": "off", 28 + "unicorn/filename-case": ["warn", { 29 + cases: { pascalCase: true, kebabCase: true }, 30 + multipleFileExtensions: false, 31 + }], 32 + "unicorn/no-null": "off", 33 + "unicorn/prevent-abbreviations": "off", 46 34 }, 47 - { 48 - rules: { "unicorn/prefer-top-level-await": "off" }, 49 - } 35 + }, 36 + { rules: { "unicorn/prefer-top-level-await": "off" } }, 50 37 );
+2 -1
dev/tsconfig.json
··· 15 15 "isolatedModules": true, 16 16 "verbatimModuleSyntax": true, 17 17 "skipLibCheck": true, 18 + "rootDir": ".", 18 19 "baseUrl": ".", 19 20 "paths": { 20 21 "$commands/*": ["./src/commands/*"], ··· 23 24 "$console/*": ["./src/console/*"] 24 25 } 25 26 }, 26 - "include": ["src", "tests"] 27 + "include": ["src", "tests", "eslint.config.js", "vitest.config.ts", "tsdown.config.ts"] 27 28 }
+17
dev/vitest.config.ts
··· 1 + import path from "node:path"; 2 + import { fileURLToPath } from "node:url"; 3 + import { defineConfig } from "vitest/config"; 4 + 5 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 6 + 7 + export default defineConfig({ 8 + test: { exclude: [], watch: false }, 9 + resolve: { 10 + alias: { 11 + $commands: path.resolve(__dirname, "./src/commands"), 12 + $utils: path.resolve(__dirname, "./src/utils"), 13 + $versioning: path.resolve(__dirname, "./src/versioning"), 14 + $console: path.resolve(__dirname, "./src/console"), 15 + }, 16 + }, 17 + });
+12 -3
docs/.vitepress/config.ts
··· 11 11 themeConfig: { 12 12 nav: [{ text: "Home", link: "/" }, { text: "Overview", link: "/overview" }, { text: "CSS", link: "/css/volt-css" }], 13 13 sidebar: [ 14 - { text: "Getting Started", items: [{ text: "Overview", link: "/overview" }] }, 15 - { text: "Concepts", items: [{ text: "Lifecycle", link: "/lifecycle" }] }, 16 - { text: "Expressions", items: [{ text: "Expressions", link: "/expressions" }] }, 14 + { 15 + text: "Getting Started", 16 + items: [{ text: "Overview", link: "/overview" }, { text: "Installation", link: "/installation" }], 17 + }, 18 + { 19 + text: "Core Concepts", 20 + items: [{ text: "State Management", link: "/state" }, { text: "Bindings", link: "/bindings" }, { 21 + text: "Expressions", 22 + link: "/expressions", 23 + }, { text: "SSR & Lifecycle", link: "/lifecycle" }], 24 + }, 25 + { text: "Tutorials", items: [{ text: "Counter", link: "/usage/counter" }] }, 17 26 { 18 27 text: "CSS", 19 28 collapsed: false,
+415
docs/bindings.md
··· 1 + # Volt Bindings 2 + 3 + Bindings connect reactive state to the DOM using `data-volt-*` attributes. Each binding evaluates expressions and updates the DOM when dependencies change. 4 + 5 + All bindings support the full expression syntax documented in [Expression Evaluation](./expressions), including property access, operators, function calls, and signal unwrapping. 6 + 7 + ## Content 8 + 9 + ### Text Content 10 + 11 + The `data-volt-text` binding updates an element's text content: 12 + 13 + ```html 14 + <p data-volt-text="message">Fallback text</p> 15 + ``` 16 + 17 + Text content is automatically escaped for security. Use this binding for any user-generated content to prevent XSS attacks. 18 + 19 + The fallback text (between the opening and closing tags) is displayed until the framework mounts and evaluates the expression. 20 + 21 + ### HTML Content 22 + 23 + The `data-volt-html` binding updates an element's innerHTML: 24 + 25 + ```html 26 + <div data-volt-html="richContent"></div> 27 + ``` 28 + 29 + This binding renders raw HTML without escaping. Only use it with trusted content. Never use `data-volt-html` with user-generated content unless it has been sanitized. 30 + 31 + The binding removes existing children before inserting new content. Event listeners on removed elements are not automatically cleaned up—prefer using `data-volt-if` for conditional content with event handlers. 32 + 33 + ## Attributes 34 + 35 + ### Generic Attributes 36 + 37 + The `data-volt-bind:*` syntax binds any HTML attribute: 38 + 39 + ```html 40 + <img data-volt-bind:src="imageUrl" data-volt-bind:alt="imageAlt"> 41 + <a data-volt-bind:href="linkUrl" data-volt-bind:target="linkTarget">Link</a> 42 + <input data-volt-bind:disabled="isDisabled" data-volt-bind:placeholder="placeholderText"> 43 + ``` 44 + 45 + The attribute name follows the colon. The expression value is converted to a string and set as the attribute value. 46 + 47 + For boolean attributes (`disabled`, `checked`, `required`, etc.), the attribute is added when the expression is truthy and removed when falsy. 48 + 49 + ### Class Binding 50 + 51 + The `data-volt-class` binding toggles CSS classes based on an object expression: 52 + 53 + ```html 54 + <div data-volt-class="{ active: isActive, disabled: !canInteract, 'has-error': hasError }"> 55 + ``` 56 + 57 + Each key in the object is a class name. When the corresponding value is truthy, the class is added; when falsy, the class is removed. 58 + 59 + Class names with hyphens or spaces must be quoted. The binding preserves existing classes not managed by Volt.js. 60 + 61 + ## Event Bindings 62 + 63 + ### Event Listeners 64 + 65 + The `data-volt-on-*` syntax attaches event listeners where the wildcard is the event name: 66 + 67 + ```html 68 + <button data-volt-on-click="handleClick">Click me</button> 69 + <input data-volt-on-input="query.set($event.target.value)"> 70 + <form data-volt-on-submit="handleSubmit($event)"> 71 + ``` 72 + 73 + Event handlers receive two special scope variables: 74 + 75 + - `$event`: The native browser event object 76 + - `$el`: The element that has the binding 77 + 78 + Event expressions commonly call functions or set signal values. The event's default behavior can be prevented by calling `$event.preventDefault()` in the expression. 79 + 80 + ### Supported Events 81 + 82 + Any valid DOM event name works: 83 + 84 + - Mouse: `click`, `dblclick`, `mousedown`, `mouseup`, `mouseenter`, `mouseleave`, `mousemove` 85 + - Keyboard: `keydown`, `keyup`, `keypress` 86 + - Form: `input`, `change`, `submit`, `focus`, `blur` 87 + - Touch: `touchstart`, `touchmove`, `touchend` 88 + - Drag: `dragstart`, `dragover`, `drop` 89 + - Media: `play`, `pause`, `ended`, `timeupdate` 90 + 91 + Event names are case-insensitive in HTML but case-sensitive in XHTML. Use lowercase for consistency. 92 + 93 + ## Form Bindings 94 + 95 + ### Two-Way Binding 96 + 97 + The `data-volt-model` binding creates two-way synchronization between form inputs and signals: 98 + 99 + ```html 100 + <input data-volt-model="username"> 101 + <textarea data-volt-model="bio"></textarea> 102 + <select data-volt-model="country"> 103 + <option value="us">United States</option> 104 + <option value="uk">United Kingdom</option> 105 + </select> 106 + ``` 107 + 108 + The binding works with text inputs, textareas, select dropdowns, checkboxes, and radio buttons. 109 + 110 + For checkboxes, the signal value is a boolean. For radio buttons, all inputs with the same `data-volt-model` should share the same signal, and each input should have a unique `value` attribute. 111 + 112 + The binding listens for `input` events and updates the signal with the current value. It also sets the initial value from the signal when mounting. 113 + 114 + ### Checkbox Arrays 115 + 116 + Multiple checkboxes can bind to an array signal: 117 + 118 + ```html 119 + <input type="checkbox" value="red" data-volt-model="colors"> 120 + <input type="checkbox" value="green" data-volt-model="colors"> 121 + <input type="checkbox" value="blue" data-volt-model="colors"> 122 + ``` 123 + 124 + When checked, the checkbox value is added to the array. When unchecked, it's removed. The signal must be initialized as an array. 125 + 126 + ## Conditional Rendering 127 + 128 + ### If/Else 129 + 130 + The `data-volt-if` binding conditionally renders elements based on expression truthiness: 131 + 132 + ```html 133 + <p data-volt-if="isLoggedIn">Welcome back!</p> 134 + <p data-volt-else>Please log in.</p> 135 + ``` 136 + 137 + When the condition is falsy, the element is removed from the DOM entirely. When truthy, it's inserted back. 138 + 139 + The `data-volt-else` binding must immediately follow a `data-volt-if` sibling element. It renders when the preceding `if` condition is falsy. 140 + 141 + Removed elements and their children are completely disposed, including event listeners and nested bindings. This prevents memory leaks and ensures clean teardown. 142 + 143 + ### Show/Hide Alternative 144 + 145 + For toggling visibility without removing elements from the DOM, use `data-volt-class` with a `hidden` class: 146 + 147 + ```html 148 + <style> 149 + .hidden { display: none; } 150 + </style> 151 + <p data-volt-class="{ hidden: !isVisible }">Toggle me</p> 152 + ``` 153 + 154 + This approach is more performant for frequently toggled content since elements remain in the DOM. 155 + 156 + ## List Rendering 157 + 158 + ### For Loop 159 + 160 + The `data-volt-for` binding repeats elements for each item in an array: 161 + 162 + ```html 163 + <ul> 164 + <li data-volt-for="item in items" data-volt-text="item.name"></li> 165 + </ul> 166 + ``` 167 + 168 + The syntax is `item in array` where `item` is the loop variable name and `array` is an expression that resolves to an array. 169 + 170 + Each iteration creates a new scope with: 171 + 172 + - `item`: The current array element 173 + - `$index`: The zero-based index (number) 174 + 175 + The binding tracks array changes and efficiently updates the DOM: 176 + 177 + - New items are appended 178 + - Removed items are disposed 179 + - Reordered items are moved 180 + 181 + For optimal performance with large lists, ensure array items have stable identities. Mutating the array in place triggers re-renders for affected items only. 182 + 183 + ### Nested Loops 184 + 185 + Loops can be nested to render multidimensional data: 186 + 187 + ```html 188 + <div data-volt-for="category in categories"> 189 + <h2 data-volt-text="category.name"></h2> 190 + <ul> 191 + <li data-volt-for="product in category.products" data-volt-text="product.name"></li> 192 + </ul> 193 + </div> 194 + ``` 195 + 196 + Each loop creates its own scope. Inner loops can access outer loop variables. 197 + 198 + ### Index Access 199 + 200 + Use the `$index` variable to access the current iteration index: 201 + 202 + ```html 203 + <ul> 204 + <li data-volt-for="item in items"> 205 + <span data-volt-text="$index + 1"></span>: <span data-volt-text="item.name"></span> 206 + </li> 207 + </ul> 208 + ``` 209 + 210 + ## HTTP 211 + 212 + HTTP bindings enable declarative AJAX requests without writing JavaScript. They integrate with hypermedia patterns for server-rendered HTML fragments. 213 + 214 + ### HTTP Methods 215 + 216 + Each HTTP method has a corresponding binding: 217 + 218 + ```html 219 + <button data-volt-get="/api/users">Fetch Users</button> 220 + <form data-volt-post="/api/users">...</form> 221 + <button data-volt-put="/api/users/1">Update</button> 222 + <button data-volt-patch="/api/users/1">Patch</button> 223 + <button data-volt-delete="/api/users/1">Delete</button> 224 + ``` 225 + 226 + The binding value is the URL. When the element is activated (clicked for buttons, submitted for forms), the request is sent. 227 + 228 + ### Target and Swap 229 + 230 + Control where and how the response HTML is inserted using `data-volt-target` and `data-volt-swap`: 231 + 232 + ```html 233 + <button 234 + data-volt-get="/partials/content" 235 + data-volt-target="#main" 236 + data-volt-swap="innerHTML"> 237 + Load Content 238 + </button> 239 + ``` 240 + 241 + **Target** is a CSS selector identifying the element to update. If omitted, the element with the HTTP binding is the target. 242 + 243 + **Swap** strategies determine how content is inserted: 244 + 245 + - `innerHTML`: Replace target's content (default) 246 + - `outerHTML`: Replace target itself 247 + - `beforebegin`: Insert before target 248 + - `afterbegin`: Insert as target's first child 249 + - `beforeend`: Insert as target's last child 250 + - `afterend`: Insert after target 251 + - `delete`: Remove target from DOM 252 + - `none`: Make request but don't modify DOM 253 + 254 + ### Form Serialization 255 + 256 + Forms with HTTP bindings automatically serialize input values: 257 + 258 + ```html 259 + <form data-volt-post="/api/login"> 260 + <input name="username" data-volt-model="username"> 261 + <input name="password" type="password" data-volt-model="password"> 262 + <button type="submit">Login</button> 263 + </form> 264 + ``` 265 + 266 + The framework serializes form data based on the HTTP method: 267 + 268 + - GET/DELETE: Query parameters in URL 269 + - POST/PUT/PATCH: Request body as `application/x-www-form-urlencoded` or `multipart/form-data` for file uploads 270 + 271 + ### Loading Indicators 272 + 273 + Show loading states during requests with `data-volt-indicator`: 274 + 275 + ```html 276 + <button data-volt-get="/api/data" data-volt-indicator="#spinner"> 277 + Load Data 278 + </button> 279 + <div id="spinner" class="hidden">Loading...</div> 280 + ``` 281 + 282 + The indicator element (selected by CSS selector) has a `loading` class added during the request and removed when complete. 283 + 284 + ### Retry Logic 285 + 286 + Enable automatic retry with exponential backoff using `data-volt-retry`: 287 + 288 + ```html 289 + <button 290 + data-volt-get="/api/unreliable" 291 + data-volt-retry="3"> 292 + Fetch with Retry 293 + </button> 294 + ``` 295 + 296 + The binding value is the maximum number of retry attempts. The framework automatically retries failed requests with increasing delays (1s, 2s, 4s, etc.). 297 + 298 + Retries only occur for network failures and 5xx server errors. Client errors (4xx) are not retried. 299 + 300 + ## Plugins 301 + 302 + Plugins extend the framework with additional bindings. Register plugins before mounting to make their bindings available. 303 + 304 + ### Persist 305 + 306 + The `data-volt-persist` binding synchronizes signals with browser storage: 307 + 308 + ```html 309 + <div 310 + data-volt 311 + data-volt-state='{"theme": "light"}' 312 + data-volt-persist:theme="localStorage"> 313 + </div> 314 + ``` 315 + 316 + The binding syntax is `data-volt-persist:signalName="storageType"` where storage type is: 317 + 318 + - `localStorage`: Persists across browser sessions 319 + - `sessionStorage`: Persists for the current tab session 320 + - `indexedDB`: For large datasets (async) 321 + 322 + The signal value is serialized to JSON and stored. On mount, stored values override initial state. 323 + 324 + ### Scroll 325 + 326 + Scroll bindings manage scroll position and behavior: 327 + 328 + ```html 329 + <!-- Restore scroll position on navigation --> 330 + <div data-volt-scroll-restore></div> 331 + 332 + <!-- Scroll to element --> 333 + <button data-volt-scroll-to="#target">Scroll to Target</button> 334 + 335 + <!-- Scroll spy (add class when in viewport) --> 336 + <section data-volt-scroll-spy="active"></section> 337 + 338 + <!-- Smooth scrolling --> 339 + <div data-volt-scroll-smooth></div> 340 + ``` 341 + 342 + **Scroll restore** saves scroll position before navigation and restores it when returning. 343 + 344 + **Scroll to** scrolls the viewport to bring the target element into view when activated. 345 + 346 + **Scroll spy** adds a class when the element enters the viewport and removes it when leaving. 347 + 348 + **Smooth scrolling** enables CSS `scroll-behavior: smooth` for the element. 349 + 350 + ### URL Synchronization 351 + 352 + The `data-volt-url` binding syncs signals with URL query parameters or hash: 353 + 354 + ```html 355 + <div 356 + data-volt 357 + data-volt-state='{"page": 1, "query": ""}' 358 + data-volt-url:page="query" 359 + data-volt-url:query="query"> 360 + </div> 361 + ``` 362 + 363 + The binding syntax is `data-volt-url:signalName="urlPart"` where URL part is: 364 + 365 + - `query`: Sync with query parameter (e.g., `?page=1`) 366 + - `hash`: Sync with URL hash (e.g., `#section`) 367 + 368 + Signal changes update the URL, and URL changes (back/forward navigation) update signals. This enables client-side routing without additional libraries. 369 + 370 + ## Custom Bindings 371 + 372 + Register custom bindings for domain-specific behavior using the plugin API: 373 + 374 + ```js 375 + import { registerPlugin } from '@voltjs/volt'; 376 + 377 + registerPlugin('tooltip', (ctx) => { 378 + const message = ctx.evaluate(ctx.element.getAttribute('data-volt-tooltip')); 379 + const tooltip = document.createElement('div'); 380 + tooltip.className = 'tooltip'; 381 + tooltip.textContent = message; 382 + 383 + ctx.element.addEventListener('mouseenter', () => { 384 + document.body.appendChild(tooltip); 385 + }); 386 + 387 + ctx.element.addEventListener('mouseleave', () => { 388 + tooltip.remove(); 389 + }); 390 + 391 + ctx.addCleanup(() => tooltip.remove()); 392 + }); 393 + ``` 394 + 395 + The plugin context provides: 396 + 397 + - `element`: The DOM element with the binding 398 + - `scope`: Signal scope for the mounted component 399 + - `evaluate(expression)`: Evaluate expressions in the current scope 400 + - `findSignal(path)`: Find signals by property path 401 + - `addCleanup(fn)`: Register cleanup callbacks 402 + 403 + Custom bindings should always register cleanup to prevent memory leaks. 404 + 405 + ## Lifecycle 406 + 407 + All bindings follow a consistent lifecycle: 408 + 409 + 1. **Mount**: The `charge()` or `mount()` function discovers elements with `data-volt-*` attributes 410 + 2. **Evaluation**: Expressions are parsed and evaluated in the current scope 411 + 3. **Subscription**: Bindings subscribe to referenced signals 412 + 4. **Update**: When signals change, bindings re-evaluate and update the DOM 413 + 5. **Cleanup**: When unmounted, subscriptions are disposed and DOM changes are reverted 414 + 415 + Cleanup is automatic for all built-in bindings. Custom bindings must explicitly register cleanup using `ctx.addCleanup()`.
+170
docs/installation.md
··· 1 + # Installation 2 + 3 + <!-- TODO: Figure out actual project path @voltjs/volt --> 4 + 5 + Volt.js can be installed via CDN or package manager. Choose the method that best fits your project setup. 6 + 7 + ## CDN (unpkg) 8 + 9 + The simplest way to get started is loading Volt.js directly from a CDN. This approach requires no build tools and works immediately in any HTML file. 10 + 11 + ### ES Modules 12 + 13 + Use the module build for modern browsers with ES module support: 14 + 15 + ```html 16 + <script type="module"> 17 + import { charge, registerPlugin } from 'https://unpkg.com/@voltjs/volt@latest/dist/volt.js'; 18 + charge(); 19 + </script> 20 + ``` 21 + 22 + You can optionally pin to a specific version: 23 + 24 + ```html 25 + <script type="module"> 26 + import { charge } from 'https://unpkg.com/@voltjs/volt@0.1.0/dist/volt.js'; 27 + charge(); 28 + </script> 29 + ``` 30 + 31 + ## Package Manager 32 + 33 + For applications using node based tools, install Volt.js via npm or similar: 34 + 35 + ```bash 36 + npm install @voltjs/volt 37 + ``` 38 + 39 + ```bash 40 + pnpm add @voltjs/volt 41 + ``` 42 + 43 + ### Module Imports 44 + 45 + Import only the functions you need to minimize bundle size: 46 + 47 + ```js 48 + import { charge, registerPlugin } from '@voltjs/volt'; 49 + import { persistPlugin } from '@voltjs/volt/plugins'; 50 + 51 + registerPlugin('persist', persistPlugin); 52 + charge(); 53 + ``` 54 + 55 + The framework uses tree-shaking to eliminate unused code when bundled with modern build tools like Vite, Rollup, or Webpack. 56 + 57 + ## TypeScript 58 + 59 + Volt.js is written in TypeScript and includes complete type definitions. 60 + 61 + TypeScript users get automatic type inference for: 62 + 63 + - Signal values and methods 64 + - Computed dependencies 65 + - Plugin contexts 66 + - Scope objects passed to `mount()` 67 + 68 + ## Basic Setup 69 + 70 + ### Declarative Mode (Recommended) 71 + 72 + For applications that can be built entirely in HTML, use the declarative approach with `charge()`: 73 + 74 + ```html 75 + <!DOCTYPE html> 76 + <html lang="en"> 77 + <head> 78 + <meta charset="UTF-8"> 79 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 80 + <title>Volt.js App</title> 81 + </head> 82 + <body> 83 + <div data-volt data-volt-state='{"count": 0}'> 84 + <h1 data-volt-text="count">0</h1> 85 + <button data-volt-on-click="count.set(count.get() + 1)">Increment</button> 86 + </div> 87 + 88 + <script type="module"> 89 + import { charge } from 'https://unpkg.com/@voltjs/volt@latest/dist/volt.js'; 90 + charge(); 91 + </script> 92 + </body> 93 + </html> 94 + ``` 95 + 96 + The `charge()` function auto-discovers all elements with the `data-volt` attribute and mounts them with their declared state. 97 + 98 + ### Programmatic Mode 99 + 100 + For applications requiring initialization logic, use the programmatic API with `mount()`: 101 + 102 + ```html 103 + <script type="module"> 104 + import { mount, signal } from 'https://unpkg.com/@voltjs/volt@latest/dist/volt.js'; 105 + 106 + const count = signal(0); 107 + 108 + mount(document.querySelector('#app'), { 109 + count, 110 + increment: () => count.set(count.get() + 1) 111 + }); 112 + </script> 113 + ``` 114 + 115 + This approach gives you full control over signal creation, initialization, and the scope object. 116 + 117 + ## Server-Side Rendering 118 + 119 + For SSR applications, use the `hydrate()` function instead of `charge()` to preserve server-rendered HTML and attach interactivity: 120 + 121 + ```html 122 + <script type="module"> 123 + import { hydrate } from '@voltjs/volt'; 124 + hydrate(); 125 + </script> 126 + ``` 127 + 128 + See the [Server-Side Rendering & Lifecycle](./lifecycle) documentation for complete SSR patterns and hydration strategies. 129 + 130 + ## Plugin Setup 131 + 132 + Volt.js includes several built-in plugins that must be registered before use: 133 + 134 + ```html 135 + <script type="module"> 136 + import { charge, registerPlugin } from '@voltjs/volt'; 137 + import { persistPlugin, scrollPlugin, urlPlugin } from '@voltjs/volt/plugins'; 138 + 139 + registerPlugin('persist', persistPlugin); 140 + registerPlugin('scroll', scrollPlugin); 141 + registerPlugin('url', urlPlugin); 142 + 143 + charge(); 144 + </script> 145 + ``` 146 + 147 + Register plugins before calling `charge()`, `mount()`, or `hydrate()` to ensure they're available for binding resolution. 148 + 149 + ## Browser Compatibility 150 + 151 + Volt.js requires modern browsers with support for: 152 + 153 + - ES2020 syntax (optional chaining, nullish coalescing) 154 + - ES modules 155 + - Proxy objects 156 + - CSS custom properties 157 + 158 + **Minimum versions:** 159 + 160 + - Chrome 90+ 161 + - Firefox 88+ 162 + - Safari 14+ 163 + - Edge 90+ 164 + 165 + ## Next Up 166 + 167 + - Read the [Framework Overview](./overview) to understand core concepts 168 + - Learn about [State Management](./state) with signals and computed values 169 + - Explore available [Bindings](./bindings) for DOM manipulation 170 + - Check out [Expression Evaluation](./expressions) for template syntax
+129
docs/state.md
··· 1 + # State Management 2 + 3 + Volt.js uses signal-based reactivity for state management. State changes automatically trigger DOM updates without virtual DOM diffing or reconciliation. 4 + 5 + ## Reactive Primitives 6 + 7 + ### Signals 8 + 9 + Signals are the foundation of reactive state. A signal holds a single value that can be read, written, and observed for changes. 10 + 11 + Create signals using the `signal()` function, which returns an object with three methods: 12 + 13 + - `get()` returns the current value 14 + - `set(newValue)` updates the value and notifies subscribers 15 + - `subscribe(callback)` registers a listener for changes 16 + 17 + Signals use strict equality (`===`) to determine if a value has changed. Setting a signal to its current value will not trigger notifications. 18 + 19 + ### Computed Values 20 + 21 + Computed signals derive their values from other signals. They automatically track dependencies and recalculate only when those dependencies change. 22 + 23 + The `computed()` function takes a calculation function and a dependency array. The framework ensures computed values stay synchronized with their sources. 24 + 25 + Computed values are read-only and should not produce side effects. They exist purely to transform or combine other state. 26 + 27 + ### Effects 28 + 29 + Effects run side effects in response to signal changes. The `effect()` function executes immediately and re-runs whenever its dependencies update. 30 + 31 + Common uses include: 32 + 33 + - Synchronizing with external APIs 34 + - Logging or analytics 35 + - Coordinating multiple signals 36 + 37 + For asynchronous operations, use `asyncEffect()` which handles cleanup of pending operations when dependencies change or the effect is disposed. 38 + 39 + ## Declarative State 40 + 41 + The preferred approach for most applications is declaring state directly in HTML using the `data-volt-state` attribute. This eliminates the need to write JavaScript for basic state management. 42 + 43 + State is declared as inline JSON on any element with the `data-volt` attribute: 44 + 45 + ```html 46 + <div data-volt data-volt-state='{"count": 0, "items": []}'> 47 + ``` 48 + 49 + The framework automatically converts these values into reactive signals. Nested objects and arrays become reactive, and property access in expressions automatically unwraps signal values. 50 + 51 + ### Computed Values in Markup 52 + 53 + Derive values declaratively using `data-volt-computed:name` attributes. The name becomes a signal in the scope, and the attribute value is the computation expression: 54 + 55 + ```html 56 + <div data-volt 57 + data-volt-state='{"count": 5}' 58 + data-volt-computed:doubled="count * 2"> 59 + ``` 60 + 61 + Computed values defined this way follow the same rules as programmatic computed signalsthey track dependencies and update automatically. 62 + 63 + ## Programmatic State 64 + 65 + For complex applications requiring initialization logic or external API integration, create signals programmatically and pass them to the `mount()` function. 66 + 67 + This approach gives you full control over signal creation, composition, and lifecycle. Use it when: 68 + 69 + - State initialization requires async operations 70 + - Signals need to be shared across multiple mount points 71 + - Complex validation or transformation logic is needed 72 + - Integration with external state management is required 73 + 74 + ## Scope and Access 75 + 76 + Each mounted element creates a scope containing its signals and computed values. Bindings access signals by property path relative to their scope. 77 + 78 + When using declarative state, the scope is built automatically from `data-volt-state` and `data-volt-computed:*` attributes. 79 + 80 + When using programmatic mounting, the scope is the object passed as the second argument to `mount()`. 81 + 82 + Bindings can access nested properties, and the evaluator automatically unwraps signal values. Event handlers receive special scope additions: `$el` for the element and `$event` for the event object. 83 + 84 + ## Signal Methods in Expressions 85 + 86 + While signal values are automatically unwrapped in most expressions, explicit signal methods are available when needed: 87 + 88 + - Use `signal.get()` to read the current value 89 + - Use `signal.set(newValue)` to update state from event handlers 90 + - Use `signal.subscribe(fn)` in custom JavaScript (not typical in templates) 91 + 92 + The `.set()` method is commonly used in `data-volt-on-*` event bindings to update state in response to user actions. 93 + 94 + ## State Persistence 95 + 96 + Signals can be synchronized with browser storage using the built-in persist plugin. See the plugin documentation for details on localStorage, sessionStorage, and IndexedDB integration. 97 + 98 + ## State Serialization 99 + 100 + For server-side rendering, signals can be serialized to JSON and embedded in HTML for hydration on the client. This preserves state across the server-client boundary. 101 + 102 + Only serialize base signals containing primitive values, arrays, and plain objects. Computed signals are recalculated during hydration and should not be serialized. 103 + 104 + See the [Server-Side Rendering & Lifecycle](./lifecycle.md) documentation for complete SSR patterns. 105 + 106 + ## Guidelines 107 + 108 + ### Performance 109 + 110 + - Keep signal values immutable when possible. Create new objects rather than mutating existing ones 111 + - Use computed signals to avoid redundant calculations 112 + - Avoid creating signals inside loops or frequently-called functions 113 + 114 + ### Architecture 115 + 116 + - Prefer declarative state for simple, self-contained components 117 + - Use programmatic state for complex initialization or cross-component coordination 118 + - Keep state close to where it's used: avoid deeply nested property access 119 + - Structure state with consistent shapes to prevent runtime errors in expressions 120 + 121 + ### Debugging 122 + 123 + Signal updates are synchronous and deterministic. To trace state changes: 124 + 125 + - Use browser DevTools to set breakpoints in signal `.set()` calls 126 + - Subscribe to signals and log changes for debugging 127 + - Enable Volt.js lifecycle hooks to observe mount and binding creation 128 + 129 + All errors in effects and subscriptions are caught and logged rather than thrown, preventing cascade failures.
+289
docs/usage/counter.md
··· 1 + # Building a Counter 2 + 3 + This tutorial walks through building a simple counter application to demonstrate Volt.js fundamentals: reactive state, event handling, computed values, and declarative markup. 4 + 5 + ## Basic Counter (Declarative) 6 + 7 + The simplest way to build a counter is using declarative state and bindings directly in HTML. 8 + 9 + Create an HTML file with this structure: 10 + 11 + ```html 12 + <!DOCTYPE html> 13 + <html lang="en"> 14 + <head> 15 + <meta charset="UTF-8"> 16 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 17 + <title>Counter - Volt.js</title> 18 + </head> 19 + <body> 20 + <div data-volt data-volt-state='{"count": 0}'> 21 + <h1 data-volt-text="count">0</h1> 22 + <button data-volt-on-click="count.set(count.get() + 1)">Increment</button> 23 + </div> 24 + 25 + <script type="module"> 26 + import { charge } from 'https://unpkg.com/@voltjs/volt@latest/dist/volt.js'; 27 + charge(); 28 + </script> 29 + </body> 30 + </html> 31 + ``` 32 + 33 + **How it works:** 34 + 35 + The `data-volt` attribute marks the root element for mounting. Inside, `data-volt-state` declares initial state as inline JSON. 36 + The framework converts `count` into a reactive signal automatically. 37 + 38 + The `data-volt-text` binding displays the current count value. When the signal changes, the text content updates automatically. 39 + 40 + The `data-volt-on-click` binding attaches a click handler that increments the count. We call `count.get()` to read the current value and `count.set()` to update it. 41 + 42 + Finally, `charge()` discovers all `[data-volt]` elements and mounts them with their declared state. 43 + 44 + ## Adding Decrement 45 + 46 + Extend the counter with both increment and decrement buttons: 47 + 48 + ```html 49 + <div data-volt data-volt-state='{"count": 0}'> 50 + <h1 data-volt-text="count">0</h1> 51 + <button data-volt-on-click="count.set(count.get() - 1)">-</button> 52 + <button data-volt-on-click="count.set(count.get() + 1)">+</button> 53 + </div> 54 + ``` 55 + 56 + Each button calls `count.set()` with a different expression. The decrement button subtracts 1, while increment adds 1. 57 + 58 + ## Computed Values 59 + 60 + Add derived state using `data-volt-computed` to show whether the count is positive, negative, or zero: 61 + 62 + ```html 63 + <div data-volt 64 + data-volt-state='{"count": 0}' 65 + data-volt-computed:status="count > 0 ? 'positive' : count < 0 ? 'negative' : 'zero'"> 66 + <h1 data-volt-text="count">0</h1> 67 + <p>Status: <span data-volt-text="status">zero</span></p> 68 + 69 + <button data-volt-on-click="count.set(count.get() - 1)">-</button> 70 + <button data-volt-on-click="count.set(count.get() + 1)">+</button> 71 + </div> 72 + ``` 73 + 74 + The `data-volt-computed:status` attribute creates a computed signal named `status`. It uses a ternary expression to classify the count. When `count` changes, `status` recalculates automatically. 75 + 76 + ## Conditional Rendering 77 + 78 + Show different messages based on the count value using conditional bindings: 79 + 80 + ```html 81 + <div data-volt data-volt-state='{"count": 0}'> 82 + <h1 data-volt-text="count">0</h1> 83 + 84 + <p data-volt-if="count === 0">The count is zero</p> 85 + <p data-volt-if="count > 0" data-volt-text="'Positive: ' + count"></p> 86 + <p data-volt-if="count < 0" data-volt-text="'Negative: ' + count"></p> 87 + 88 + <button data-volt-on-click="count.set(count.get() - 1)">-</button> 89 + <button data-volt-on-click="count.set(count.get() + 1)">+</button> 90 + <button data-volt-on-click="count.set(0)">Reset</button> 91 + </div> 92 + ``` 93 + 94 + The `data-volt-if` binding conditionally renders elements. Only one paragraph displays at a time based on the count value. A reset button sets the count back to zero. 95 + 96 + ## Styling with Classes 97 + 98 + Apply dynamic CSS classes based on state: 99 + 100 + ```html 101 + <style> 102 + .counter { 103 + padding: 2rem; 104 + text-align: center; 105 + font-family: system-ui, sans-serif; 106 + } 107 + 108 + .display { 109 + font-size: 4rem; 110 + margin: 1rem 0; 111 + } 112 + 113 + .positive { color: #22c55e; } 114 + .negative { color: #ef4444; } 115 + .zero { color: #6b7280; } 116 + 117 + button { 118 + font-size: 1.5rem; 119 + padding: 0.5rem 1.5rem; 120 + margin: 0.25rem; 121 + cursor: pointer; 122 + } 123 + </style> 124 + ``` 125 + 126 + ```html 127 + <div class="counter" 128 + data-volt 129 + data-volt-state='{"count": 0}'> 130 + <h1 class="display" 131 + data-volt-text="count" 132 + data-volt-class="{ positive: count > 0, negative: count < 0, zero: count === 0 }"> 133 + 0 134 + </h1> 135 + 136 + <div> 137 + <button data-volt-on-click="count.set(count.get() - 1)">-</button> 138 + <button data-volt-on-click="count.set(0)">Reset</button> 139 + <button data-volt-on-click="count.set(count.get() + 1)">+</button> 140 + </div> 141 + </div> 142 + ``` 143 + 144 + The `data-volt-class` binding takes an object where keys are class names and values are conditions. When `count` is positive, the `positive` class applies. When negative, the `negative` class applies. When zero, the `zero` class applies. 145 + 146 + ## Persisting State 147 + 148 + Use the persist plugin to save the count across page reloads: 149 + 150 + ```html 151 + <div data-volt 152 + data-volt-state='{"count": 0}' 153 + data-volt-persist:count="localStorage"> 154 + <h1 data-volt-text="count">0</h1> 155 + 156 + <button data-volt-on-click="count.set(count.get() - 1)">-</button> 157 + <button data-volt-on-click="count.set(0)">Reset</button> 158 + <button data-volt-on-click="count.set(count.get() + 1)">+</button> 159 + </div> 160 + 161 + <script type="module"> 162 + import { charge, registerPlugin } from 'https://unpkg.com/@voltjs/volt@latest/dist/volt.js'; 163 + import { persistPlugin } from 'https://unpkg.com/@voltjs/volt@latest/dist/plugins.js'; 164 + 165 + registerPlugin('persist', persistPlugin); 166 + charge(); 167 + </script> 168 + ``` 169 + 170 + The `data-volt-persist:count="localStorage"` binding synchronizes the `count` signal with browser localStorage. When the count changes, it's saved automatically. When the page loads, the saved value is restored. 171 + 172 + ## Step Counter 173 + 174 + Build a counter that increments by a configurable step value: 175 + 176 + ```html 177 + <div data-volt data-volt-state='{"count": 0, "step": 1}'> 178 + <h1 data-volt-text="count">0</h1> 179 + 180 + <label> 181 + Step: 182 + <input type="number" data-volt-model="step" min="1" value="1"> 183 + </label> 184 + 185 + <div> 186 + <button data-volt-on-click="count.set(count.get() - step)">-</button> 187 + <button data-volt-on-click="count.set(0)">Reset</button> 188 + <button data-volt-on-click="count.set(count.get() + step)">+</button> 189 + </div> 190 + </div> 191 + ``` 192 + 193 + The `data-volt-model` binding creates two-way synchronization between the input and the `step` signal. As you type, the step value updates. The increment and decrement buttons use the current step value. 194 + 195 + ## Bounded Counter 196 + 197 + Add minimum and maximum bounds with disabled button states: 198 + 199 + ```html 200 + <div data-volt 201 + data-volt-state='{"count": 0, "min": -10, "max": 10}'> 202 + <h1 data-volt-text="count">0</h1> 203 + 204 + <div> 205 + <button 206 + data-volt-on-click="count.set(count.get() - 1)" 207 + data-volt-bind:disabled="count <= min"> 208 + - 209 + </button> 210 + <button data-volt-on-click="count.set(0)">Reset</button> 211 + <button 212 + data-volt-on-click="count.set(count.get() + 1)" 213 + data-volt-bind:disabled="count >= max"> 214 + + 215 + </button> 216 + </div> 217 + 218 + <p>Range: <span data-volt-text="min"></span> to <span data-volt-text="max"></span></p> 219 + </div> 220 + ``` 221 + 222 + The `data-volt-bind:disabled` binding disables buttons when the count reaches the minimum or maximum. The decrement button disables at the minimum, and the increment button disables at the maximum. 223 + 224 + ## Programmatic Counter 225 + 226 + For applications requiring initialization logic or custom functions, use the programmatic API: 227 + 228 + ```html 229 + <script type="module"> 230 + import { mount, signal, computed } from 'https://unpkg.com/@voltjs/volt@latest/dist/volt.js'; 231 + 232 + const count = signal(0); 233 + const message = computed(() => { 234 + const value = count.get(); 235 + if (value === 0) return 'Start counting!'; 236 + if (value > 0) return `Up by ${value}`; 237 + return `Down by ${Math.abs(value)}`; 238 + }, [count]); 239 + 240 + const increment = () => { 241 + count.set(count.get() + 1); 242 + }; 243 + 244 + const decrement = () => { 245 + count.set(count.get() - 1); 246 + }; 247 + 248 + const reset = () => { 249 + count.set(0); 250 + }; 251 + 252 + mount(document.querySelector('#app'), { 253 + count, 254 + message, 255 + increment, 256 + decrement, 257 + reset 258 + }); 259 + </script> 260 + ``` 261 + 262 + This approach creates signals explicitly using `signal()` and `computed()`. Functions are defined for event handlers and passed to the scope object. The `mount()` function attaches bindings to the element. 263 + 264 + Use programmatic mounting when you need: 265 + 266 + - Complex initialization logic 267 + - Integration with external libraries 268 + - Signals shared across multiple components 269 + - Custom validation or transformation 270 + 271 + ## Summary 272 + 273 + This counter demonstrates core Volt.js concepts: 274 + 275 + - Reactive state with signals 276 + - Event handling with `data-volt-on-*` 277 + - Computed values deriving from state 278 + - Conditional rendering with `data-volt-if` 279 + - Two-way form binding with `data-volt-model` 280 + - Attribute binding with `data-volt-bind:*` 281 + - Dynamic classes with `data-volt-class` 282 + - State persistence with plugins 283 + 284 + ## Further Reading 285 + 286 + - [State Management](../state) for advanced signal patterns 287 + - [Bindings](../bindings) for complete binding reference 288 + - [Expressions](../expressions) for template syntax details 289 + - [Lifecycle](../lifecycle) for SSR and hydration
+26 -45
lib/eslint.config.js
··· 1 1 import { includeIgnoreFile } from "@eslint/compat"; 2 2 import js from "@eslint/js"; 3 3 import unicorn from "eslint-plugin-unicorn"; 4 + import { defineConfig } from "eslint/config"; 4 5 import globals from "globals"; 5 6 import { fileURLToPath } from "node:url"; 6 7 import ts from "typescript-eslint"; 7 8 8 - const gitignorePath = fileURLToPath( 9 - new globalThis.URL("./.gitignore", import.meta.url) 10 - ); 9 + const gitignorePath = fileURLToPath(new globalThis.URL("../.gitignore", import.meta.url)); 11 10 12 11 /** @type {import('eslint').Linter.Config} */ 13 - export default ts.config( 14 - includeIgnoreFile(gitignorePath), 15 - js.configs.recommended, 16 - unicorn.configs.recommended, 17 - ...ts.configs.recommended, 18 - { 19 - languageOptions: { 20 - globals: { ...globals.browser, ...globals.node }, 21 - parserOptions: { 22 - project: ["./tsconfig.json"], 23 - tsconfigRootDir: import.meta.dirname, 24 - }, 25 - }, 26 - ignores: [ 27 - "./cli/**", 28 - "eslint.config.js", 29 - "vite.config.ts", 30 - "./examples/**", 31 - "./docs/**", 32 - ], 33 - rules: { 34 - "no-undef": "off", 35 - "@typescript-eslint/no-unused-vars": [ 36 - "warn", 37 - { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 38 - ], 39 - "@typescript-eslint/no-explicit-any": "off", 40 - "unicorn/prefer-ternary": "off", 41 - "no-console": "off", 42 - "unicorn/filename-case": [ 43 - "warn", 44 - { 45 - cases: { pascalCase: true, kebabCase: true }, 46 - multipleFileExtensions: false, 47 - }, 48 - ], 49 - "unicorn/no-null": "off", 50 - "unicorn/prevent-abbreviations": "off", 51 - }, 12 + export default defineConfig( 13 + includeIgnoreFile(gitignorePath), 14 + js.configs.recommended, 15 + unicorn.configs.recommended, 16 + ...ts.configs.recommended, 17 + { 18 + languageOptions: { 19 + globals: { ...globals.browser, ...globals.node }, 20 + parserOptions: { project: "./tsconfig.json", tsconfigRootDir: import.meta.dirname }, 21 + }, 22 + rules: { 23 + "no-undef": "off", 24 + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], 25 + "@typescript-eslint/no-explicit-any": "off", 26 + "unicorn/prefer-ternary": "off", 27 + "no-console": "off", 28 + "unicorn/filename-case": ["warn", { 29 + cases: { pascalCase: true, kebabCase: true }, 30 + multipleFileExtensions: false, 31 + }], 32 + "unicorn/no-null": "off", 33 + "unicorn/prevent-abbreviations": "off", 52 34 }, 53 - { 54 - rules: { "unicorn/prefer-top-level-await": "off" }, 55 - } 35 + }, 36 + { rules: { "unicorn/prefer-top-level-await": "off" } }, 56 37 );
+2 -1
lib/tsconfig.json
··· 17 17 "erasableSyntaxOnly": true, 18 18 "noFallthroughCasesInSwitch": true, 19 19 "noUncheckedSideEffectImports": true, 20 + "rootDir": ".", 20 21 "baseUrl": ".", 21 22 "paths": { 22 23 "$types/*": ["./src/types/*"], ··· 26 27 "$volt": ["./src/index.ts"] 27 28 } 28 29 }, 29 - "include": ["src", "test"] 30 + "include": ["src", "test", "eslint.config.js", "vite.config.ts"] 30 31 }
+3
lib/vite.config.ts
··· 1 1 import path from "node:path"; 2 + import { fileURLToPath } from "node:url"; 2 3 import { defineConfig } from "vite"; 3 4 import { type ViteUserConfig } from "vitest/config"; 5 + 6 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 4 7 5 8 const test: ViteUserConfig["test"] = { 6 9 environment: "jsdom",