@igojs/signal 6.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -0
- package/index.js +24 -0
- package/package.json +30 -0
- package/src/client/DerivedCache.js +63 -0
- package/src/client/EventBinder.js +135 -0
- package/src/client/FormHandler.js +94 -0
- package/src/client/README.md +273 -0
- package/src/client/SignalComponent.js +409 -0
- package/src/client/StateProxy.js +74 -0
- package/src/client/dust/Templates.js +26 -0
- package/src/client/dust/Utils.js +124 -0
- package/src/client/dust/i18n.js +22 -0
- package/src/client/index.js +72 -0
- package/src/server/SerializeUtils.js +70 -0
- package/src/server/SignalController.js +94 -0
- package/src/server/index.js +17 -0
- package/src/shared/serialize.js +35 -0
- package/test/client/DerivedCacheTest.js +44 -0
- package/test/client/SignalComponentTest.js +59 -0
- package/test/client/StateProxyTest.js +59 -0
- package/test/server/SerializeUtilsTest.js +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# @igojs/signal
|
|
2
|
+
|
|
3
|
+
Reactive frontend/SSR framework for Igo.js with automatic dependency tracking.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @igojs/signal
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Reactive State** - Deep reactivity via Proxy (like Vue 3)
|
|
14
|
+
- **Auto-tracking** - Automatic dependency detection for computed values
|
|
15
|
+
- **SSR Support** - Server-side rendering with hydration
|
|
16
|
+
- **DiffDOM** - Efficient DOM reconciliation
|
|
17
|
+
- **Form Handling** - Two-way binding for forms
|
|
18
|
+
- **Dust Templates** - Integration with @igojs/dust
|
|
19
|
+
|
|
20
|
+
## Architecture
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Props (immutable) → State (reactive) → Derived (computed) → Template → DOM
|
|
24
|
+
↓ ↓
|
|
25
|
+
Proxy tracking DiffDOM reconciliation
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
### Server Setup
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
const express = require('express');
|
|
34
|
+
const signal = require('@igojs/signal');
|
|
35
|
+
|
|
36
|
+
const app = express();
|
|
37
|
+
|
|
38
|
+
// Configure translations (optional)
|
|
39
|
+
signal.configure({
|
|
40
|
+
translations: require('./locales/fr/translation.json')
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Add signal middleware
|
|
44
|
+
app.use(signal.middleware);
|
|
45
|
+
|
|
46
|
+
// Template endpoint for client-side loading
|
|
47
|
+
app.get('/__signal/templates', signal.templates);
|
|
48
|
+
|
|
49
|
+
// Controller
|
|
50
|
+
app.get('/products', (req, res) => {
|
|
51
|
+
res.locals.signal_props = {
|
|
52
|
+
products: await Product.list(),
|
|
53
|
+
form: { search: req.query.search || '' }
|
|
54
|
+
};
|
|
55
|
+
res.render('products/index');
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Component Definition
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
// components/ProductList.js
|
|
63
|
+
const { SignalComponent } = require('@igojs/signal');
|
|
64
|
+
|
|
65
|
+
class ProductList extends SignalComponent {
|
|
66
|
+
constructor(element) {
|
|
67
|
+
super(element, 'components/ProductList');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Computed value with auto-tracking
|
|
71
|
+
get filteredProducts() {
|
|
72
|
+
const search = this.state.form?.search?.toLowerCase() || '';
|
|
73
|
+
return this.props.products.filter(p =>
|
|
74
|
+
p.name.toLowerCase().includes(search)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Event handlers
|
|
79
|
+
get events() {
|
|
80
|
+
return [
|
|
81
|
+
{ selector: '.delete-btn', eventType: 'click', handler: this.onDelete }
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async onDelete(e) {
|
|
86
|
+
const id = e.target.dataset.id;
|
|
87
|
+
await fetch(`/api/products/${id}`, { method: 'DELETE' });
|
|
88
|
+
this.props.products = this.props.products.filter(p => p.id !== id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = ProductList;
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Template (Dust)
|
|
96
|
+
|
|
97
|
+
```dust
|
|
98
|
+
{! views/components/ProductList.dust !}
|
|
99
|
+
<div data-component="components/ProductList" data-props="{@serialize props="products,form" /}">
|
|
100
|
+
<input type="text" name="search" value="{form.search}" placeholder="Search...">
|
|
101
|
+
|
|
102
|
+
<ul>
|
|
103
|
+
{#filteredProducts}
|
|
104
|
+
<li>
|
|
105
|
+
{name} - ${price}
|
|
106
|
+
<button class="delete-btn" data-id="{id}">Delete</button>
|
|
107
|
+
</li>
|
|
108
|
+
{/filteredProducts}
|
|
109
|
+
</ul>
|
|
110
|
+
</div>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Client Entry Point
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
// assets/js/app.js
|
|
117
|
+
const signal = require('@igojs/signal/src/client');
|
|
118
|
+
|
|
119
|
+
signal.start({
|
|
120
|
+
components: require.context('./components', true, /\.js$/),
|
|
121
|
+
helpers: require('./helpers')
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## API
|
|
126
|
+
|
|
127
|
+
### Server
|
|
128
|
+
|
|
129
|
+
| Export | Description |
|
|
130
|
+
|--------|-------------|
|
|
131
|
+
| `middleware` | Express middleware for SSR |
|
|
132
|
+
| `templates` | Endpoint for client template loading |
|
|
133
|
+
| `configure({ translations })` | Configure i18n translations |
|
|
134
|
+
| `serialize(data)` | Serialize data for hydration |
|
|
135
|
+
| `SignalComponent` | Base component class |
|
|
136
|
+
|
|
137
|
+
### SignalComponent
|
|
138
|
+
|
|
139
|
+
| Property/Method | Description |
|
|
140
|
+
|-----------------|-------------|
|
|
141
|
+
| `this.props` | Immutable props from server |
|
|
142
|
+
| `this.state` | Reactive state (triggers re-render on change) |
|
|
143
|
+
| `this.rawState` | Direct state access (no reactivity) |
|
|
144
|
+
| `get xyz()` | Computed values with auto-tracking |
|
|
145
|
+
| `get events()` | Event bindings `[{selector, eventType, handler}]` |
|
|
146
|
+
| `beforeRender()` | Lifecycle hook before render |
|
|
147
|
+
| `afterRender()` | Lifecycle hook after render |
|
|
148
|
+
| `destroy()` | Cleanup component |
|
|
149
|
+
|
|
150
|
+
### Reactivity
|
|
151
|
+
|
|
152
|
+
```javascript
|
|
153
|
+
// Deep reactivity
|
|
154
|
+
this.state.user = { name: 'John' };
|
|
155
|
+
this.state.user.name = 'Jane'; // Triggers render
|
|
156
|
+
this.state.items.push({ id: 1 }); // Triggers render
|
|
157
|
+
this.state.items[0].active = true; // Triggers render
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Form Handling
|
|
161
|
+
|
|
162
|
+
Forms are automatically bound when `props.form` exists:
|
|
163
|
+
|
|
164
|
+
```javascript
|
|
165
|
+
// In your component
|
|
166
|
+
get selectedProduct() {
|
|
167
|
+
const id = Number(this.state.form?.product_id);
|
|
168
|
+
return this.props.products.find(p => p.id === id);
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```dust
|
|
173
|
+
<select name="product_id">
|
|
174
|
+
{#products}
|
|
175
|
+
<option value="{id}" {@selected key=id value=form.product_id /}>{name}</option>
|
|
176
|
+
{/products}
|
|
177
|
+
</select>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Module Structure
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
src/
|
|
184
|
+
├── client/ # Browser code
|
|
185
|
+
│ ├── index.js # Entry point, start()
|
|
186
|
+
│ ├── SignalComponent.js # Base component class
|
|
187
|
+
│ ├── StateProxy.js # Deep reactivity
|
|
188
|
+
│ ├── DerivedCache.js # Computed value memoization
|
|
189
|
+
│ ├── EventBinder.js # Event listener management
|
|
190
|
+
│ ├── FormHandler.js # Two-way form binding
|
|
191
|
+
│ └── dust/
|
|
192
|
+
│ ├── Templates.js # Template loading
|
|
193
|
+
│ ├── Utils.js # Dust helpers/filters
|
|
194
|
+
│ └── i18n.js # i18next setup
|
|
195
|
+
└── server/ # Node.js code
|
|
196
|
+
├── index.js # Server exports
|
|
197
|
+
├── SignalController.js # SSR middleware
|
|
198
|
+
└── SerializeUtils.js # Data serialization
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Documentation
|
|
202
|
+
|
|
203
|
+
See the [full documentation](https://igocreate.github.io/igo/#/signal/components).
|
package/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @igojs/signal - Reactive frontend/SSR framework for Igo.js
|
|
2
|
+
|
|
3
|
+
// Server-side exports
|
|
4
|
+
const server = require('./src/server');
|
|
5
|
+
|
|
6
|
+
// Re-export server utilities
|
|
7
|
+
module.exports = {
|
|
8
|
+
// Server middleware for SSR
|
|
9
|
+
middleware: server.middleware,
|
|
10
|
+
|
|
11
|
+
// Template serving endpoint
|
|
12
|
+
templates: server.templates,
|
|
13
|
+
|
|
14
|
+
// Configure signal (translations, etc.)
|
|
15
|
+
configure: server.configure,
|
|
16
|
+
|
|
17
|
+
// Serialization for client hydration
|
|
18
|
+
serialize: server.serialize,
|
|
19
|
+
|
|
20
|
+
// SignalComponent for SSR rendering
|
|
21
|
+
get SignalComponent() {
|
|
22
|
+
return require('./src/client/SignalComponent');
|
|
23
|
+
},
|
|
24
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@igojs/signal",
|
|
3
|
+
"version": "6.0.0-beta.1",
|
|
4
|
+
"description": "Signal - Reactive frontend/SSR framework for Igo.js",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"browser": "src/client/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "mocha --exit 'test/**/*.js'"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"frontend",
|
|
12
|
+
"ssr",
|
|
13
|
+
"reactive",
|
|
14
|
+
"signals",
|
|
15
|
+
"components"
|
|
16
|
+
],
|
|
17
|
+
"author": "@igocreate",
|
|
18
|
+
"license": "ISC",
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@igojs/server": "*",
|
|
24
|
+
"@igojs/dust": "*",
|
|
25
|
+
"devalue": "^5.6.1",
|
|
26
|
+
"diff-dom": "^5.2.1",
|
|
27
|
+
"i18next": "^25.7.3",
|
|
28
|
+
"lodash": "^4.17.21"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DerivedCache - Memoization with auto-tracked dependencies (like Vue 3 computed)
|
|
3
|
+
*
|
|
4
|
+
* Caches expensive calculations and recalculates only when dependencies change.
|
|
5
|
+
* Uses Proxy-based dependency tracking to automatically detect what values are accessed.
|
|
6
|
+
*/
|
|
7
|
+
class DerivedCache {
|
|
8
|
+
constructor() {
|
|
9
|
+
this._cache = new Map(); // Map<key, { value, deps }>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Memoize a derived value with optional precomputed value
|
|
13
|
+
memoize(key, fn, deps, context, precomputedValue) {
|
|
14
|
+
const resolvedDeps = context ? this._resolveDeps(deps, context) : deps;
|
|
15
|
+
|
|
16
|
+
// If precomputed value provided, use it directly (first render optimization)
|
|
17
|
+
if (precomputedValue !== undefined) {
|
|
18
|
+
this._cache.set(key, { value: precomputedValue, deps: [...resolvedDeps], fn });
|
|
19
|
+
return precomputedValue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Check cache for existing value
|
|
23
|
+
const cached = this._cache.get(key);
|
|
24
|
+
if (cached && this._areDepsEqual(cached.deps, resolvedDeps)) {
|
|
25
|
+
return cached.value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Recompute
|
|
29
|
+
const value = fn();
|
|
30
|
+
this._cache.set(key, { value, deps: [...resolvedDeps] });
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Resolve dependency paths like ['props', 'products'] to actual values
|
|
35
|
+
_resolveDeps(deps, context) {
|
|
36
|
+
return deps.map(dep => {
|
|
37
|
+
if (!Array.isArray(dep)) return dep;
|
|
38
|
+
|
|
39
|
+
let value = context;
|
|
40
|
+
for (const key of dep) value = value?.[key];
|
|
41
|
+
return value;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Shallow comparison (Object.is like React)
|
|
46
|
+
_areDepsEqual(prevDeps, nextDeps) {
|
|
47
|
+
if (prevDeps.length !== nextDeps.length) return false;
|
|
48
|
+
for (let i = 0; i < prevDeps.length; i++) {
|
|
49
|
+
if (!Object.is(prevDeps[i], nextDeps[i])) return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
clear() {
|
|
55
|
+
this._cache.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
invalidate(key) {
|
|
59
|
+
this._cache.delete(key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = DerivedCache;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventBinder - Optimized event listener management with WeakMap caching
|
|
3
|
+
*
|
|
4
|
+
* Similar to how modern frameworks (Vue, Svelte, Solid) optimize event binding,
|
|
5
|
+
* this class avoids unnecessary rebinding by caching listeners and reusing them
|
|
6
|
+
* when DOM elements are preserved by DiffDOM.
|
|
7
|
+
*
|
|
8
|
+
* Strategy: WeakMap<Element, Map<eventType, handler>>
|
|
9
|
+
* - Outer WeakMap: Element → Map of events (automatic garbage collection)
|
|
10
|
+
* - Inner Map: eventType → handler (supports multiple events per element)
|
|
11
|
+
*
|
|
12
|
+
* Performance:
|
|
13
|
+
* - O(1) lookup to check if listener exists
|
|
14
|
+
* - No rebinding if element is preserved by DiffDOM
|
|
15
|
+
* - Automatic cleanup when elements are removed (WeakMap GC)
|
|
16
|
+
*/
|
|
17
|
+
class EventBinder {
|
|
18
|
+
constructor() {
|
|
19
|
+
// WeakMap<Element, Map<eventType, handler>>
|
|
20
|
+
this._elementListeners = new WeakMap();
|
|
21
|
+
this._boundListeners = [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Bind events to elements with optimization
|
|
26
|
+
*
|
|
27
|
+
* @param {HTMLElement} rootElement - Root element to search within
|
|
28
|
+
* @param {Array} events - Array of {selector, eventType, handler}
|
|
29
|
+
* @param {Object} context - Context to bind handlers to (usually the component)
|
|
30
|
+
*/
|
|
31
|
+
bind(rootElement, events, context) {
|
|
32
|
+
if (!events || !Array.isArray(events)) return;
|
|
33
|
+
|
|
34
|
+
const newListeners = [];
|
|
35
|
+
const processedElements = new Map(); // Map<Element, Set<eventType>>
|
|
36
|
+
|
|
37
|
+
events.forEach(({ selector, eventType, handler }) => {
|
|
38
|
+
// Support 'document'/'window' strings as selector for global events
|
|
39
|
+
let elements;
|
|
40
|
+
if (selector === 'document') {
|
|
41
|
+
elements = [document];
|
|
42
|
+
} else if (selector === 'window') {
|
|
43
|
+
elements = [window];
|
|
44
|
+
} else {
|
|
45
|
+
elements = rootElement.querySelectorAll(selector);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
elements.forEach(targetElement => {
|
|
49
|
+
// Skip warning for global selectors (document/window)
|
|
50
|
+
const isGlobalSelector = targetElement === document || targetElement === window;
|
|
51
|
+
|
|
52
|
+
// Warn if trying to bind to an element inside a child component
|
|
53
|
+
// Only warn if targeting elements INSIDE a child component, not the component's root element itself
|
|
54
|
+
if (!isGlobalSelector) {
|
|
55
|
+
const closestComponent = targetElement.closest('[data-component]');
|
|
56
|
+
const isTargetingChildComponentRoot = closestComponent === targetElement;
|
|
57
|
+
if (closestComponent && closestComponent !== rootElement && !isTargetingChildComponentRoot) {
|
|
58
|
+
console.warn(
|
|
59
|
+
`[EventBinder] Warning: Attempting to bind ${eventType} event to selector "${selector}" ` +
|
|
60
|
+
`which is inside a child component (${closestComponent.dataset.component}). ` +
|
|
61
|
+
`Consider listening to a custom event from the child component instead.`,
|
|
62
|
+
{ parentComponent: context.constructor?.name, childComponent: closestComponent.dataset.component, targetElement }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Get or create event map for this element
|
|
68
|
+
let eventMap = this._elementListeners.get(targetElement);
|
|
69
|
+
if (!eventMap) {
|
|
70
|
+
eventMap = new Map();
|
|
71
|
+
this._elementListeners.set(targetElement, eventMap);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check if this (element, eventType) already has a listener
|
|
75
|
+
const existingHandler = eventMap.get(eventType);
|
|
76
|
+
|
|
77
|
+
if (existingHandler) {
|
|
78
|
+
// ✅ Element preserved by DiffDOM → reuse existing listener
|
|
79
|
+
newListeners.push({
|
|
80
|
+
element: targetElement,
|
|
81
|
+
eventType,
|
|
82
|
+
handler: existingHandler
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
// ✅ New element or new eventType → create new listener
|
|
86
|
+
const boundHandler = handler.bind(context);
|
|
87
|
+
targetElement.addEventListener(eventType, boundHandler);
|
|
88
|
+
eventMap.set(eventType, boundHandler);
|
|
89
|
+
newListeners.push({
|
|
90
|
+
element: targetElement,
|
|
91
|
+
eventType,
|
|
92
|
+
handler: boundHandler
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Track processed elements for cleanup
|
|
97
|
+
if (!processedElements.has(targetElement)) {
|
|
98
|
+
processedElements.set(targetElement, new Set());
|
|
99
|
+
}
|
|
100
|
+
processedElements.get(targetElement).add(eventType);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Cleanup: Remove listeners for elements that were removed or changed
|
|
105
|
+
this._boundListeners.forEach(({ element, eventType, handler }) => {
|
|
106
|
+
const processedEvents = processedElements.get(element);
|
|
107
|
+
if (!processedEvents || !processedEvents.has(eventType)) {
|
|
108
|
+
// Element was removed or event type changed → cleanup
|
|
109
|
+
element?.removeEventListener(eventType, handler);
|
|
110
|
+
const eventMap = this._elementListeners.get(element);
|
|
111
|
+
if (eventMap) {
|
|
112
|
+
eventMap.delete(eventType);
|
|
113
|
+
if (eventMap.size === 0) {
|
|
114
|
+
this._elementListeners.delete(element);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
this._boundListeners = newListeners;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Unbind all listeners and clear cache
|
|
125
|
+
*/
|
|
126
|
+
unbind() {
|
|
127
|
+
this._boundListeners.forEach(({ element, eventType, handler }) => {
|
|
128
|
+
element?.removeEventListener(eventType, handler);
|
|
129
|
+
});
|
|
130
|
+
this._boundListeners = [];
|
|
131
|
+
// Note: WeakMap clears itself automatically when elements are garbage collected
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = EventBinder;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
class FormHandler {
|
|
2
|
+
constructor(component, formData) {
|
|
3
|
+
this.component = component;
|
|
4
|
+
this.element = component.element;
|
|
5
|
+
this._state = component.rawState;
|
|
6
|
+
this._boundListeners = [];
|
|
7
|
+
|
|
8
|
+
// Initialize form state
|
|
9
|
+
this._state.form = this.initForm(formData);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Initialize form from props.form
|
|
13
|
+
// Uses a shared form object (window.__signal_form) so all components share the same form state
|
|
14
|
+
initForm(formData) {
|
|
15
|
+
if (!window.__signal_form) {
|
|
16
|
+
window.__signal_form = { ...formData };
|
|
17
|
+
}
|
|
18
|
+
return window.__signal_form;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
bind() {
|
|
22
|
+
this.element.querySelectorAll('input, select, textarea').forEach(element => {
|
|
23
|
+
if (!element.name) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Skip inputs that are inside child components
|
|
28
|
+
const parentComponent = element.closest('[data-component]');
|
|
29
|
+
if (parentComponent && parentComponent !== this.element) {
|
|
30
|
+
return; // Skip this input, it belongs to a child component
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const eventType = ['checkbox', 'radio'].includes(element.type) || element.tagName === 'SELECT' ? 'change' : 'input';
|
|
34
|
+
const handler = (e) => this.handleInput(e.target);
|
|
35
|
+
|
|
36
|
+
element.addEventListener(eventType, handler);
|
|
37
|
+
this._boundListeners.push({ element, eventType, handler });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
unbind() {
|
|
42
|
+
this._boundListeners.forEach(({ element, eventType, handler }) => {
|
|
43
|
+
element?.removeEventListener(eventType, handler);
|
|
44
|
+
});
|
|
45
|
+
this._boundListeners = [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Handle form input change
|
|
49
|
+
handleInput(element) {
|
|
50
|
+
const { name, type, value } = element;
|
|
51
|
+
|
|
52
|
+
// name[index][] format
|
|
53
|
+
const arrayWithIndexMatch = name.match(/^(.+)\[(\d+)\]\[\]$/);
|
|
54
|
+
if (arrayWithIndexMatch) {
|
|
55
|
+
const [, fieldName, indexStr] = arrayWithIndexMatch;
|
|
56
|
+
const index = parseInt(indexStr, 10);
|
|
57
|
+
|
|
58
|
+
this._state.form[fieldName] = this._state.form[fieldName] || [];
|
|
59
|
+
|
|
60
|
+
if (type === 'select-multiple') {
|
|
61
|
+
this._state.form[fieldName][index] = [...element.options].filter(o => o.selected).map(o => o.value);
|
|
62
|
+
} else if (type === 'checkbox') {
|
|
63
|
+
this._state.form[fieldName][index] = Array.from(this.element.querySelectorAll(`[name="${name}"]:checked`)).map(el => el.value);
|
|
64
|
+
} else {
|
|
65
|
+
this._state.form[fieldName][index] = value;
|
|
66
|
+
}
|
|
67
|
+
return this.component._triggerRender();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isArray = name.endsWith('[]');
|
|
71
|
+
const fieldName = isArray ? name.slice(0, -2) : name;
|
|
72
|
+
|
|
73
|
+
if (type === 'checkbox') {
|
|
74
|
+
this._state.form[fieldName] = isArray
|
|
75
|
+
? Array.from(this.element.querySelectorAll(`[name="${name}"]:checked`)).map(el => el.value)
|
|
76
|
+
: element.checked;
|
|
77
|
+
} else if (type === 'select-multiple') {
|
|
78
|
+
this._state.form[fieldName] = [...element.options].filter(o => o.selected).map(o => o.value);
|
|
79
|
+
} else if (isArray) {
|
|
80
|
+
this._state.form[fieldName] = this._state.form[fieldName] || [];
|
|
81
|
+
const elements = Array.from(this.element.querySelectorAll(`[name="${name}"]`));
|
|
82
|
+
const elementIndex = elements.indexOf(element);
|
|
83
|
+
if (elementIndex !== -1) {
|
|
84
|
+
this._state.form[fieldName][elementIndex] = value;
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
this._state.form[fieldName] = value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.component._triggerRender();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = FormHandler;
|