+37
README.md
+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
+

51
+
52
+

53
+
54
+

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
+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
+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
docs/images/voltx-css_components.png
This is a binary file and will not be displayed.
docs/images/voltx-css_structured-content.png
docs/images/voltx-css_structured-content.png
This is a binary file and will not be displayed.
docs/images/voltx-css_typography.png
docs/images/voltx-css_typography.png
This is a binary file and will not be displayed.
+36
-2
docs/internals/reactivity.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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";
+141
lib/src/styles/components.css
+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
+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
+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
+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