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

refactor: Vite demo application (#7)

* docs: milestone planning
* fix: expression eval with unwrapped signals
* refactor: new demo structure
* fix: css text wrapping
* fix: nested model property binding & eval
* feat: create css showcase

authored by Owais and committed by GitHub e259f0de 47dfb7b2

+37
README.md
··· 39 39 | Streams | `data-volt-stream="/events"` listens for SSE or WebSocket updates and applies JSON patches. | 40 40 | Plugins | Modular extensions (`data-volt-persist`, `data-volt-surge`, `data-volt-shift`, etc.) to enhance the core. | 41 41 42 + ## VoltX.css 43 + 44 + VoltX ships with an optional classless CSS framework inspired by Pico CSS and Tufte CSS. It provides beautiful, semantic styling for HTML elements without requiring any CSS classes—just write semantic markup and it looks great out of the box. 45 + 46 + Features include typography with modular scale, Tufte-style sidenotes, styled form elements, dialogs, accordions, tooltips, tables, and more. See the framework's [README](./lib/README.md#voltxcss) for installation and usage details. 47 + 48 + Here are some highlights 49 + 50 + ![VoltX Typography](./docs/images/voltx-css_typography.png) 51 + 52 + ![VoltX Structured Content](./docs/images/voltx-css_structured-content.png) 53 + 54 + ![VoltX Components](./docs/images/voltx-css_components.png) 55 + 42 56 ## Packages 43 57 44 58 ```sh ··· 54 68 - Local development: `pnpm install` then `pnpm --filter lib dev` run package-specific scripts (`build`, `test`, etc.). 55 69 - Review [contribution](./CONTRIBUTING.md) guidelines 56 70 - Documentation: `pnpm --filter docs docs:dev` launches the VitePress site. 71 + 72 + ### Working on New Features 73 + 74 + The `lib/` package includes a comprehensive demo Vite app showcasing all VoltX.js features: 75 + 76 + ```sh 77 + # Start the demo development server 78 + pnpm --filter voltx.js dev 79 + ``` 80 + 81 + The demo app essentially provides an interactive sandbox to develop and catch bugs in new implementations. 82 + 83 + #### Pages 84 + 85 + - **Home**: Framework overview and quick start examples 86 + - **CSS**: VoltX.css typography, layout, and component features 87 + - **Interactivity**: Dialogs, buttons, event handling 88 + - **Forms**: Two-way binding and form validation patterns 89 + - **Reactivity**: Signals, computed values, conditional/list rendering 90 + - **Plugins**: Persistence, scroll management, URL sync 91 + - **Animations**: Transitions and keyframe animations 92 + 93 + Docs are the source of truth but take advantage of this environment. When developing new features, add examples to the appropriate demo section or create a new page to showcase the functionality. 57 94 58 95 ## License 59 96
+22
ROADMAP.md
··· 173 173 - Announcement post and release notes 174 174 - Community contribution guide & governance doc 175 175 176 + ### Better Demo 177 + 178 + **Goal:** Transform the current programmatic demo into a declarative multi-page SPA showcasing all framework and CSS features. 179 + **Outcome:** Production-quality reference application demonstrating VoltX.js best practices and real-world patterns. 180 + **Deliverables:** 181 + - Convert demo from programmatic to declarative mode (charge() + data-volt attributes) 182 + - Implement multi-page routing using Navigation & History API plugin 183 + - Add tooltips to VoltX css using data attributes 184 + - Example: data-vx-tooltip="Right" data-placement="right" 185 + - Page: Home - Framework overview and feature highlights 186 + - Page: Getting Started - Installation and first examples 187 + - Page: Reactivity - Signals, computed, effects, bindings, conditional/list rendering 188 + - Page: HTTP - Backend integration with all methods, swap strategies, retry logic 189 + - Page: State - Global stores and scope helpers ($store, $scope, $pulse, $uid, $probe, $pins, $arc) 190 + - Page: Persistence - localStorage/sessionStorage/IndexedDB, persist plugin, URL sync 191 + - Page: Animations - Surge directive, shift plugin, View Transitions 192 + - Page: Forms - Model binding, validation, event modifiers, multi-step forms 193 + - Page: CSS - Complete Volt CSS showcase (typography, layout, Tufte sidenotes, tables) 194 + - Page: Patterns - Real-world components (tabs, accordion, modal, autocomplete) 195 + - View-source friendly code with clear examples 196 + - Copy-paste ready patterns for common use cases 197 + 176 198 ## Parking Lot 177 199 178 200 ### Evaluator & Binder Hardening
+214
TODO.md
··· 1 + # Better Demo Implementation TODO 2 + 3 + This document tracks the implementation of the Better Demo deliverables from ROADMAP.md. 4 + 5 + ## Existing Issues 6 + 7 + - [x] **FIXME** (lib/src/demo/sections/plugins.ts:68): Sidenotes need stylesheet constraints - RESOLVED 8 + - [x] **FIXME** (lib/src/demo/sections/interactivity.ts:46): Dialog footer structure needs correction - RESOLVED 9 + 10 + ## Phase 1: Foundation (First 4 Deliverables) 11 + 12 + ### 1. Convert to Declarative Mode 13 + 14 + **Approach:** Use DOM utilities (lib/src/demo/utils.ts) to programmatically build HTML markup that uses declarative VoltX attributes (data-volt-state, data-volt-computed, data-volt-*), then mount with charge() instead of programmatic mount() + signals. 15 + 16 + - [x] Add window.$helpers for DOM operations (openDialog, closeDialog, scrollTo, etc.) 17 + - [x] Update buildDemoStructure() to add data-volt attribute to root element 18 + - [x] Add data-volt-state with all initial state as JSON on root element 19 + - [x] Add data-volt-computed attributes for derived values (doubled, activeTodos, completedTodos) 20 + - [x] Convert all sections to produce markup with declarative bindings: 21 + - [x] Interactivity section - use $helpers.openDialog(), button expressions 22 + - [x] Reactivity section - reference state directly in expressions (count.get(), etc.) 23 + - [x] Forms section - use $helpers.handleFormSubmit(), data-volt-model on inputs 24 + - [x] Plugins section - declarative persist/scroll/url attributes (already mostly done) 25 + - [x] Animations section - declarative surge/shift attributes (already done) 26 + - [x] Typography section - no changes needed (static content) 27 + - [x] Remove demoScope export (replaced by declarative state on element) 28 + - [x] Update setupDemo() to use charge() instead of mount() 29 + - [x] Update lib/src/main.ts to just call setupDemo() (no other code) 30 + 31 + ### 2. Implement Multi-Page Routing 32 + 33 + - [x] Register navigate plugin in main.ts 34 + - [x] Initialize navigation listener with initNavigationListener() 35 + - [x] Create route-based content structure (added currentPage signal) 36 + - [x] Add navigation menu with declarative page switching 37 + - [x] Implement content swapping mechanism (conditional rendering with data-volt-if) 38 + - [x] Ensure browser back/forward buttons work correctly (initNavigationListener) 39 + - [x] Add View Transition API integration (built into navigate plugin) 40 + 41 + ### 3. Add Tooltip CSS Feature 42 + 43 + - [x] Design tooltip data attribute API (data-vx-tooltip, data-placement) 44 + - [x] Add tooltip CSS to lib/src/styles/components.css 45 + - [x] Support placements: top, right, bottom, left 46 + - [x] Implement tooltip positioning logic (CSS-only with pseudo-elements) 47 + - [x] Add hover/focus interactions 48 + - [x] Ensure accessibility (uses native attributes) 49 + - [x] Test tooltips across different viewport sizes (responsive: hidden on mobile) 50 + - [x] Add tooltip examples to home page 51 + 52 + ### 4. Create Home Page 53 + 54 + - [x] Design home page layout 55 + - [x] Add framework overview section 56 + - [x] Create feature highlights grid/list 57 + - Bundle size < 15KB 58 + - No virtual DOM 59 + - Signal-based reactivity 60 + - Zero dependencies 61 + - Declarative-first approach 62 + - [x] Add quick navigation to demo pages 63 + - [x] Include getting started code snippet 64 + - [x] Add links to documentation and GitHub 65 + - [x] Ensure home page uses Volt CSS classless styling 66 + 67 + ## PHASE 1 COMPLETE ✓ 68 + 69 + ## Phase 2: Core Feature Pages 70 + 71 + ### 5. Page: Getting Started 72 + 73 + - [ ] Installation instructions (npm, JSR, CDN) 74 + - [ ] First example with charge() 75 + - [ ] Declarative vs programmatic comparison 76 + - [ ] Basic signal usage example 77 + - [ ] Link to full documentation 78 + 79 + ### 6. Page: Reactivity 80 + 81 + - [ ] Migrate existing reactivity section 82 + - [ ] Signals demo (get/set/subscribe) 83 + - [ ] Computed values demo 84 + - [ ] Effects demo 85 + - [ ] Conditional rendering (data-volt-if/else) 86 + - [ ] List rendering (data-volt-for) 87 + - [ ] Class bindings (data-volt-class) 88 + - [ ] Two-way binding (data-volt-model) 89 + 90 + ### 7. Page: HTTP 91 + 92 + - [ ] Demonstrate all HTTP methods (GET, POST, PUT, PATCH, DELETE) 93 + - [ ] Show swap strategies (innerHTML, outerHTML, beforebegin, afterbegin, beforeend, afterend, delete, none) 94 + - [ ] Loading indicators with data-volt-indicator 95 + - [ ] Error handling patterns 96 + - [ ] Retry logic demonstration 97 + - [ ] Form serialization for POST/PUT/PATCH 98 + - [ ] Target selector usage 99 + 100 + ### 8. Page: State 101 + 102 + - [ ] Global store demonstration 103 + - [ ] Scope helpers overview 104 + - $store - access global state 105 + - $scope - current scope reference 106 + - $pulse - microtask scheduling 107 + - $uid - deterministic IDs 108 + - $probe - element refs 109 + - $pins - custom helpers 110 + - $arc - custom event dispatch 111 + - [ ] Cross-component communication patterns 112 + - [ ] Store registration and updates 113 + 114 + ## Phase 3: Plugin & Advanced Pages 115 + 116 + ### 9. Page: Persistence 117 + 118 + - [ ] Migrate existing persist plugin demo 119 + - [ ] localStorage persistence demo 120 + - [ ] sessionStorage persistence demo 121 + - [ ] IndexedDB persistence demo 122 + - [ ] URL sync with url plugin 123 + - [ ] Demonstrate storage modifiers (.local, .session, .ifmissing) 124 + - [ ] Cross-tab synchronization example 125 + 126 + ### 10. Page: Animations 127 + 128 + - [ ] Migrate existing animations section 129 + - [ ] Surge plugin demos (fade, slide, scale, blur) 130 + - [ ] Custom timing (duration, delay) 131 + - [ ] Different enter/leave transitions 132 + - [ ] Shift plugin demos (bounce, shake, pulse, flash, spin) 133 + - [ ] Custom animation settings 134 + - [ ] Combined effects (surge + shift) 135 + - [ ] View Transition API integration 136 + 137 + ### 11. Page: Forms 138 + 139 + - [ ] Migrate existing forms section 140 + - [ ] Complete form example with all input types 141 + - [ ] Two-way binding demonstration 142 + - [ ] Validation patterns 143 + - [ ] Event modifiers (.prevent, .stop, etc.) 144 + - [ ] Multi-step form example 145 + - [ ] Form submission handling 146 + 147 + ## Phase 4: Reference & Patterns 148 + 149 + ### 12. Page: CSS 150 + 151 + - [ ] Migrate existing typography section 152 + - [ ] Expand with additional Volt CSS features 153 + - [ ] Typography showcase (headings, paragraphs, lists) 154 + - [ ] Tufte-style sidenotes 155 + - [ ] Tables with zebra striping 156 + - [ ] Code blocks and inline code 157 + - [ ] Blockquotes and citations 158 + - [ ] Semantic HTML elements 159 + - [ ] Layout examples 160 + - [ ] Responsive behavior 161 + 162 + ### 13. Page: Patterns 163 + 164 + - [ ] Tabs component pattern 165 + - [ ] Accordion component pattern 166 + - [ ] Modal dialog pattern (expand existing) 167 + - [ ] Autocomplete/search pattern 168 + - [ ] Dropdown menu pattern 169 + - [ ] Toast/notification pattern 170 + - [ ] Pagination pattern 171 + - [ ] Infinite scroll pattern 172 + 173 + ## Phase 5: Polish & Documentation 174 + 175 + ### 14. View-Source Friendly Code 176 + 177 + - [ ] Ensure all HTML is readable and well-commented 178 + - [ ] Add explanatory comments to complex bindings 179 + - [ ] Include inline documentation where helpful 180 + - [ ] Make examples copy-paste ready 181 + 182 + ### 15. Copy-Paste Ready Patterns 183 + 184 + - [ ] Extract reusable patterns into clearly marked sections 185 + - [ ] Provide minimal examples for each feature 186 + - [ ] Include both inline and external script examples 187 + - [ ] Document common pitfalls and solutions 188 + 189 + ## File Structure 190 + 191 + ```sh 192 + lib/ 193 + index.html # Main entry with routing and global state 194 + src/ 195 + main.ts # Minimal bootstrap (charge + navigate init) 196 + demo/ 197 + index.ts # Removed or minimal utilities only 198 + sections/ # Keep or convert to HTML partials 199 + utils.ts # DOM utilities (may still be useful) 200 + pages/ # New directory for page templates 201 + home.html 202 + getting-started.html 203 + reactivity.html 204 + http.html 205 + state.html 206 + persistence.html 207 + animations.html 208 + forms.html 209 + css.html 210 + patterns.html 211 + styles/ 212 + components.css # Add tooltip styles here 213 + ... 214 + ```
docs/images/voltx-css_components.png

This is a binary file and will not be displayed.

docs/images/voltx-css_structured-content.png

This is a binary file and will not be displayed.

docs/images/voltx-css_typography.png

This is a binary file and will not be displayed.

+36 -2
docs/internals/reactivity.md
··· 52 52 53 53 While this document focuses on signals, most application code interacts with `reactive()` objects. 54 54 These are proxies backed by signals; property reads call `signal.get()`, writes call `signal.set()`. 55 - See [Proxy Objects](./proxies.md) for a detailed discussion. 55 + See [Proxy Objects](./proxies) for a detailed discussion. 56 + 57 + ## Expression Evaluation and Signal Unwrapping 58 + 59 + The expression evaluator bridges declarative markup with the reactive core. It compiles attribute expressions into functions that run in a sandboxed scope proxy. 60 + 61 + ### Auto-Unwrapping Behavior 62 + 63 + By default, the evaluator automatically unwraps signals in read contexts (bindings like `data-volt-text`, `data-volt-if`, etc.). This enables natural comparisons and operations: 64 + 65 + ```html 66 + <div data-volt data-volt-state='{"page": "home"}'> 67 + <!-- page signal is auto-unwrapped, so === comparison works --> 68 + <div data-volt-if="page === 'home'">Home Content</div> 69 + </div> 70 + ``` 71 + 72 + Without auto-unwrapping, strict equality (`===`) would compare the signal wrapper object to the string `'home'`, always returning false. Auto-unwrapping ensures the comparison uses the signal's value. 73 + 74 + ### Event Handlers 75 + 76 + In event handlers (`data-volt-on-*`) and initialization code (`data-volt-init`), signals are **not** auto-unwrapped. This preserves access to signal methods like `.set()` and `.subscribe()`: 77 + 78 + ```html 79 + <button data-volt-on-click="count.set(count.get() + 1)">Increment</button> 80 + ``` 81 + 82 + ### Implementation 83 + 84 + The evaluator uses a scope proxy that wraps signal objects differently based on context: 85 + 86 + - **Read mode** (`unwrapSignals: true`): Returns the signal's value for transparent comparisons 87 + - **Write mode** (`unwrapSignals: false`): Returns a wrapped signal with `.get()`, `.set()`, and `.subscribe()` methods 88 + 89 + This dual behavior is controlled by the `opts.unwrapSignals` parameter passed to `evaluate()`. 56 90 57 91 ## Scope Helpers 58 92 59 93 When a scope is mounted, VoltX injects several helpers that lean on the reactive core: 60 94 61 95 - `$pulse(cb)` queues `cb` on the microtask queue. 62 - It’s often used to observe the DOM after reactive updates settle. 96 + It's often used to observe the DOM after reactive updates settle. 63 97 - `$probe(expr, cb)` bridges the evaluator and the tracker. 64 98 It uses `extractDeps()` to pre-compute dependencies for the expression, subscribes to them, and re-evaluates via `evaluate()` on change. 65 99 - `$arc`, `$uid`, `$pins`, `$store`, and `$probe` all use the same subscription mechanics.
+16 -1
docs/usage/global-state.md
··· 58 58 - `$store.get(key)` - Get signal value 59 59 - `$store.set(key, value)` - Update signal value 60 60 - `$store.has(key)` - Check if key exists 61 - - `$store[key]` - Direct signal access 61 + - `$store[key]` - Direct signal access (auto-unwrapped in read contexts) 62 + 63 + **Note on Signal Unwrapping:** 64 + 65 + When accessing store values via `$store[key]` in read contexts (like `data-volt-text` or `data-volt-if`), the signal is automatically unwrapped. In event handlers, use `.get()` and `.set()` methods for explicit control: 66 + 67 + ```html 68 + <!-- Read context: signal auto-unwrapped --> 69 + <p data-volt-if="$store.theme === 'dark'">Dark mode active</p> 70 + 71 + <!-- Event handler: use methods --> 72 + <button data-volt-on-click="$store.theme.set('light')">Switch to Light</button> 73 + 74 + <!-- Or use the store's convenience methods --> 75 + <button data-volt-on-click="$store.set('theme', 'light')">Switch to Light</button> 76 + ``` 62 77 63 78 ### `$origin` 64 79
+27 -6
docs/usage/state.md
··· 88 88 Bindings can access nested properties, and the evaluator automatically unwraps signal values. 89 89 Event handlers receive special scope additions: `$el` for the element and `$event` for the event object. 90 90 91 - ## Signal Methods in Expressions 91 + ## Signal Auto-Unwrapping 92 + 93 + VoltX automatically unwraps signals in read contexts, making expressions simpler and more natural: 94 + 95 + ```html 96 + <div data-volt data-volt-state='{"count": 5, "name": "Alice"}'> 97 + <!-- Signals are automatically unwrapped in bindings --> 98 + <p data-volt-text="count"></p> 99 + <p data-volt-if="count > 0">Count is positive</p> 100 + <p data-volt-if="name === 'Alice'">Hello Alice!</p> 92 101 93 - While signal values are automatically unwrapped in most expressions, explicit signal methods are available when needed: 102 + <!-- In event handlers, use .get() to read and .set() to write --> 103 + <button data-volt-on-click="count.set(count.get() + 1)">Increment</button> 104 + </div> 105 + ``` 106 + 107 + **Read Contexts** (signals auto-unwrapped): 108 + - `data-volt-text`, `data-volt-html` 109 + - `data-volt-if`, `data-volt-else` 110 + - `data-volt-for` 111 + - `data-volt-class`, `data-volt-style` 112 + - `data-volt-bind:*` 113 + - `data-volt-computed:*` expressions 94 114 95 - - Use `signal.get()` to read the current value 96 - - Use `signal.set(newValue)` to update state from event handlers 97 - - Use `signal.subscribe(fn)` in custom JavaScript (not typical in templates) 115 + **Write Contexts** (signals not auto-unwrapped): 116 + - `data-volt-on-*` event handlers 117 + - `data-volt-init` initialization code 118 + - `data-volt-model` (handles both read and write automatically) 98 119 99 - The `.set()` method is commonly used in `data-volt-on-*` event bindings to update state in response to user actions. 120 + This design allows strict equality comparisons (`===`) to work naturally in conditional rendering while preserving access to signal methods like `.set()` in event handlers. 100 121 101 122 ## State Persistence 102 123
+38 -2
lib/README.md
··· 64 64 65 65 Plugins are opt-in and can be combined declaratively or registered programmatically via `charge({ plugins: [...] })`. 66 66 67 - ## Using CSS 67 + ## VoltX.css 68 68 69 - Import the optional CSS framework: 69 + VoltX ships with an optional classless CSS framework inspired by Pico CSS and Tufte CSS. It provides beautiful, semantic styling without requiring any CSS classes—just write semantic HTML and it looks great. It's perfect for prototyping. 70 + 71 + ### Features 72 + 73 + - **Typography**: Modular scale (1.25 ratio), harmonious spacing, and responsive sizing 74 + - **Tufte-style sidenotes**: Margin notes using `<small>` that float on desktop, inline on mobile 75 + - **Components**: Styled dialogs, accordions (details/summary), and pure-CSS tooltips 76 + - **Forms**: Consistent, accessible input styling with validation states 77 + - **Tables**: Zebra striping and responsive behavior 78 + - **Code blocks**: Syntax-appropriate styling for inline and block code 79 + - **Dark mode**: Automatic theme switching based on system preferences 80 + 81 + ### Installation 82 + 83 + Import the CSS in your application: 70 84 71 85 ```typescript 72 86 import 'voltx.js/css'; ··· 77 91 ```html 78 92 <link rel="stylesheet" href="https://unpkg.com/voltx.js/dist/voltx.css"> 79 93 ``` 94 + 95 + ### Usage 96 + 97 + No classes needed—just write semantic HTML: 98 + 99 + ```html 100 + <article> 101 + <h2>Beautiful Typography</h2> 102 + <p> 103 + This paragraph has proper spacing. 104 + <small>This sidenote appears in the margin on wide screens.</small> 105 + All elements are styled automatically. 106 + </p> 107 + 108 + <details> 109 + <summary>Accordion Example</summary> 110 + <p>Content revealed when opened.</p> 111 + </details> 112 + </article> 113 + ``` 114 + 115 + See the demo app's CSS section for comprehensive examples of all features. 80 116 81 117 ## Documentation 82 118
+95 -7
lib/src/core/binder.ts
··· 1 1 /** 2 - * Binder system for mounting and managing Volt.js bindings 2 + * Binder system for mounting and managing VoltX.js bindings 3 3 */ 4 4 5 5 import { executeSurgeEnter, executeSurgeLeave, hasSurge } from "$plugins/surge"; ··· 47 47 } 48 48 49 49 /** 50 - * Mount Volt.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope. 50 + * Mount VoltX.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope. 51 51 * 52 52 * @param root - Root element to mount on 53 53 * @param scope - Scope object containing signals and data ··· 495 495 } 496 496 497 497 /** 498 + * Get a nested property value from an object using a path array 499 + * 500 + * @example 501 + * getNestedProperty({ user: { name: "Alice" } }, ["user", "name"]) // "Alice" 502 + */ 503 + function getNestedProperty(obj: unknown, path: string[]): unknown { 504 + let current = obj; 505 + for (const key of path) { 506 + if (current == null || typeof current !== "object") { 507 + return undefined; 508 + } 509 + current = (current as Record<string, unknown>)[key]; 510 + } 511 + return current; 512 + } 513 + 514 + /** 515 + * Set a nested property value in an object immutably using a path array 516 + * 517 + * @example 518 + * setNestedProperty({ user: { name: "Alice" } }, ["user", "name"], "Bob") 519 + * // Returns: { user: { name: "Bob" } } 520 + */ 521 + function setNestedProperty(obj: unknown, path: string[], value: unknown): unknown { 522 + if (path.length === 0) { 523 + return value; 524 + } 525 + 526 + if (obj == null || typeof obj !== "object") { 527 + return obj; 528 + } 529 + 530 + const clone = Array.isArray(obj) ? [...obj] : { ...obj }; 531 + const [head, ...tail] = path; 532 + 533 + if (tail.length === 0) { 534 + (clone as Record<string, unknown>)[head] = value; 535 + } else { 536 + (clone as Record<string, unknown>)[head] = setNestedProperty((clone as Record<string, unknown>)[head], tail, value); 537 + } 538 + 539 + return clone; 540 + } 541 + 542 + /** 543 + * Find a signal and optional nested property path for data-volt-model binding 544 + * 545 + * Supports two patterns: 546 + * 1. Nested signals: { formData: { name: signal("") } } with path "formData.name" 547 + * 2. Signal with object value: { formData: signal({ name: "" }) } with path "formData.name" 548 + * 549 + * @returns Object with signal and propertyPath, or null if not found 550 + */ 551 + function findModelSignal(scope: Scope, path: string): Nullable<{ signal: Signal<unknown>; propertyPath: string[] }> { 552 + const signal = findScopedSignal(scope, path); 553 + if (signal) { 554 + return { signal, propertyPath: [] }; 555 + } 556 + 557 + const parts = path.split("."); 558 + for (let i = parts.length - 1; i > 0; i--) { 559 + const prefix = parts.slice(0, i).join("."); 560 + const testSignal = findScopedSignal(scope, prefix); 561 + 562 + if (testSignal) { 563 + const propertyPath = parts.slice(i); 564 + return { signal: testSignal, propertyPath }; 565 + } 566 + } 567 + 568 + return null; 569 + } 570 + 571 + /** 498 572 * Bind data-volt-model for two-way data binding on form elements with support for modifiers. 499 573 * Syncs the signal value with the input value bidirectionally. 500 574 * ··· 505 579 * - .debounce[.ms] - debounces signal updates (default 300ms) 506 580 */ 507 581 function bindModel(context: BindingContext, signalPath: string, modifiers: Modifier[] = []): void { 508 - const signal = findScopedSignal(context.scope, signalPath); 509 - if (!signal) { 582 + const result = findModelSignal(context.scope, signalPath); 583 + if (!result) { 510 584 console.error(`Signal "${signalPath}" not found for data-volt-model`); 511 585 return; 512 586 } 587 + 588 + const { signal, propertyPath } = result; 513 589 514 590 const element = context.element as FormControlElement; 515 591 const type = element instanceof HTMLInputElement ? element.type : null; 516 - const initialValue = signal.get(); 592 + 593 + const getValue = (): unknown => { 594 + const signalValue = signal.get(); 595 + return propertyPath.length > 0 ? getNestedProperty(signalValue, propertyPath) : signalValue; 596 + }; 597 + 598 + const initialValue = getValue(); 517 599 setElementValue(element, initialValue, type); 518 600 519 601 const unsubscribe = signal.subscribe(() => { 520 - const value = signal.get(); 602 + const value = getValue(); 521 603 setElementValue(element, value, type); 522 604 }); 523 605 context.cleanups.push(unsubscribe); ··· 541 623 } 542 624 } 543 625 544 - (signal as Signal<unknown>).set(value); 626 + if (propertyPath.length > 0) { 627 + const currentObj = signal.get(); 628 + const updatedObj = setNestedProperty(currentObj, propertyPath, value); 629 + (signal as Signal<unknown>).set(updatedObj); 630 + } else { 631 + (signal as Signal<unknown>).set(value); 632 + } 545 633 }; 546 634 547 635 let handler = baseHandler;
+5 -2
lib/src/core/evaluator.ts
··· 472 472 * 473 473 * @param expr - The expression string to evaluate 474 474 * @param scope - The scope object containing values 475 + * @param opts - Evaluation options. By default, signals are unwrapped for read operations. 476 + * Pass { unwrapSignals: false } to keep signals wrapped (needed for event handlers that call .set()) 475 477 * @returns The evaluated result 476 478 * @throws EvaluationError if expression is invalid or evaluation fails 477 479 */ 478 480 export function evaluate(expr: string, scope: Scope, opts?: EvaluateOpts): unknown { 479 481 try { 480 482 const fn = compileExpr(expr, false); 481 - const wrapOptions = opts && opts.unwrapSignals ? readWrapOptions : defaultWrapOptions; 483 + const wrapOptions = opts?.unwrapSignals === false ? defaultWrapOptions : readWrapOptions; 482 484 const proxiedScope = createScopeProxy(scope, wrapOptions); 483 485 const result = fn(proxiedScope, unwrapMaybeSignal); 484 486 return unwrapSignal(result); ··· 498 500 * 499 501 * Used for event handlers that may contain multiple semicolon-separated statements. 500 502 * Statements are executed in order but no return value is captured. 503 + * Signals are NOT unwrapped by default to allow calling .set() and other signal methods. 501 504 * 502 505 * @param expr - The statement(s) to evaluate 503 506 * @param scope - The scope object containing values ··· 506 509 export function evaluateStatements(expr: string, scope: Scope): void { 507 510 try { 508 511 const fn = compileExpr(expr, true); 509 - const proxiedScope = createScopeProxy(scope); 512 + const proxiedScope = createScopeProxy(scope, defaultWrapOptions); 510 513 fn(proxiedScope, unwrapMaybeSignal); 511 514 } catch (error) { 512 515 if (error instanceof EvaluationError) {
+162 -197
lib/src/demo/index.ts
··· 1 1 /** 2 - * Demo module for showcasing Volt.js features and volt.css styling 2 + * Demo module for showcasing VoltX.js features and volt.css styling 3 3 * 4 4 * This module creates the entire demo structure programmatically using DOM APIs, 5 - * demonstrating how to build complex UIs with Volt.js. 5 + * then uses charge() to mount it declaratively. 6 6 */ 7 7 8 + import { charge } from "$core/charge"; 9 + import { isSignal } from "$core/shared"; 10 + import { initNavigationListener } from "$plugins/navigate"; 8 11 import { persistPlugin } from "$plugins/persist"; 9 12 import { scrollPlugin } from "$plugins/scroll"; 10 13 import { shiftPlugin } from "$plugins/shift"; 11 14 import { surgePlugin } from "$plugins/surge"; 12 15 import { urlPlugin } from "$plugins/url"; 13 - import { computed, effect, mount, registerPlugin, signal } from "$volt"; 16 + import type { Signal } from "$types/volt"; 17 + import { registerPlugin } from "$volt"; 14 18 import { createAnimationsSection } from "./sections/animations"; 19 + import { createCssSection } from "./sections/css"; 15 20 import { createFormsSection } from "./sections/forms"; 21 + import { createHomeSection } from "./sections/home"; 16 22 import { createInteractivitySection } from "./sections/interactivity"; 17 23 import { createPluginsSection } from "./sections/plugins"; 18 24 import { createReactivitySection } from "./sections/reactivity"; 19 - import { createTypographySection } from "./sections/typography"; 20 25 import * as dom from "./utils"; 21 26 22 27 registerPlugin("persist", persistPlugin); ··· 25 30 registerPlugin("surge", surgePlugin); 26 31 registerPlugin("shift", shiftPlugin); 27 32 28 - const message = signal("Welcome to the Volt.js Demo"); 29 - const count = signal(0); 30 - const doubled = computed(() => count.get() * 2); 31 - 32 - const formData = signal({ name: "", email: "", bio: "", country: "us", newsletter: false, plan: "free" }); 33 - 34 - const todos = signal([{ id: 1, text: "Learn Volt.js", done: false }, { id: 2, text: "Build an app", done: false }, { 35 - id: 3, 36 - text: "Ship to production", 37 - done: false, 38 - }]); 39 - 40 - const newTodoText = signal(""); 41 - let todoIdCounter = 4; 42 - 43 - const showAdvanced = signal(false); 44 - 45 - const isActive = signal(true); 46 - const isHighlighted = signal(false); 47 - 48 - const dialogMessage = signal(""); 49 - const dialogInput = signal(""); 50 - 51 - const persistedCount = signal(0); 52 - const scrollPosition = signal(0); 53 - const urlParam = signal(""); 54 - 55 - const showFade = signal(false); 56 - const showSlideDown = signal(false); 57 - const showScale = signal(false); 58 - const showBlur = signal(false); 59 - const showSlowFade = signal(false); 60 - const showDelayedSlide = signal(false); 61 - const showGranular = signal(false); 62 - const showCombined = signal(false); 63 - const triggerBounce = signal(0); 64 - const triggerShake = signal(0); 65 - const triggerFlash = signal(0); 66 - const triggerTripleBounce = signal(0); 67 - const triggerLongShake = signal(0); 68 - 69 - const activeTodos = computed(() => todos.get().filter((todo) => !todo.done)); 70 - const completedTodos = computed(() => todos.get().filter((todo) => todo.done)); 71 - 72 - effect(() => { 73 - console.log("Count changed:", count.get()); 74 - }); 75 - 76 - const increment = () => { 77 - count.set(count.get() + 1); 78 - }; 79 - 80 - const decrement = () => { 81 - count.set(count.get() - 1); 82 - }; 83 - 84 - const reset = () => { 85 - count.set(0); 86 - }; 87 - 88 - const updateMessage = () => { 89 - message.set(`Count is now ${count.get()}`); 90 - }; 91 - 92 - const openDialog = () => { 93 - const dialog = document.querySelector("#demo-dialog") as HTMLDialogElement; 94 - if (dialog) { 95 - dialogMessage.set(""); 96 - dialogInput.set(""); 97 - dialog.showModal(); 98 - } 99 - }; 100 - 101 - const closeDialog = () => { 102 - const dialog = document.querySelector("#demo-dialog") as HTMLDialogElement; 103 - if (dialog) { 104 - dialog.close(); 105 - } 106 - }; 107 - 108 - const submitDialog = (event: Event) => { 109 - event.preventDefault(); 110 - dialogMessage.set(`You entered: ${dialogInput.get()}`); 111 - setTimeout(closeDialog, 2000); 112 - }; 113 - 114 - const addTodo = () => { 115 - const text = newTodoText.get().trim(); 116 - if (text) { 117 - todos.set([...todos.get(), { id: todoIdCounter++, text, done: false }]); 118 - newTodoText.set(""); 119 - } 120 - }; 121 - 122 - const toggleTodo = (id: number) => { 123 - todos.set(todos.get().map((todo) => todo.id === id ? { ...todo, done: !todo.done } : todo)); 124 - }; 125 - 126 - const removeTodo = (id: number) => { 127 - todos.set(todos.get().filter((todo) => todo.id !== id)); 128 - }; 129 - 130 - const handleFormSubmit = (event: Event) => { 131 - event.preventDefault(); 132 - console.log("Form submitted:", formData.get()); 133 - alert(`Form submitted! Check console for data.`); 134 - }; 135 - 136 - const toggleAdvanced = () => { 137 - showAdvanced.set(!showAdvanced.get()); 138 - }; 33 + /** 34 + * Helper functions for DOM operations that can't be expressed declaratively 35 + * These are added to the scope so they can be called from data-volt-on-* attributes 36 + */ 37 + const helpers = { 38 + openDialog(id: string) { 39 + const dialog = document.querySelector(`#${id}`) as HTMLDialogElement; 40 + if (dialog) { 41 + dialog.showModal(); 42 + } 43 + }, 139 44 140 - const toggleActive = () => { 141 - isActive.set(!isActive.get()); 142 - }; 45 + closeDialog(id: string) { 46 + const dialog = document.querySelector(`#${id}`) as HTMLDialogElement; 47 + if (dialog) { 48 + dialog.close(); 49 + } 50 + }, 143 51 144 - const toggleHighlight = () => { 145 - isHighlighted.set(!isHighlighted.get()); 146 - }; 52 + scrollToTop() { 53 + window.scrollTo({ top: 0, behavior: "smooth" }); 54 + }, 147 55 148 - const scrollToTop = () => { 149 - window.scrollTo({ top: 0, behavior: "smooth" }); 150 - }; 56 + scrollToSection(id: string) { 57 + const element = document.querySelector(`#${id}`); 58 + if (element) { 59 + element.scrollIntoView({ behavior: "smooth" }); 60 + } 61 + }, 151 62 152 - const scrollToSection = (id: string) => { 153 - const element = document.querySelector(`#${id}`); 154 - if (element) { 155 - element.scrollIntoView({ behavior: "smooth" }); 156 - } 157 - }; 158 - 159 - export const demoScope = { 160 - message, 161 - count, 162 - doubled, 163 - formData, 164 - todos, 165 - newTodoText, 166 - activeTodos, 167 - completedTodos, 168 - showAdvanced, 169 - isActive, 170 - isHighlighted, 171 - dialogMessage, 172 - dialogInput, 173 - persistedCount, 174 - scrollPosition, 175 - urlParam, 176 - showFade, 177 - showSlideDown, 178 - showScale, 179 - showBlur, 180 - showSlowFade, 181 - showDelayedSlide, 182 - showGranular, 183 - showCombined, 184 - triggerBounce, 185 - triggerShake, 186 - triggerFlash, 187 - triggerTripleBounce, 188 - triggerLongShake, 189 - increment, 190 - decrement, 191 - reset, 192 - updateMessage, 193 - openDialog, 194 - closeDialog, 195 - submitDialog, 196 - addTodo, 197 - toggleTodo, 198 - removeTodo, 199 - handleFormSubmit, 200 - toggleAdvanced, 201 - toggleActive, 202 - toggleHighlight, 203 - scrollToTop, 204 - scrollToSection, 63 + handleFormSubmit(event: Event) { 64 + event.preventDefault(); 65 + const form = event.target as HTMLFormElement; 66 + const formData = new FormData(form); 67 + const data = Object.fromEntries(formData.entries()); 68 + console.log("Form submitted:", data); 69 + alert("Form submitted! Check console for data."); 70 + }, 205 71 }; 206 72 207 73 const buildNav = () => 208 74 dom.nav( 209 75 null, 210 - dom.a({ href: "#typography" }, "Typography"), 76 + dom.a({ "data-volt-navigate": "", href: "/" }, "Home"), 211 77 " | ", 212 - dom.a({ href: "#interactivity" }, "Interactivity"), 78 + dom.a({ "data-volt-navigate": "", href: "/css" }, "CSS"), 213 79 " | ", 214 - dom.a({ href: "#forms" }, "Forms"), 80 + dom.a({ "data-volt-navigate": "", href: "/interactivity" }, "Interactivity"), 215 81 " | ", 216 - dom.a({ href: "#reactivity" }, "Reactivity"), 82 + dom.a({ "data-volt-navigate": "", href: "/forms" }, "Forms"), 217 83 " | ", 218 - dom.a({ href: "#plugins" }, "Plugins"), 84 + dom.a({ "data-volt-navigate": "", href: "/reactivity" }, "Reactivity"), 219 85 " | ", 220 - dom.a({ href: "#animations" }, "Animations"), 86 + dom.a({ "data-volt-navigate": "", href: "/plugins" }, "Plugins"), 87 + " | ", 88 + dom.a({ "data-volt-navigate": "", href: "/animations" }, "Animations"), 221 89 ); 222 90 91 + /** 92 + * Get the current page from the URL pathname 93 + */ 94 + function getCurrentPageFromPath(): string { 95 + const path = globalThis.location.pathname; 96 + if (path === "/" || path === "") return "home"; 97 + return path.slice(1); // Remove leading slash 98 + } 99 + 223 100 function buildDemoStructure(): HTMLElement { 101 + const initialState = { 102 + currentPage: getCurrentPageFromPath(), 103 + message: "Welcome to the VoltX.js Demo", 104 + count: 0, 105 + formData: { name: "", email: "", bio: "", country: "us", newsletter: false, plan: "free" }, 106 + todos: [{ id: 1, text: "Learn VoltX.js", done: false }, { id: 2, text: "Build an app", done: false }, { 107 + id: 3, 108 + text: "Ship to production", 109 + done: false, 110 + }], 111 + newTodoText: "", 112 + todoIdCounter: 4, 113 + showAdvanced: false, 114 + isActive: true, 115 + isHighlighted: false, 116 + dialogMessage: "", 117 + dialogInput: "", 118 + persistedCount: 0, 119 + scrollPosition: 0, 120 + urlParam: "", 121 + showFade: false, 122 + showSlideDown: false, 123 + showScale: false, 124 + showBlur: false, 125 + showSlowFade: false, 126 + showDelayedSlide: false, 127 + showGranular: false, 128 + showCombined: false, 129 + triggerBounce: 0, 130 + triggerShake: 0, 131 + triggerFlash: 0, 132 + triggerTripleBounce: 0, 133 + triggerLongShake: 0, 134 + }; 135 + 224 136 return dom.div( 225 - null, 137 + { 138 + "data-volt": "", 139 + "data-volt-state": JSON.stringify(initialState), 140 + "data-volt-computed:doubled": "count * 2", 141 + "data-volt-computed:activeTodos": "todos.filter(t => !t.done)", 142 + "data-volt-computed:completedTodos": "todos.filter(t => t.done)", 143 + }, 226 144 dom.header( 227 145 null, 228 146 dom.h1({ "data-volt-text": "message" }, "Loading..."), 229 147 dom.p( 230 148 null, 231 - "A comprehensive demo showcasing Volt.js reactive framework and Volt CSS classless styling.", 149 + "A comprehensive demo showcasing VoltX.js reactive framework and Volt CSS classless styling.", 232 150 dom.small( 233 151 null, 234 152 "This demo demonstrates both the framework's reactive capabilities and the elegant, semantic styling of Volt CSS. No CSS classes needed!", ··· 239 157 dom.el( 240 158 "main", 241 159 null, 242 - createTypographySection(), 243 - createInteractivitySection(), 244 - createFormsSection(), 245 - createReactivitySection(), 246 - createPluginsSection(), 247 - createAnimationsSection(), 160 + dom.div({ "data-volt-if": "currentPage === 'home'" }, createHomeSection()), 161 + dom.div({ "data-volt-if": "currentPage === 'css'" }, createCssSection()), 162 + dom.div({ "data-volt-if": "currentPage === 'interactivity'" }, createInteractivitySection()), 163 + dom.div({ "data-volt-if": "currentPage === 'forms'" }, createFormsSection()), 164 + dom.div({ "data-volt-if": "currentPage === 'reactivity'" }, createReactivitySection()), 165 + dom.div({ "data-volt-if": "currentPage === 'plugins'" }, createPluginsSection()), 166 + dom.div({ "data-volt-if": "currentPage === 'animations'" }, createAnimationsSection()), 248 167 ), 249 168 dom.footer( 250 169 null, 251 170 dom.p( 252 171 null, 253 172 "Built with ", 254 - dom.a({ href: "https://github.com/stormlightlabs/volt" }, "Volt.js"), 173 + dom.a({ href: "https://github.com/stormlightlabs/volt" }, "VoltX.js"), 255 174 " - A lightweight, reactive hypermedia framework", 256 175 ), 257 176 dom.p( 258 177 null, 259 - "This demo showcases both Volt.js reactive features and Volt CSS classless styling. View source to see how everything works!", 178 + "This demo showcases both VoltX.js reactive features and Volt CSS classless styling. View source to see how everything works!", 260 179 ), 261 180 ), 262 181 ); ··· 272 191 const demoStructure = buildDemoStructure(); 273 192 app.append(demoStructure); 274 193 275 - mount(app, demoScope); 194 + const chargeResult = charge(); 195 + const cleanupNav = initNavigationListener(); 196 + 197 + const rootScope = chargeResult.roots[0]?.scope; 198 + if (!rootScope) { 199 + console.error("Failed to get root scope from charge result"); 200 + return; 201 + } 202 + 203 + // Add helper functions to scope (not serializable, so added after charge) 204 + rootScope.$helpers = helpers; 205 + 206 + const handleNavigate = (event: Event) => { 207 + const customEvent = event as CustomEvent; 208 + const url = customEvent.detail?.url || globalThis.location.pathname; 209 + const page = url === "/" || url === "" ? "home" : url.slice(1); 276 210 277 - window.addEventListener("scroll", () => { 278 - scrollPosition.set(window.scrollY); 279 - }); 211 + const currentPageSignal = rootScope.currentPage as Signal<string>; 212 + if (currentPageSignal && isSignal(currentPageSignal)) { 213 + currentPageSignal.set(page); 214 + } 215 + 216 + window.scrollTo({ top: 0, behavior: "instant" }); 217 + }; 218 + 219 + const handlePopstate = () => { 220 + const page = getCurrentPageFromPath(); 221 + const currentPageSignal = rootScope.currentPage as Signal<string>; 222 + if (currentPageSignal && isSignal(currentPageSignal)) { 223 + currentPageSignal.set(page); 224 + } 225 + }; 226 + 227 + const handleScroll = () => { 228 + const scrollPositionSignal = rootScope.scrollPosition as Signal<number>; 229 + if (scrollPositionSignal && isSignal(scrollPositionSignal)) { 230 + scrollPositionSignal.set(window.scrollY); 231 + } 232 + }; 233 + 234 + globalThis.addEventListener("volt:navigate", handleNavigate); 235 + globalThis.addEventListener("volt:popstate", handlePopstate); 236 + window.addEventListener("scroll", handleScroll); 237 + 238 + return () => { 239 + chargeResult.cleanup(); 240 + cleanupNav(); 241 + globalThis.removeEventListener("volt:navigate", handleNavigate); 242 + globalThis.removeEventListener("volt:popstate", handlePopstate); 243 + window.removeEventListener("scroll", handleScroll); 244 + }; 280 245 }
+13 -13
lib/src/demo/sections/animations.ts
··· 23 23 dom.details( 24 24 null, 25 25 dom.summary(null, "Fade"), 26 - dom.button({ "data-volt-on-click": "showFade.set(!showFade.get())" }, "Toggle Fade"), 26 + dom.button({ "data-volt-on-click": "showFade.set(!showFade)" }, "Toggle Fade"), 27 27 dom.blockquote({ "data-volt-if": "showFade", "data-volt-surge": "fade" }, "Fades in and out smoothly"), 28 28 ), 29 29 dom.details( 30 30 null, 31 31 dom.summary(null, "Slide Down"), 32 - dom.button({ "data-volt-on-click": "showSlideDown.set(!showSlideDown.get())" }, "Toggle Slide"), 32 + dom.button({ "data-volt-on-click": "showSlideDown.set(!showSlideDown)" }, "Toggle Slide"), 33 33 dom.blockquote({ "data-volt-if": "showSlideDown", "data-volt-surge": "slide-down" }, "Slides down from above"), 34 34 ), 35 35 dom.details( 36 36 null, 37 37 dom.summary(null, "Scale"), 38 - dom.button({ "data-volt-on-click": "showScale.set(!showScale.get())" }, "Toggle Scale"), 38 + dom.button({ "data-volt-on-click": "showScale.set(!showScale)" }, "Toggle Scale"), 39 39 dom.blockquote({ "data-volt-if": "showScale", "data-volt-surge": "scale" }, "Scales up smoothly"), 40 40 ), 41 41 dom.details( 42 42 null, 43 43 dom.summary(null, "Blur"), 44 - dom.button({ "data-volt-on-click": "showBlur.set(!showBlur.get())" }, "Toggle Blur"), 44 + dom.button({ "data-volt-on-click": "showBlur.set(!showBlur)" }, "Toggle Blur"), 45 45 dom.blockquote({ "data-volt-if": "showBlur", "data-volt-surge": "blur" }, "Blur effect transition"), 46 46 ), 47 47 ), ··· 56 56 dom.details( 57 57 null, 58 58 dom.summary(null, "Slow Fade (1000ms)"), 59 - dom.button({ "data-volt-on-click": "showSlowFade.set(!showSlowFade.get())" }, "Toggle"), 59 + dom.button({ "data-volt-on-click": "showSlowFade.set(!showSlowFade)" }, "Toggle"), 60 60 dom.blockquote({ "data-volt-if": "showSlowFade", "data-volt-surge": "fade.1000" }, "Very slow fade"), 61 61 ), 62 62 dom.details( 63 63 null, 64 64 dom.summary(null, "Delayed Slide (500ms + 200ms delay)"), 65 - dom.button({ "data-volt-on-click": "showDelayedSlide.set(!showDelayedSlide.get())" }, "Toggle"), 65 + dom.button({ "data-volt-on-click": "showDelayedSlide.set(!showDelayedSlide)" }, "Toggle"), 66 66 dom.blockquote( 67 67 { "data-volt-if": "showDelayedSlide", "data-volt-surge": "slide-down.500.200" }, 68 68 "Slides with delay", ··· 77 77 "Specify different transitions for entering and leaving.", 78 78 dom.small(null, "Use data-volt-surge:enter and data-volt-surge:leave for granular control"), 79 79 ), 80 - dom.button({ "data-volt-on-click": "showGranular.set(!showGranular.get())" }, "Toggle Mixed Transition"), 80 + dom.button({ "data-volt-on-click": "showGranular.set(!showGranular)" }, "Toggle Mixed Transition"), 81 81 dom.blockquote({ 82 82 "data-volt-if": "showGranular", 83 83 "data-volt-surge:enter": "slide-down.400", ··· 95 95 dom.div( 96 96 { style: "display: flex; gap: 0.5rem; flex-wrap: wrap;" }, 97 97 dom.button({ 98 - "data-volt-on-click": "triggerBounce.set(triggerBounce.get() + 1)", 98 + "data-volt-on-click": "triggerBounce.set(triggerBounce + 1)", 99 99 "data-volt-shift": "triggerBounce:bounce", 100 100 }, "Bounce"), 101 101 dom.button({ 102 - "data-volt-on-click": "triggerShake.set(triggerShake.get() + 1)", 102 + "data-volt-on-click": "triggerShake.set(triggerShake + 1)", 103 103 "data-volt-shift": "triggerShake:shake", 104 104 }, "Shake"), 105 105 dom.button({ "data-volt-shift": "pulse" }, "Pulse (Continuous)"), 106 106 dom.button({ 107 - "data-volt-on-click": "triggerFlash.set(triggerFlash.get() + 1)", 107 + "data-volt-on-click": "triggerFlash.set(triggerFlash + 1)", 108 108 "data-volt-shift": "triggerFlash:flash", 109 109 }, "Flash"), 110 110 ), ··· 121 121 dom.div( 122 122 { style: "display: flex; gap: 0.5rem; flex-wrap: wrap;" }, 123 123 dom.button({ 124 - "data-volt-on-click": "triggerTripleBounce.set(triggerTripleBounce.get() + 1)", 124 + "data-volt-on-click": "triggerTripleBounce.set(triggerTripleBounce + 1)", 125 125 "data-volt-shift": "triggerTripleBounce:bounce.800.3", 126 126 }, "Triple Bounce (800ms each)"), 127 127 dom.button({ 128 - "data-volt-on-click": "triggerLongShake.set(triggerLongShake.get() + 1)", 128 + "data-volt-on-click": "triggerLongShake.set(triggerLongShake + 1)", 129 129 "data-volt-shift": "triggerLongShake:shake.1000.2", 130 130 }, "Long Shake (1000ms, 2x)"), 131 131 ), ··· 138 138 "Surge and shift can be combined for complex animation choreography.", 139 139 dom.small(null, "Toggle to see content that fades in, then bounces on mount"), 140 140 ), 141 - dom.button({ "data-volt-on-click": "showCombined.set(!showCombined.get())" }, "Toggle Combined Animation"), 141 + dom.button({ "data-volt-on-click": "showCombined.set(!showCombined)" }, "Toggle Combined Animation"), 142 142 dom.aside( 143 143 { "data-volt-if": "showCombined", "data-volt-surge": "fade.400", "data-volt-shift": "bounce" }, 144 144 dom.p(
+309
lib/src/demo/sections/css.ts
··· 1 + /** 2 + * CSS & Typography Section 3 + * Showcases VoltX CSS styling capabilities, typography, and component features 4 + */ 5 + 6 + import * as dom from "../utils"; 7 + 8 + export function createCssSection(): HTMLElement { 9 + return dom.article( 10 + { id: "css" }, 11 + dom.h2(null, "VoltX.css"), 12 + dom.section( 13 + null, 14 + dom.h3(null, "Headings & Hierarchy"), 15 + dom.p( 16 + null, 17 + "Volt CSS provides a harmonious type scale based on a 1.25 ratio (major third).", 18 + dom.small( 19 + null, 20 + "The modular scale creates visual hierarchy without requiring any CSS classes. Font sizes range from 0.889rem to 2.566rem.", 21 + ), 22 + " All headings automatically receive appropriate sizing, spacing, and weight.", 23 + ), 24 + dom.h4(null, "Level 4 Heading"), 25 + dom.p(null, "Demonstrates the fourth level of hierarchy."), 26 + dom.h5(null, "Level 5 Heading"), 27 + dom.p(null, "Even smaller, but still distinct and readable."), 28 + dom.h6(null, "Level 6 Heading"), 29 + dom.p(null, "The smallest heading level in the hierarchy."), 30 + ), 31 + dom.section( 32 + null, 33 + dom.h3(null, "Tufte-Style Sidenotes"), 34 + dom.p( 35 + null, 36 + "One of the signature features of Volt CSS is Tufte-style sidenotes.", 37 + dom.small( 38 + null, 39 + "Edward Tufte is renowned for his work on information design and data visualization. His books feature extensive use of margin notes that provide context without interrupting the main narrative flow.", 40 + ), 41 + " These appear in the margin on desktop and inline on mobile devices.", 42 + ), 43 + dom.p( 44 + null, 45 + "Sidenotes are created using the semantic ", 46 + dom.code(null, "<small>"), 47 + " element.", 48 + dom.small( 49 + null, 50 + "The <small> element represents side comments and fine print, making it semantically appropriate for sidenotes. No custom attributes needed!", 51 + ), 52 + " This keeps markup clean and portable while maintaining semantic meaning.", 53 + ), 54 + dom.p( 55 + null, 56 + "The responsive behavior ensures readability across all devices.", 57 + dom.small( 58 + null, 59 + "On narrow screens, sidenotes appear inline with subtle styling. On wider screens (≥1200px), they float into the right margin.", 60 + ), 61 + " Try resizing your browser to see the effect.", 62 + ), 63 + ), 64 + dom.section( 65 + null, 66 + dom.h3(null, "Lists"), 67 + dom.p(null, "Both ordered and unordered lists are styled with appropriate spacing:"), 68 + dom.h4(null, "Unordered List"), 69 + dom.ul( 70 + null, 71 + ...dom.repeat(dom.li, [ 72 + "Reactive signals for state management", 73 + "Computed values derived from signals", 74 + "Effect system for side effects", 75 + "Declarative data binding via attributes", 76 + ]), 77 + ), 78 + dom.h4(null, "Ordered List"), 79 + dom.ol( 80 + null, 81 + ...dom.repeat(dom.li, [ 82 + "Define your signals and computed values", 83 + "Write semantic HTML markup", 84 + "Add data-volt-* attributes for reactivity", 85 + "Mount the scope and watch the magic happen", 86 + ]), 87 + ), 88 + dom.h4(null, "Description List"), 89 + dom.dl( 90 + null, 91 + ...dom.kv([["Signal", "A reactive primitive that holds a value and notifies subscribers of changes."], [ 92 + "Computed", 93 + "A derived value that automatically updates when its dependencies change.", 94 + ], ["Effect", "A side effect that runs when its reactive dependencies change."]]), 95 + ), 96 + ), 97 + dom.section( 98 + null, 99 + dom.h3(null, "Blockquotes & Citations"), 100 + dom.blockquote( 101 + null, 102 + dom.p( 103 + null, 104 + "Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.", 105 + ), 106 + dom.cite(null, "Antoine de Saint-Exupéry"), 107 + ), 108 + dom.blockquote( 109 + null, 110 + dom.p( 111 + null, 112 + "The best programs are written so that computing machines can perform them quickly and so that human beings can understand them clearly.", 113 + ), 114 + dom.cite(null, "Donald Knuth"), 115 + ), 116 + ), 117 + dom.section( 118 + null, 119 + dom.h3(null, "Code & Preformatted Text"), 120 + dom.p(null, "Inline code uses ", dom.code(null, "monospace font"), " for clarity."), 121 + dom.p(null, "Code blocks preserve formatting and provide syntax-appropriate styling:"), 122 + dom.pre( 123 + null, 124 + dom.code( 125 + null, 126 + `import { signal, computed, mount } from 'volt'; 127 + 128 + const count = signal(0); 129 + const doubled = computed(() => count.get() * 2); 130 + 131 + mount(document.querySelector('#app'), { 132 + count, 133 + doubled, 134 + increment: () => count.set(count.get() + 1) 135 + });`, 136 + ), 137 + ), 138 + ), 139 + dom.section( 140 + null, 141 + dom.h3(null, "Tables"), 142 + dom.p( 143 + null, 144 + "Tables receive zebra striping and responsive styling automatically.", 145 + dom.small( 146 + null, 147 + "Tables use alternating row colors for improved scannability. On mobile, they remain scrollable horizontally if needed.", 148 + ), 149 + ), 150 + dom.table( 151 + null, 152 + dom.thead(null, dom.tr(null, ...dom.repeat(dom.th, ["Feature", "Volt.js", "Framework X", "Framework Y"]))), 153 + dom.tbody( 154 + null, 155 + dom.tr(null, ...dom.repeat(dom.td, ["Bundle Size", "< 15KB gzipped", "~40KB", "~30KB"])), 156 + dom.tr(null, dom.td(null, "Virtual DOM"), dom.td(null, "No"), dom.td(null, "Yes"), dom.td(null, "Yes")), 157 + dom.tr(null, ...dom.repeat(dom.td, ["Reactive System", "Signals", "Proxy-based", "Observable"])), 158 + dom.tr(null, ...dom.repeat(dom.td, ["Learning Curve", "Gentle", "Moderate", "Steep"])), 159 + ), 160 + ), 161 + ), 162 + dom.section( 163 + null, 164 + dom.h3(null, "Tooltips"), 165 + dom.p(null, "VoltX CSS includes pure-CSS tooltips with zero JavaScript. Try hovering over these examples:"), 166 + dom.p( 167 + null, 168 + dom.abbr({ "data-vx-tooltip": "Tooltips appear on top by default", "data-placement": "top" }, "Top"), 169 + " · ", 170 + dom.abbr({ "data-vx-tooltip": "Tooltips can appear on the right", "data-placement": "right" }, "Right"), 171 + " · ", 172 + dom.abbr({ "data-vx-tooltip": "Tooltips can appear on the bottom", "data-placement": "bottom" }, "Bottom"), 173 + " · ", 174 + dom.abbr({ "data-vx-tooltip": "Tooltips can appear on the left", "data-placement": "left" }, "Left"), 175 + ), 176 + dom.p( 177 + null, 178 + dom.small( 179 + null, 180 + "Tooltips use the data-vx-tooltip attribute and are styled with pure CSS. They automatically hide on mobile devices for better touch interaction.", 181 + ), 182 + ), 183 + dom.h4(null, "Implementation"), 184 + dom.p( 185 + null, 186 + "Add the ", 187 + dom.code(null, "data-vx-tooltip"), 188 + " attribute with your tooltip text, and optionally specify ", 189 + dom.code(null, "data-placement"), 190 + " (top, right, bottom, left) for positioning.", 191 + ), 192 + dom.pre( 193 + null, 194 + dom.code( 195 + null, 196 + `<abbr data-vx-tooltip="Helpful tooltip text" 197 + data-placement="top"> 198 + Hover me 199 + </abbr>`, 200 + ), 201 + ), 202 + ), 203 + dom.section( 204 + null, 205 + dom.h3(null, "Accordions"), 206 + dom.p( 207 + null, 208 + "Native HTML ", 209 + dom.code(null, "<details>"), 210 + " and ", 211 + dom.code(null, "<summary>"), 212 + " elements provide semantic accordion functionality.", 213 + dom.small( 214 + null, 215 + "The details element is fully accessible by default, with keyboard navigation built in. VoltX CSS styles it beautifully without requiring any JavaScript.", 216 + ), 217 + ), 218 + dom.details( 219 + null, 220 + dom.summary(null, "What is VoltX.js?"), 221 + dom.p( 222 + null, 223 + "VoltX.js is a lightweight reactive framework for building declarative user interfaces. It uses signals for reactivity and provides HTML-driven behavior through data-volt-* attributes.", 224 + ), 225 + ), 226 + dom.details( 227 + null, 228 + dom.summary(null, "How does reactivity work?"), 229 + dom.p( 230 + null, 231 + "VoltX uses a signal-based reactive system. When a signal changes, all computed values and effects that depend on it automatically update. The DOM bindings subscribe to these signals and update the UI accordingly.", 232 + ), 233 + ), 234 + dom.details( 235 + null, 236 + dom.summary(null, "What about styling?"), 237 + dom.p( 238 + null, 239 + "VoltX CSS is a classless CSS framework inspired by Pico CSS and Tufte CSS. It provides beautiful, semantic styling without requiring any CSS classes. Just write semantic HTML and it looks great!", 240 + ), 241 + ), 242 + ), 243 + dom.section( 244 + null, 245 + dom.h3(null, "Dialogs"), 246 + dom.p( 247 + null, 248 + "Native ", 249 + dom.code(null, "<dialog>"), 250 + " elements provide semantic modal functionality with built-in accessibility.", 251 + dom.small( 252 + null, 253 + "The dialog element includes keyboard handling (ESC to close), focus trapping, and backdrop support. Modern browsers support it natively.", 254 + ), 255 + " VoltX CSS styles dialogs elegantly with proper backdrop blur and animations.", 256 + ), 257 + dom.p( 258 + null, 259 + "Dialogs require JavaScript to open and close (using ", 260 + dom.code(null, "showModal()"), 261 + " and ", 262 + dom.code(null, "close()"), 263 + " methods). ", 264 + "See the ", 265 + dom.a({ href: "/interactivity" }, "Interactivity section"), 266 + " for a working dialog demo.", 267 + ), 268 + dom.h4(null, "Structure"), 269 + dom.p(null, "Dialogs should follow this semantic structure for proper styling:"), 270 + dom.pre( 271 + null, 272 + dom.code( 273 + null, 274 + `<dialog id="my-dialog"> 275 + <article> 276 + <header> 277 + <h3>Dialog Title</h3> 278 + <button aria-label="Close">×</button> 279 + </header> 280 + <!-- Dialog content --> 281 + <footer> 282 + <button>Cancel</button> 283 + <button>Confirm</button> 284 + </footer> 285 + </article> 286 + </dialog>`, 287 + ), 288 + ), 289 + ), 290 + dom.section( 291 + null, 292 + dom.h3(null, "Additional Features"), 293 + dom.p(null, "VoltX CSS includes comprehensive styling for all semantic HTML elements:"), 294 + dom.ul( 295 + null, 296 + ...dom.repeat(dom.li, [ 297 + "Typography with modular scale and Tufte-style sidenotes", 298 + "Form elements with consistent, accessible styling", 299 + "Tables with zebra striping and responsive behavior", 300 + "Code blocks with syntax-appropriate styling", 301 + "Blockquotes and citations", 302 + "Responsive images and media", 303 + "And much more, all without CSS classes!", 304 + ]), 305 + ), 306 + dom.p(null, "Explore the other sections of this demo to see these features in action."), 307 + ), 308 + ); 309 + }
+2 -2
lib/src/demo/sections/forms.ts
··· 23 23 ), 24 24 ), 25 25 dom.form( 26 - { "data-volt-on-submit": "handleFormSubmit" }, 26 + { "data-volt-on-submit": "$helpers.handleFormSubmit($event)" }, 27 27 dom.fieldset( 28 28 null, 29 29 dom.legend(null, "User Information"), ··· 76 76 dom.details( 77 77 null, 78 78 dom.summary(null, "Current Form Data (Live)"), 79 - dom.pre(null, dom.code({ "data-volt-text": "JSON.stringify(formData.get(), null, 2)" }, "Loading...")), 79 + dom.pre(null, dom.code({ "data-volt-text": "JSON.stringify(formData, null, 2)" }, "Loading...")), 80 80 ), 81 81 ), 82 82 );
+117
lib/src/demo/sections/home.ts
··· 1 + /** 2 + * Home Section 3 + * Landing page with framework overview and feature highlights 4 + */ 5 + 6 + import * as dom from "../utils"; 7 + 8 + export function createHomeSection(): HTMLElement { 9 + return dom.article( 10 + { id: "home" }, 11 + dom.section( 12 + null, 13 + dom.h2(null, "VoltX.js: Declarative Reactivity for the Modern Web"), 14 + dom.p( 15 + null, 16 + "VoltX.js is a lightweight reactive framework that brings signal-based reactivity to HTML.", 17 + dom.small( 18 + null, 19 + "Build rich, interactive applications using declarative markup without writing JavaScript. No virtual DOM, no build step, no dependencies—just pure reactive primitives and HTML attributes.", 20 + ), 21 + ), 22 + ), 23 + dom.section( 24 + null, 25 + dom.h3(null, "Why VoltX.js?"), 26 + dom.dl( 27 + null, 28 + ...dom.kv([ 29 + ["< 15KB Gzipped", "Smaller than most icon libraries. Ships only what you need with zero dependencies."], 30 + [ 31 + "Declarative-First", 32 + "Build entire applications using only HTML attributes. JavaScript is optional for complex logic.", 33 + ], 34 + [ 35 + "Signal-Based Reactivity", 36 + "Fine-grained reactivity that updates only what changed. No virtual DOM diffing.", 37 + ], 38 + ["Zero Build Step", "Import from npm or CDN and start building. No webpack, no rollup, no configuration."], 39 + ["Progressive Enhancement", "Works with server-rendered HTML. Add reactivity incrementally where needed."], 40 + ["Standards-Based", "Built on standard DOM APIs. No proprietary JSX or template syntax."], 41 + ]), 42 + ), 43 + ), 44 + dom.section( 45 + null, 46 + dom.h3(null, "Quick Start"), 47 + dom.p(null, "Get started with VoltX.js in seconds:"), 48 + dom.pre( 49 + null, 50 + dom.code( 51 + null, 52 + `<!-- Include VoltX.js from CDN --> 53 + <script type="module"> 54 + import { charge } from 'https://esm.sh/voltx.js'; 55 + charge(); 56 + </script> 57 + 58 + <!-- Declare reactive state in HTML --> 59 + <div data-volt data-volt-state='{"count": 0}' 60 + data-volt-computed:doubled="count * 2"> 61 + <p>Count: <span data-volt-text="count"></span></p> 62 + <p>Doubled: <span data-volt-text="doubled"></span></p> 63 + <button data-volt-on-click="count.set(count + 1)"> 64 + Increment 65 + </button> 66 + </div>`, 67 + ), 68 + ), 69 + dom.p( 70 + null, 71 + "That's it! No build step, no configuration, no JavaScript beyond the import. ", 72 + dom.strong(null, "The state, reactivity, and interactions are all declared in HTML."), 73 + ), 74 + ), 75 + dom.section( 76 + null, 77 + dom.h3(null, "Explore the Features"), 78 + dom.p(null, "Navigate through the demo to see VoltX.js in action:"), 79 + dom.ul( 80 + null, 81 + dom.li( 82 + null, 83 + dom.strong(null, "Typography & CSS"), 84 + " - Explore Volt CSS, our classless CSS framework with Tufte-style sidenotes", 85 + ), 86 + dom.li( 87 + null, 88 + dom.strong(null, "Interactivity"), 89 + " - See dialogs, buttons, and event handling with declarative bindings", 90 + ), 91 + dom.li(null, dom.strong(null, "Forms"), " - Two-way data binding with all standard form elements"), 92 + dom.li(null, dom.strong(null, "Reactivity"), " - Conditional rendering, list rendering, and computed values"), 93 + dom.li(null, dom.strong(null, "Plugins"), " - Persistence, scroll management, URL synchronization, and more"), 94 + dom.li( 95 + null, 96 + dom.strong(null, "Animations"), 97 + " - Built-in transitions and keyframe animations with zero configuration", 98 + ), 99 + ), 100 + dom.p(null, "Use the navigation above to explore each section."), 101 + ), 102 + dom.section( 103 + null, 104 + dom.h3(null, "Learn More"), 105 + dom.p( 106 + null, 107 + dom.a({ href: "https://github.com/stormlightlabs/volt" }, "GitHub Repository"), 108 + " | ", 109 + dom.a({ href: "https://stormlightlabs.github.io/volt" }, "Documentation"), 110 + " | ", 111 + dom.a({ href: "https://www.npmjs.com/package/voltx.js" }, "npm Package"), 112 + " | ", 113 + dom.a({ href: "https://jsr.io/@voltx/core" }, "JSR Package"), 114 + ), 115 + ), 116 + ); 117 + }
+19 -14
lib/src/demo/sections/interactivity.ts
··· 23 23 ), 24 24 " VoltX CSS styles it elegantly, and VoltX.js handles the interaction.", 25 25 ), 26 - dom.button({ "data-volt-on-click": "openDialog" }, "Open Dialog"), 27 - dom.p({ "data-volt-if": "dialogMessage.get()", "data-volt-text": "dialogMessage" }), 26 + dom.button({ 27 + "data-volt-on-click": "$helpers.openDialog('demo-dialog'); dialogMessage.set(''); dialogInput.set('')", 28 + }, "Open Dialog"), 29 + dom.p({ "data-volt-if": "dialogMessage", "data-volt-text": "dialogMessage" }), 28 30 dom.dialog( 29 31 { id: "demo-dialog" }, 30 32 dom.article( ··· 32 34 dom.header( 33 35 null, 34 36 dom.h3(null, "Dialog Demo"), 35 - dom.button({ "data-volt-on-click": "closeDialog", "aria-label": "Close" }, "×"), 37 + dom.button({ "data-volt-on-click": "$helpers.closeDialog('demo-dialog')", "aria-label": "Close" }, "×"), 36 38 ), 37 39 dom.form( 38 - { "data-volt-on-submit": "submitDialog" }, 40 + { 41 + id: "demo-dialog-form", 42 + "data-volt-on-submit": 43 + "$event.preventDefault(); dialogMessage.set('You entered: ' + dialogInput); setTimeout(() => $helpers.closeDialog('demo-dialog'), 2000)", 44 + }, 39 45 ...dom.labelFor("Enter something:", { 40 46 type: "text", 41 47 id: "dialog-input", ··· 43 49 placeholder: "Type here...", 44 50 required: true, 45 51 }), 46 - // FIXME: this needs to be the modal footer 47 - dom.footer( 48 - null, 49 - dom.button({ type: "button", "data-volt-on-click": "closeDialog" }, "Cancel"), 50 - dom.button({ type: "submit" }, "Submit"), 51 - ), 52 + ), 53 + dom.footer( 54 + null, 55 + dom.button({ type: "button", "data-volt-on-click": "$helpers.closeDialog('demo-dialog')" }, "Cancel"), 56 + dom.button({ type: "submit", form: "demo-dialog-form" }, "Submit"), 52 57 ), 53 58 ), 54 59 ), ··· 65 70 ), 66 71 dom.div( 67 72 { style: "display: flex; gap: 0.5rem; flex-wrap: wrap;" }, 68 - ...dom.buttons([["Increment", "increment"], ["Decrement", "decrement"], ["Reset", "reset"], [ 69 - "Update Header", 70 - "updateMessage", 71 - ]]), 73 + ...dom.buttons([["Increment", "count.set(count + 1)"], ["Decrement", "count.set(count - 1)"], [ 74 + "Reset", 75 + "count.set(0)", 76 + ], ["Update Header", "message.set('Count is now ' + count)"]]), 72 77 ), 73 78 ), 74 79 );
+7 -8
lib/src/demo/sections/plugins.ts
··· 23 23 dom.p(null, "Persisted Count: ", dom.strong({ "data-volt-text": "persistedCount" }, "0")), 24 24 dom.div( 25 25 { "data-volt-persist:persistedCount": "localStorage" }, 26 - dom.button({ "data-volt-on-click": "persistedCount.set(persistedCount.get() + 1)" }, "Increment Persisted"), 26 + dom.button({ "data-volt-on-click": "persistedCount.set(persistedCount + 1)" }, "Increment Persisted"), 27 27 " ", 28 28 dom.button({ "data-volt-on-click": "persistedCount.set(0)" }, "Reset Persisted"), 29 29 ), ··· 42 42 dom.p( 43 43 null, 44 44 "Current Scroll Position: ", 45 - dom.strong({ "data-volt-text": "Math.round(scrollPosition.get())" }, "0"), 45 + dom.strong({ "data-volt-text": "Math.round(scrollPosition)" }, "0"), 46 46 "px", 47 47 ), 48 48 dom.div( 49 49 { style: "display: flex; gap: 0.5rem; flex-wrap: wrap;" }, 50 - dom.button({ "data-volt-on-click": "scrollToTop" }, "Scroll to Top"), 51 - dom.button({ "data-volt-on-click": "scrollToSection('typography')" }, "Go to Typography"), 52 - dom.button({ "data-volt-on-click": "scrollToSection('forms')" }, "Go to Forms"), 53 - dom.button({ "data-volt-on-click": "scrollToSection('plugins')" }, "Go to Plugins"), 50 + dom.button({ "data-volt-on-click": "$helpers.scrollToTop()" }, "Scroll to Top"), 51 + dom.button({ "data-volt-on-click": "$helpers.scrollToSection('typography')" }, "Go to Typography"), 52 + dom.button({ "data-volt-on-click": "$helpers.scrollToSection('forms')" }, "Go to Forms"), 53 + dom.button({ "data-volt-on-click": "$helpers.scrollToSection('plugins')" }, "Go to Plugins"), 54 54 ), 55 55 ), 56 56 dom.section( ··· 65 65 ), 66 66 ), 67 67 dom.div( 68 - // FIXME: this needs to be constrained in the stylesheet to allow for the sidenotes 69 68 { "data-volt-url:urlParam": "query", "style": "max-width: var(--content-width);" }, 70 69 ...dom.labelFor("URL Parameter (synced with ?urlParam=...)", { 71 70 type: "text", ··· 73 72 "data-volt-model": "urlParam", 74 73 placeholder: "Type to update URL...", 75 74 }), 76 - dom.p(null, "Current value: ", dom.strong({ "data-volt-text": "urlParam.get() || '(empty)'" }, "Loading...")), 75 + dom.p(null, "Current value: ", dom.strong({ "data-volt-text": "urlParam || '(empty)'" }, "Loading...")), 77 76 ), 78 77 ), 79 78 );
+27 -18
lib/src/demo/sections/reactivity.ts
··· 35 35 " for conditional display:", 36 36 ), 37 37 dom.button( 38 - { "data-volt-on-click": "toggleAdvanced" }, 39 - dom.span({ "data-volt-text": "showAdvanced.get() ? 'Hide' : 'Show'" }, "Show"), 38 + { "data-volt-on-click": "showAdvanced.set(!showAdvanced)" }, 39 + dom.span({ "data-volt-text": "showAdvanced ? 'Hide' : 'Show'" }, "Show"), 40 40 " Advanced Options", 41 41 ), 42 42 dom.div( 43 - { "data-volt-if": "showAdvanced.get()" }, 43 + { "data-volt-if": "showAdvanced" }, 44 44 dom.h4(null, "Advanced Configuration"), 45 45 dom.p(null, "These options are only visible when advanced mode is enabled."), 46 46 dom.labelWith("Enable debug mode", { type: "checkbox" }), ··· 70 70 type: "text", 71 71 "data-volt-model": "newTodoText", 72 72 placeholder: "New todo...", 73 - "data-volt-on-keydown": "$event.key === 'Enter' ? addTodo() : null", 73 + "data-volt-on-keydown": "$event.key === 'Enter' && newTodoText.trim() ? (todos.set([...todos, {id: todoIdCounter, text: newTodoText.trim(), done: false}]), todoIdCounter.set(todoIdCounter + 1), newTodoText.set('')) : null", 74 74 }), 75 - dom.button({ "data-volt-on-click": "addTodo" }, "Add Todo"), 75 + dom.button({ 76 + "data-volt-on-click": "newTodoText.trim() ? (todos.set([...todos, {id: todoIdCounter, text: newTodoText.trim(), done: false}]), todoIdCounter.set(todoIdCounter + 1), newTodoText.set('')) : null", 77 + }, "Add Todo"), 76 78 ), 77 - dom.h4(null, "Active Todos (", dom.span({ "data-volt-text": "activeTodos.get().length" }, "0"), ")"), 79 + dom.h4(null, "Active Todos (", dom.span({ "data-volt-text": "activeTodos.length" }, "0"), ")"), 78 80 dom.ul( 79 81 null, 80 82 dom.li( 81 - { "data-volt-for": "todo in todos.get().filter(t => !t.done)" }, 82 - dom.input({ type: "checkbox", "data-volt-on-change": "toggleTodo(todo.id)" }), 83 + { "data-volt-for": "todo in activeTodos" }, 84 + dom.input({ 85 + type: "checkbox", 86 + "data-volt-on-change": "todos.set(todos.map(t => t.id === todo.id ? {...t, done: !t.done} : t))", 87 + }), 83 88 " ", 84 89 dom.span({ "data-volt-text": "todo.text" }, "Todo item"), 85 90 " ", 86 - dom.button({ "data-volt-on-click": "removeTodo(todo.id)" }, "Remove"), 91 + dom.button({ "data-volt-on-click": "todos.set(todos.filter(t => t.id !== todo.id))" }, "Remove"), 87 92 ), 88 93 ), 89 - dom.h4(null, "Completed Todos (", dom.span({ "data-volt-text": "completedTodos.get().length" }, "0"), ")"), 94 + dom.h4(null, "Completed Todos (", dom.span({ "data-volt-text": "completedTodos.length" }, "0"), ")"), 90 95 dom.ul( 91 96 null, 92 97 dom.li( 93 - { "data-volt-for": "todo in todos.get().filter(t => t.done)" }, 94 - dom.input({ type: "checkbox", checked: true, "data-volt-on-change": "toggleTodo(todo.id)" }), 98 + { "data-volt-for": "todo in completedTodos" }, 99 + dom.input({ 100 + type: "checkbox", 101 + checked: true, 102 + "data-volt-on-change": "todos.set(todos.map(t => t.id === todo.id ? {...t, done: !t.done} : t))", 103 + }), 95 104 " ", 96 105 dom.del({ "data-volt-text": "todo.text" }, "Todo item"), 97 106 " ", 98 - dom.button({ "data-volt-on-click": "removeTodo(todo.id)" }, "Remove"), 107 + dom.button({ "data-volt-on-click": "todos.set(todos.filter(t => t.id !== todo.id))" }, "Remove"), 99 108 ), 100 109 ), 101 110 ), ··· 104 113 dom.h3(null, "Class Bindings"), 105 114 dom.p(null, "Toggle CSS classes reactively using ", dom.code(null, "data-volt-class"), ":"), 106 115 dom.p( 107 - { "data-volt-class": "{ active: isActive.get(), highlight: isHighlighted.get() }" }, 116 + { "data-volt-class": "{ active: isActive, highlight: isHighlighted }" }, 108 117 "This paragraph has dynamic classes. Try the buttons below!", 109 118 ), 110 119 dom.button( 111 - { "data-volt-on-click": "toggleActive" }, 120 + { "data-volt-on-click": "isActive.set(!isActive)" }, 112 121 "Toggle Active (currently: ", 113 - dom.span({ "data-volt-text": "isActive.get() ? 'ON' : 'OFF'" }, "OFF"), 122 + dom.span({ "data-volt-text": "isActive ? 'ON' : 'OFF'" }, "OFF"), 114 123 ")", 115 124 ), 116 125 " ", 117 126 dom.button( 118 - { "data-volt-on-click": "toggleHighlight" }, 127 + { "data-volt-on-click": "isHighlighted.set(!isHighlighted)" }, 119 128 "Toggle Highlight (currently: ", 120 - dom.span({ "data-volt-text": "isHighlighted.get() ? 'ON' : 'OFF'" }, "OFF"), 129 + dom.span({ "data-volt-text": "isHighlighted ? 'ON' : 'OFF'" }, "OFF"), 121 130 ")", 122 131 ), 123 132 style,
-163
lib/src/demo/sections/typography.ts
··· 1 - /** 2 - * Typography & Layout Section 3 - * Demonstrates Volt CSS typography features including Tufte-style sidenotes 4 - */ 5 - 6 - import * as dom from "../utils"; 7 - 8 - export function createTypographySection(): HTMLElement { 9 - return dom.article( 10 - { id: "typography" }, 11 - dom.h2(null, "Typography & Layout"), 12 - dom.section( 13 - null, 14 - dom.h3(null, "Headings & Hierarchy"), 15 - dom.p( 16 - null, 17 - "Volt CSS provides a harmonious type scale based on a 1.25 ratio (major third).", 18 - dom.small( 19 - null, 20 - "The modular scale creates visual hierarchy without requiring any CSS classes. Font sizes range from 0.889rem to 2.566rem.", 21 - ), 22 - " All headings automatically receive appropriate sizing, spacing, and weight.", 23 - ), 24 - dom.h4(null, "Level 4 Heading"), 25 - dom.p(null, "Demonstrates the fourth level of hierarchy."), 26 - dom.h5(null, "Level 5 Heading"), 27 - dom.p(null, "Even smaller, but still distinct and readable."), 28 - dom.h6(null, "Level 6 Heading"), 29 - dom.p(null, "The smallest heading level in the hierarchy."), 30 - ), 31 - dom.section( 32 - null, 33 - dom.h3(null, "Tufte-Style Sidenotes"), 34 - dom.p( 35 - null, 36 - "One of the signature features of Volt CSS is Tufte-style sidenotes.", 37 - dom.small( 38 - null, 39 - "Edward Tufte is renowned for his work on information design and data visualization. His books feature extensive use of margin notes that provide context without interrupting the main narrative flow.", 40 - ), 41 - " These appear in the margin on desktop and inline on mobile devices.", 42 - ), 43 - dom.p( 44 - null, 45 - "Sidenotes are created using the semantic ", 46 - dom.code(null, "<small>"), 47 - " element.", 48 - dom.small( 49 - null, 50 - "The <small> element represents side comments and fine print, making it semantically appropriate for sidenotes. No custom attributes needed!", 51 - ), 52 - " This keeps markup clean and portable while maintaining semantic meaning.", 53 - ), 54 - dom.p( 55 - null, 56 - "The responsive behavior ensures readability across all devices.", 57 - dom.small( 58 - null, 59 - "On narrow screens, sidenotes appear inline with subtle styling. On wider screens (≥1200px), they float into the right margin.", 60 - ), 61 - " Try resizing your browser to see the effect.", 62 - ), 63 - ), 64 - dom.section( 65 - null, 66 - dom.h3(null, "Lists"), 67 - dom.p(null, "Both ordered and unordered lists are styled with appropriate spacing:"), 68 - dom.h4(null, "Unordered List"), 69 - dom.ul( 70 - null, 71 - ...dom.repeat(dom.li, [ 72 - "Reactive signals for state management", 73 - "Computed values derived from signals", 74 - "Effect system for side effects", 75 - "Declarative data binding via attributes", 76 - ]), 77 - ), 78 - dom.h4(null, "Ordered List"), 79 - dom.ol( 80 - null, 81 - ...dom.repeat(dom.li, [ 82 - "Define your signals and computed values", 83 - "Write semantic HTML markup", 84 - "Add data-volt-* attributes for reactivity", 85 - "Mount the scope and watch the magic happen", 86 - ]), 87 - ), 88 - dom.h4(null, "Description List"), 89 - dom.dl( 90 - null, 91 - ...dom.kv([["Signal", "A reactive primitive that holds a value and notifies subscribers of changes."], [ 92 - "Computed", 93 - "A derived value that automatically updates when its dependencies change.", 94 - ], ["Effect", "A side effect that runs when its reactive dependencies change."]]), 95 - ), 96 - ), 97 - dom.section( 98 - null, 99 - dom.h3(null, "Blockquotes & Citations"), 100 - dom.blockquote( 101 - null, 102 - dom.p( 103 - null, 104 - "Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.", 105 - ), 106 - dom.cite(null, "Antoine de Saint-Exupéry"), 107 - ), 108 - dom.blockquote( 109 - null, 110 - dom.p( 111 - null, 112 - "The best programs are written so that computing machines can perform them quickly and so that human beings can understand them clearly.", 113 - ), 114 - dom.cite(null, "Donald Knuth"), 115 - ), 116 - ), 117 - dom.section( 118 - null, 119 - dom.h3(null, "Code & Preformatted Text"), 120 - dom.p(null, "Inline code uses ", dom.code(null, "monospace font"), " for clarity."), 121 - dom.p(null, "Code blocks preserve formatting and provide syntax-appropriate styling:"), 122 - dom.pre( 123 - null, 124 - dom.code( 125 - null, 126 - `import { signal, computed, mount } from 'volt'; 127 - 128 - const count = signal(0); 129 - const doubled = computed(() => count.get() * 2); 130 - 131 - mount(document.querySelector('#app'), { 132 - count, 133 - doubled, 134 - increment: () => count.set(count.get() + 1) 135 - });`, 136 - ), 137 - ), 138 - ), 139 - dom.section( 140 - null, 141 - dom.h3(null, "Tables"), 142 - dom.p( 143 - null, 144 - "Tables receive zebra striping and responsive styling automatically.", 145 - dom.small( 146 - null, 147 - "Tables use alternating row colors for improved scannability. On mobile, they remain scrollable horizontally if needed.", 148 - ), 149 - ), 150 - dom.table( 151 - null, 152 - dom.thead(null, dom.tr(null, ...dom.repeat(dom.th, ["Feature", "Volt.js", "Framework X", "Framework Y"]))), 153 - dom.tbody( 154 - null, 155 - dom.tr(null, ...dom.repeat(dom.td, ["Bundle Size", "< 15KB gzipped", "~40KB", "~30KB"])), 156 - dom.tr(null, dom.td(null, "Virtual DOM"), dom.td(null, "No"), dom.td(null, "Yes"), dom.td(null, "Yes")), 157 - dom.tr(null, ...dom.repeat(dom.td, ["Reactive System", "Signals", "Proxy-based", "Observable"])), 158 - dom.tr(null, ...dom.repeat(dom.td, ["Learning Curve", "Gentle", "Moderate", "Steep"])), 159 - ), 160 - ), 161 - ), 162 - ); 163 - }
+1
lib/src/demo/utils.ts
··· 221 221 export const strong: CreateFn<"strong"> = (attrs?, ...children) => el("strong", attrs, ...children); 222 222 export const em: CreateFn<"em"> = (attrs?, ...children) => el("em", attrs, ...children); 223 223 export const del: CreateFn<"del"> = (attrs?, ...children) => el("del", attrs, ...children); 224 + export const abbr: CreateFn<"abbr"> = (attrs?, ...children) => el("abbr", attrs, ...children); 224 225 export const hr: CreateFn<"hr"> = (attrs?) => el("hr", attrs);
+1 -1
lib/src/index.ts
··· 53 53 supportsViewTransitions, 54 54 withViewTransition, 55 55 } from "$core/view-transitions"; 56 - export { goBack, goForward, initNavigationListener, navigate, navigatePlugin, redirect } from "$plugins/navigate"; 56 + export { goBack, goForward, initNavigationListener, navigate, redirect } from "$plugins/navigate"; 57 57 export { persistPlugin, registerStorageAdapter } from "$plugins/persist"; 58 58 export { scrollPlugin } from "$plugins/scroll"; 59 59 export {
+5 -3
lib/src/main.ts
··· 1 1 /** 2 - * Entry point for Volt.js development demo 2 + * Entry point for VoltX.js development demo 3 3 * 4 4 * This file initializes the comprehensive demo showcasing: 5 - * - Volt.js reactive features (signals, computed, effects, bindings) 5 + * - VoltX.js declarative reactivity (charge, data-volt-state, data-volt-computed) 6 6 * - Volt CSS classless styling (typography, layout, Tufte sidenotes) 7 - * - Plugin system (persist, scroll, url) 7 + * - Plugin system (persist, scroll, url, surge, shift) 8 + * 9 + * The demo is built programmatically but uses declarative VoltX attributes, demonstrating how to create apps with minimal JavaScript. 8 10 */ 9 11 10 12 import { setupDemo } from "./demo";
+2 -17
lib/src/plugins/navigate.ts
··· 5 5 */ 6 6 7 7 import { registerDirective } from "$core/binder"; 8 - import { hasModifier, parseModifiers } from "$core/modifiers"; 8 + import { hasModifier } from "$core/modifiers"; 9 9 import { startViewTransition } from "$core/view-transitions"; 10 10 import type { Optional } from "$types/helpers"; 11 - import type { BindingContext, Modifier, PluginContext } from "$types/volt"; 11 + import type { BindingContext, Modifier } from "$types/volt"; 12 12 13 13 type NavigationState = { scrollPosition?: { x: number; y: number }; focusSelector?: string; timestamp: number }; 14 14 ··· 44 44 handleFormNavigation(ctx, value, modifiers); 45 45 } else { 46 46 console.warn("data-volt-navigate only works on <a> and <form> elements"); 47 - } 48 - } 49 - 50 - /** 51 - * Plugin-compatible wrapper for navigate directive 52 - * @deprecated Use bindNavigate directly or register as a directive 53 - */ 54 - export function navigatePlugin(ctx: PluginContext, value: string): void { 55 - const { baseName, modifiers } = parseModifiers(value || ""); 56 - const bindingCtx: BindingContext = { element: ctx.element, scope: ctx.scope, cleanups: [] }; 57 - 58 - bindNavigate(bindingCtx, baseName, modifiers); 59 - 60 - for (const cleanup of bindingCtx.cleanups) { 61 - ctx.addCleanup(cleanup); 62 47 } 63 48 } 64 49
+141
lib/src/styles/components.css
··· 157 157 margin-bottom: var(--space-md); 158 158 border-bottom: 1px solid var(--color-border); 159 159 } 160 + 161 + /** 162 + * Tooltips 163 + * 164 + * Declarative tooltips using data-vx-tooltip attribute. 165 + * Placements: top (default), right, bottom, left 166 + * Structure: <element data-vx-tooltip="Tooltip text" data-placement="top"> 167 + */ 168 + [data-vx-tooltip] { 169 + position: relative; 170 + cursor: help; 171 + } 172 + 173 + [data-vx-tooltip]::before, 174 + [data-vx-tooltip]::after { 175 + position: absolute; 176 + opacity: 0; 177 + pointer-events: none; 178 + transition: opacity var(--transition-fast), transform var(--transition-fast); 179 + z-index: 1000; 180 + } 181 + 182 + [data-vx-tooltip]::before { 183 + content: attr(data-vx-tooltip); 184 + background: var(--color-text); 185 + color: var(--color-bg); 186 + padding: var(--space-sm) var(--space-md); 187 + border-radius: var(--radius-sm); 188 + font-size: 0.875rem; 189 + white-space: normal; 190 + min-width: 200px; 191 + max-width: 300px; 192 + width: max-content; 193 + text-align: center; 194 + display: -webkit-box; 195 + -webkit-box-orient: vertical; 196 + -webkit-line-clamp: 4; 197 + line-clamp: 4; 198 + overflow: hidden; 199 + text-overflow: ellipsis; 200 + line-height: 1.4; 201 + } 202 + 203 + [data-vx-tooltip]::after { 204 + content: ''; 205 + border: 6px solid transparent; 206 + } 207 + 208 + [data-vx-tooltip]:hover::before, 209 + [data-vx-tooltip]:hover::after, 210 + [data-vx-tooltip]:focus::before, 211 + [data-vx-tooltip]:focus::after, 212 + [data-vx-tooltip]:focus-visible::before, 213 + [data-vx-tooltip]:focus-visible::after { 214 + opacity: 1; 215 + } 216 + 217 + /* Placement: Top (default) */ 218 + [data-vx-tooltip]:not([data-placement])::before, 219 + [data-vx-tooltip][data-placement="top"]::before { 220 + bottom: calc(100% + 12px); 221 + left: 50%; 222 + transform: translateX(-50%) translateY(4px); 223 + } 224 + 225 + [data-vx-tooltip]:not([data-placement])::after, 226 + [data-vx-tooltip][data-placement="top"]::after { 227 + bottom: calc(100% + 6px); 228 + left: 50%; 229 + transform: translateX(-50%); 230 + border-top-color: var(--color-text); 231 + } 232 + 233 + [data-vx-tooltip]:not([data-placement]):hover::before, 234 + [data-vx-tooltip][data-placement="top"]:hover::before, 235 + [data-vx-tooltip]:not([data-placement]):focus::before, 236 + [data-vx-tooltip][data-placement="top"]:focus::before { 237 + transform: translateX(-50%) translateY(0); 238 + } 239 + 240 + /* Placement: Bottom */ 241 + [data-vx-tooltip][data-placement="bottom"]::before { 242 + top: calc(100% + 12px); 243 + left: 50%; 244 + transform: translateX(-50%) translateY(-4px); 245 + } 246 + 247 + [data-vx-tooltip][data-placement="bottom"]::after { 248 + top: calc(100% + 6px); 249 + left: 50%; 250 + transform: translateX(-50%); 251 + border-bottom-color: var(--color-text); 252 + } 253 + 254 + [data-vx-tooltip][data-placement="bottom"]:hover::before, 255 + [data-vx-tooltip][data-placement="bottom"]:focus::before { 256 + transform: translateX(-50%) translateY(0); 257 + } 258 + 259 + [data-vx-tooltip][data-placement="right"]::before { 260 + left: calc(100% + 12px); 261 + top: 50%; 262 + transform: translateY(-50%) translateX(-4px); 263 + } 264 + 265 + [data-vx-tooltip][data-placement="right"]::after { 266 + left: calc(100% + 6px); 267 + top: 50%; 268 + transform: translateY(-50%); 269 + border-right-color: var(--color-text); 270 + } 271 + 272 + [data-vx-tooltip][data-placement="right"]:hover::before, 273 + [data-vx-tooltip][data-placement="right"]:focus::before { 274 + transform: translateY(-50%) translateX(0); 275 + } 276 + 277 + [data-vx-tooltip][data-placement="left"]::before { 278 + right: calc(100% + 12px); 279 + top: 50%; 280 + transform: translateY(-50%) translateX(4px); 281 + } 282 + 283 + [data-vx-tooltip][data-placement="left"]::after { 284 + right: calc(100% + 6px); 285 + top: 50%; 286 + transform: translateY(-50%); 287 + border-left-color: var(--color-text); 288 + } 289 + 290 + [data-vx-tooltip][data-placement="left"]:hover::before, 291 + [data-vx-tooltip][data-placement="left"]:focus::before { 292 + transform: translateY(-50%) translateX(0); 293 + } 294 + 295 + @media (max-width: 768px) { 296 + [data-vx-tooltip]::before, 297 + [data-vx-tooltip]::after { 298 + display: none; 299 + } 300 + }
+15
lib/test/core/evaluator.test.ts
··· 279 279 evaluateStatements("count.set(20)", scope); 280 280 expect((scope.count as Signal<number>).get()).toBe(20); 281 281 }); 282 + 283 + it("should support strict equality comparisons with signals", () => { 284 + scope.status = signal("active"); 285 + scope.page = signal("home"); 286 + expect(evaluate("status === 'active'", scope)).toBe(true); 287 + expect(evaluate("status === 'inactive'", scope)).toBe(false); 288 + expect(evaluate("page === 'home'", scope)).toBe(true); 289 + expect(evaluate("page === 'about'", scope)).toBe(false); 290 + }); 291 + 292 + it("should support loose equality comparisons with signals", () => { 293 + scope.status = signal("active"); 294 + expect(evaluate("status == 'active'", scope)).toBe(true); 295 + expect(evaluate("status == 'inactive'", scope)).toBe(false); 296 + }); 282 297 }); 283 298 284 299 describe("Expression Caching", () => {
+312
lib/test/core/model-binding.test.ts
··· 573 573 expect(container.querySelector("span")?.textContent).toBe("Updated"); 574 574 }); 575 575 }); 576 + 577 + describe("nested property binding in data-volt-model", () => { 578 + describe("single-level nesting", () => { 579 + it("binds to nested property in signal with object value", () => { 580 + const container = document.createElement("div"); 581 + container.innerHTML = `<input type="text" data-volt-model="user.name" />`; 582 + 583 + const user = signal({ name: "Alice", age: 30 }); 584 + mount(container, { user }); 585 + 586 + const input = container.querySelector("input")!; 587 + expect(input.value).toBe("Alice"); 588 + }); 589 + 590 + it("updates input when nested property in signal changes", () => { 591 + const container = document.createElement("div"); 592 + container.innerHTML = `<input type="text" data-volt-model="person.email" />`; 593 + 594 + const person = signal({ email: "alice@example.com", verified: false }); 595 + mount(container, { person }); 596 + 597 + const input = container.querySelector("input")!; 598 + expect(input.value).toBe("alice@example.com"); 599 + 600 + person.set({ email: "bob@example.com", verified: true }); 601 + expect(input.value).toBe("bob@example.com"); 602 + }); 603 + 604 + it("updates nested property when input changes", () => { 605 + const container = document.createElement("div"); 606 + container.innerHTML = `<input type="text" data-volt-model="profile.username" />`; 607 + 608 + const profile = signal({ username: "alice123", bio: "Developer" }); 609 + mount(container, { profile }); 610 + 611 + const input = container.querySelector("input")!; 612 + input.value = "alice456"; 613 + input.dispatchEvent(new Event("input")); 614 + 615 + expect(profile.get().username).toBe("alice456"); 616 + expect(profile.get().bio).toBe("Developer"); 617 + }); 618 + 619 + it("maintains immutability when updating nested properties", () => { 620 + const container = document.createElement("div"); 621 + container.innerHTML = `<input type="text" data-volt-model="data.value" />`; 622 + 623 + const data = signal({ value: "original", other: "unchanged" }); 624 + const originalObject = data.get(); 625 + mount(container, { data }); 626 + 627 + const input = container.querySelector("input")!; 628 + input.value = "modified"; 629 + input.dispatchEvent(new Event("input")); 630 + 631 + const updatedObject = data.get(); 632 + expect(updatedObject).not.toBe(originalObject); 633 + expect(updatedObject.value).toBe("modified"); 634 + expect(updatedObject.other).toBe("unchanged"); 635 + expect(originalObject.value).toBe("original"); 636 + }); 637 + 638 + it("handles bidirectional updates with nested properties", () => { 639 + const container = document.createElement("div"); 640 + container.innerHTML = ` 641 + <input data-volt-model="form.email" /> 642 + <span data-volt-text="form.email"></span> 643 + `; 644 + 645 + const form = signal({ email: "test@example.com", name: "Test" }); 646 + mount(container, { form }); 647 + 648 + const input = container.querySelector("input")!; 649 + const span = container.querySelector("span")!; 650 + 651 + expect(input.value).toBe("test@example.com"); 652 + expect(span.textContent).toBe("test@example.com"); 653 + 654 + input.value = "updated@example.com"; 655 + input.dispatchEvent(new Event("input")); 656 + 657 + expect(form.get().email).toBe("updated@example.com"); 658 + expect(span.textContent).toBe("updated@example.com"); 659 + }); 660 + }); 661 + 662 + describe("multiple nested properties", () => { 663 + it("binds multiple inputs to different nested properties", () => { 664 + const container = document.createElement("div"); 665 + container.innerHTML = ` 666 + <input id="name" type="text" data-volt-model="formData.name" /> 667 + <input id="email" type="email" data-volt-model="formData.email" /> 668 + `; 669 + 670 + const formData = signal({ name: "Alice", email: "alice@example.com" }); 671 + mount(container, { formData }); 672 + 673 + const nameInput = container.querySelector("#name")! as HTMLInputElement; 674 + const emailInput = container.querySelector("#email")! as HTMLInputElement; 675 + 676 + expect(nameInput.value).toBe("Alice"); 677 + expect(emailInput.value).toBe("alice@example.com"); 678 + 679 + nameInput.value = "Bob"; 680 + nameInput.dispatchEvent(new Event("input")); 681 + 682 + expect(formData.get().name).toBe("Bob"); 683 + expect(formData.get().email).toBe("alice@example.com"); 684 + 685 + emailInput.value = "bob@example.com"; 686 + emailInput.dispatchEvent(new Event("input")); 687 + 688 + expect(formData.get().name).toBe("Bob"); 689 + expect(formData.get().email).toBe("bob@example.com"); 690 + }); 691 + }); 692 + 693 + describe("different form element types", () => { 694 + it("binds checkbox to nested boolean property", () => { 695 + const container = document.createElement("div"); 696 + container.innerHTML = `<input type="checkbox" data-volt-model="settings.enabled" />`; 697 + 698 + const settings = signal({ enabled: true, theme: "dark" }); 699 + mount(container, { settings }); 700 + 701 + const checkbox = container.querySelector("input")!; 702 + expect(checkbox.checked).toBe(true); 703 + 704 + checkbox.checked = false; 705 + checkbox.dispatchEvent(new Event("change")); 706 + 707 + expect(settings.get().enabled).toBe(false); 708 + expect(settings.get().theme).toBe("dark"); 709 + }); 710 + 711 + it("binds select to nested property", () => { 712 + const container = document.createElement("div"); 713 + container.innerHTML = ` 714 + <select data-volt-model="preferences.color"> 715 + <option value="red">Red</option> 716 + <option value="blue">Blue</option> 717 + <option value="green">Green</option> 718 + </select> 719 + `; 720 + 721 + const preferences = signal({ color: "blue", size: "medium" }); 722 + mount(container, { preferences }); 723 + 724 + const select = container.querySelector("select")!; 725 + expect(select.value).toBe("blue"); 726 + 727 + select.value = "green"; 728 + select.dispatchEvent(new Event("input")); 729 + 730 + expect(preferences.get().color).toBe("green"); 731 + expect(preferences.get().size).toBe("medium"); 732 + }); 733 + 734 + it("binds textarea to nested property", () => { 735 + const container = document.createElement("div"); 736 + container.innerHTML = `<textarea data-volt-model="post.content"></textarea>`; 737 + 738 + const post = signal({ content: "Hello world", published: false }); 739 + mount(container, { post }); 740 + 741 + const textarea = container.querySelector("textarea")!; 742 + expect(textarea.value).toBe("Hello world"); 743 + 744 + textarea.value = "Updated content"; 745 + textarea.dispatchEvent(new Event("input")); 746 + 747 + expect(post.get().content).toBe("Updated content"); 748 + expect(post.get().published).toBe(false); 749 + }); 750 + 751 + it("binds radio buttons to nested property", () => { 752 + const container = document.createElement("div"); 753 + container.innerHTML = ` 754 + <input type="radio" name="plan" value="free" data-volt-model="subscription.plan" /> 755 + <input type="radio" name="plan" value="pro" data-volt-model="subscription.plan" /> 756 + <input type="radio" name="plan" value="enterprise" data-volt-model="subscription.plan" /> 757 + `; 758 + 759 + const subscription = signal({ plan: "pro", active: true }); 760 + mount(container, { subscription }); 761 + 762 + const radios = container.querySelectorAll("input"); 763 + expect(radios[0].checked).toBe(false); 764 + expect(radios[1].checked).toBe(true); 765 + expect(radios[2].checked).toBe(false); 766 + 767 + radios[2].checked = true; 768 + radios[2].dispatchEvent(new Event("change")); 769 + 770 + expect(subscription.get().plan).toBe("enterprise"); 771 + expect(subscription.get().active).toBe(true); 772 + }); 773 + }); 774 + 775 + describe("deep nesting", () => { 776 + it("binds to deeply nested properties", () => { 777 + const container = document.createElement("div"); 778 + container.innerHTML = `<input type="text" data-volt-model="app.user.profile.displayName" />`; 779 + 780 + const app = signal({ 781 + user: { profile: { displayName: "Alice", avatar: "/avatar.png" }, settings: { theme: "dark" } }, 782 + }); 783 + mount(container, { app }); 784 + 785 + const input = container.querySelector("input")!; 786 + expect(input.value).toBe("Alice"); 787 + 788 + input.value = "Bob"; 789 + input.dispatchEvent(new Event("input")); 790 + 791 + expect(app.get().user.profile.displayName).toBe("Bob"); 792 + expect(app.get().user.profile.avatar).toBe("/avatar.png"); 793 + expect(app.get().user.settings.theme).toBe("dark"); 794 + }); 795 + 796 + it("maintains immutability with deeply nested updates", () => { 797 + const container = document.createElement("div"); 798 + container.innerHTML = `<input type="text" data-volt-model="state.form.fields.email" />`; 799 + 800 + const state = signal({ 801 + form: { fields: { email: "test@example.com", name: "Test" }, meta: { submitted: false } }, 802 + }); 803 + 804 + const originalState = state.get(); 805 + const originalForm = originalState.form; 806 + const originalFields = originalState.form.fields; 807 + 808 + mount(container, { state }); 809 + 810 + const input = container.querySelector("input")!; 811 + input.value = "new@example.com"; 812 + input.dispatchEvent(new Event("input")); 813 + 814 + const newState = state.get(); 815 + const newForm = newState.form; 816 + const newFields = newState.form.fields; 817 + 818 + expect(newState).not.toBe(originalState); 819 + expect(newForm).not.toBe(originalForm); 820 + expect(newFields).not.toBe(originalFields); 821 + 822 + expect(newFields.email).toBe("new@example.com"); 823 + expect(newFields.name).toBe("Test"); 824 + expect(newForm.meta.submitted).toBe(false); 825 + 826 + expect(originalFields.email).toBe("test@example.com"); 827 + }); 828 + }); 829 + 830 + describe("edge cases", () => { 831 + it("handles undefined nested property gracefully", () => { 832 + const container = document.createElement("div"); 833 + container.innerHTML = `<input type="text" data-volt-model="obj.missing" />`; 834 + 835 + const obj = signal({ present: "value" }); 836 + mount(container, { obj }); 837 + 838 + const input = container.querySelector("input")!; 839 + expect(input.value).toBe(""); 840 + }); 841 + 842 + it("creates nested property path when updating undefined property", () => { 843 + const container = document.createElement("div"); 844 + container.innerHTML = `<input type="text" data-volt-model="data.newProp" />`; 845 + 846 + const data = signal({ existingProp: "exists" }); 847 + mount(container, { data }); 848 + 849 + const input = container.querySelector("input")!; 850 + input.value = "new value"; 851 + input.dispatchEvent(new Event("input")); 852 + 853 + // @ts-expect-error updating shape of data 854 + expect(data.get().newProp).toBe("new value"); 855 + expect(data.get().existingProp).toBe("exists"); 856 + }); 857 + 858 + it("works with modifiers on nested properties", () => { 859 + const container = document.createElement("div"); 860 + container.innerHTML = `<input type="text" data-volt-model-trim="form.username" />`; 861 + 862 + const form = signal({ username: "", email: "" }); 863 + mount(container, { form }); 864 + 865 + const input = container.querySelector("input")!; 866 + input.value = " alice "; 867 + input.dispatchEvent(new Event("input")); 868 + 869 + expect(form.get().username).toBe("alice"); 870 + }); 871 + 872 + it("works with number modifier on nested properties", () => { 873 + const container = document.createElement("div"); 874 + container.innerHTML = `<input type="text" data-volt-model-number="config.port" />`; 875 + 876 + const config = signal({ port: 8080, host: "localhost" }); 877 + mount(container, { config }); 878 + 879 + const input = container.querySelector("input")!; 880 + input.value = "3000"; 881 + input.dispatchEvent(new Event("input")); 882 + 883 + expect(config.get().port).toBe(3000); 884 + expect(typeof config.get().port).toBe("number"); 885 + }); 886 + }); 887 + });
+2 -2
lib/test/integration/global-state.test.ts
··· 194 194 document.body.innerHTML = ` 195 195 <div data-volt data-volt-state='{"log": []}'> 196 196 <button data-volt-on-click="log.set([...log.get(), 'sync']); $pulse(() => log.set([...log.get(), 'async']))">Click</button> 197 - <p data-volt-text="log.get().join(', ')"></p> 197 + <p data-volt-text="log.join(', ')"></p> 198 198 </div> 199 199 `; 200 200 ··· 319 319 document.body.innerHTML = ` 320 320 <div data-volt data-volt-state='{"count": 0, "log": []}' data-volt-init="$probe('count', v => log.set([...log.get(), v]))"> 321 321 <button data-volt-on-click="count.set(count.get() + 1)">Increment</button> 322 - <p data-volt-text="log.get().join(', ')"></p> 322 + <p data-volt-text="log.join(', ')"></p> 323 323 </div> 324 324 `; 325 325