@bookklik/senangstart-actions 0.1.0
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 +58 -0
- package/dist/senangstart-actions.esm.js +992 -0
- package/dist/senangstart-actions.js +997 -0
- package/dist/senangstart-actions.min.js +9 -0
- package/package.json +46 -0
- package/src/evaluator.js +118 -0
- package/src/handlers/attributes.js +168 -0
- package/src/handlers/bind.js +46 -0
- package/src/handlers/directives.js +119 -0
- package/src/handlers/events.js +66 -0
- package/src/handlers/index.js +11 -0
- package/src/index.js +106 -0
- package/src/observer.js +42 -0
- package/src/reactive.js +210 -0
- package/src/walker.js +117 -0
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SenangStart Actions v0.1.0
|
|
3
|
+
* Declarative UI framework for humans and AI agents
|
|
4
|
+
* @license MIT
|
|
5
|
+
*/
|
|
6
|
+
var SenangStart = (function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SenangStart Actions - Reactive System
|
|
11
|
+
* Proxy-based reactivity with dependency tracking
|
|
12
|
+
*
|
|
13
|
+
* @module reactive
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Array methods that mutate the array
|
|
17
|
+
const ARRAY_MUTATING_METHODS = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse', 'fill', 'copyWithin'];
|
|
18
|
+
|
|
19
|
+
// Internal state
|
|
20
|
+
let pendingUpdate = false;
|
|
21
|
+
const pendingEffects = new Set();
|
|
22
|
+
let currentEffect = null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates a reactive array that triggers updates on mutations
|
|
26
|
+
*/
|
|
27
|
+
function createReactiveArray(arr, onMutate, subscribers) {
|
|
28
|
+
const handler = {
|
|
29
|
+
get(target, prop) {
|
|
30
|
+
// Track dependency for length and numeric indices
|
|
31
|
+
if (currentEffect && (prop === 'length' || !isNaN(parseInt(prop)))) {
|
|
32
|
+
if (!subscribers.has('__array__')) {
|
|
33
|
+
subscribers.set('__array__', new Set());
|
|
34
|
+
}
|
|
35
|
+
subscribers.get('__array__').add(currentEffect);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const value = target[prop];
|
|
39
|
+
|
|
40
|
+
// Intercept mutating array methods
|
|
41
|
+
if (ARRAY_MUTATING_METHODS.includes(prop) && typeof value === 'function') {
|
|
42
|
+
return function(...args) {
|
|
43
|
+
const result = Array.prototype[prop].apply(target, args);
|
|
44
|
+
|
|
45
|
+
// Notify all array subscribers
|
|
46
|
+
if (subscribers.has('__array__')) {
|
|
47
|
+
subscribers.get('__array__').forEach(callback => {
|
|
48
|
+
pendingEffects.add(callback);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
scheduleUpdate(onMutate);
|
|
53
|
+
return result;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Recursively wrap nested objects/arrays
|
|
58
|
+
if (value && typeof value === 'object') {
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
return createReactiveArray(value, onMutate, subscribers);
|
|
61
|
+
} else {
|
|
62
|
+
return createReactiveObject(value, onMutate, subscribers);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return value;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
set(target, prop, value) {
|
|
70
|
+
const oldValue = target[prop];
|
|
71
|
+
if (oldValue === value) return true;
|
|
72
|
+
|
|
73
|
+
target[prop] = value;
|
|
74
|
+
|
|
75
|
+
// Notify subscribers
|
|
76
|
+
if (subscribers.has('__array__')) {
|
|
77
|
+
subscribers.get('__array__').forEach(callback => {
|
|
78
|
+
pendingEffects.add(callback);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
scheduleUpdate(onMutate);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return new Proxy(arr, handler);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Creates a reactive object that triggers updates on property changes
|
|
92
|
+
*/
|
|
93
|
+
function createReactiveObject(obj, onMutate, subscribers) {
|
|
94
|
+
const handler = {
|
|
95
|
+
get(target, prop) {
|
|
96
|
+
// Skip internal properties
|
|
97
|
+
if (prop === '__subscribers' || prop === '__isReactive') {
|
|
98
|
+
return target[prop];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Track dependency if we're in an effect context
|
|
102
|
+
if (currentEffect) {
|
|
103
|
+
if (!subscribers.has(prop)) {
|
|
104
|
+
subscribers.set(prop, new Set());
|
|
105
|
+
}
|
|
106
|
+
subscribers.get(prop).add(currentEffect);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const value = target[prop];
|
|
110
|
+
|
|
111
|
+
// If it's a function, bind it to the proxy
|
|
112
|
+
if (typeof value === 'function') {
|
|
113
|
+
return value.bind(proxy);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Recursively wrap nested objects/arrays
|
|
117
|
+
if (value && typeof value === 'object') {
|
|
118
|
+
if (Array.isArray(value)) {
|
|
119
|
+
return createReactiveArray(value, onMutate, subscribers);
|
|
120
|
+
} else if (!value.__isReactive) {
|
|
121
|
+
return createReactiveObject(value, onMutate, subscribers);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return value;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
set(target, prop, value) {
|
|
129
|
+
const oldValue = target[prop];
|
|
130
|
+
if (oldValue === value) return true;
|
|
131
|
+
|
|
132
|
+
target[prop] = value;
|
|
133
|
+
|
|
134
|
+
// Notify subscribers for this property
|
|
135
|
+
if (subscribers.has(prop)) {
|
|
136
|
+
subscribers.get(prop).forEach(callback => {
|
|
137
|
+
pendingEffects.add(callback);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Schedule batched update
|
|
142
|
+
scheduleUpdate(onMutate);
|
|
143
|
+
|
|
144
|
+
return true;
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
deleteProperty(target, prop) {
|
|
148
|
+
delete target[prop];
|
|
149
|
+
|
|
150
|
+
if (subscribers.has(prop)) {
|
|
151
|
+
subscribers.get(prop).forEach(callback => {
|
|
152
|
+
pendingEffects.add(callback);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
scheduleUpdate(onMutate);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const proxy = new Proxy(obj, handler);
|
|
162
|
+
return proxy;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Creates a reactive proxy that tracks dependencies and triggers updates
|
|
167
|
+
*/
|
|
168
|
+
function createReactive(data, onUpdate) {
|
|
169
|
+
const subscribers = new Map(); // property -> Set of callbacks
|
|
170
|
+
|
|
171
|
+
let proxy;
|
|
172
|
+
if (Array.isArray(data)) {
|
|
173
|
+
proxy = createReactiveArray(data, onUpdate, subscribers);
|
|
174
|
+
} else {
|
|
175
|
+
proxy = createReactiveObject(data, onUpdate, subscribers);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
proxy.__subscribers = subscribers;
|
|
179
|
+
proxy.__isReactive = true;
|
|
180
|
+
return proxy;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Run an effect function while tracking its dependencies
|
|
185
|
+
*/
|
|
186
|
+
function runEffect(fn) {
|
|
187
|
+
currentEffect = fn;
|
|
188
|
+
try {
|
|
189
|
+
fn();
|
|
190
|
+
} finally {
|
|
191
|
+
currentEffect = null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Schedule a batched DOM update
|
|
197
|
+
*/
|
|
198
|
+
function scheduleUpdate(callback) {
|
|
199
|
+
if (pendingUpdate) return;
|
|
200
|
+
|
|
201
|
+
pendingUpdate = true;
|
|
202
|
+
queueMicrotask(() => {
|
|
203
|
+
pendingUpdate = false;
|
|
204
|
+
|
|
205
|
+
// Run all pending effects
|
|
206
|
+
const effects = [...pendingEffects];
|
|
207
|
+
pendingEffects.clear();
|
|
208
|
+
effects.forEach(effect => {
|
|
209
|
+
try {
|
|
210
|
+
runEffect(effect);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
console.error('[SenangStart] Effect error:', e);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (callback) callback();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* SenangStart Actions - Expression Evaluator
|
|
222
|
+
* Safe evaluation of expressions within component scope
|
|
223
|
+
*
|
|
224
|
+
* @module evaluator
|
|
225
|
+
*/
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Create a function to evaluate an expression within a component scope
|
|
229
|
+
*/
|
|
230
|
+
function createEvaluator(expression, scope, element) {
|
|
231
|
+
const { data, $refs, $store } = scope;
|
|
232
|
+
|
|
233
|
+
// Magic properties
|
|
234
|
+
const magics = {
|
|
235
|
+
$data: data,
|
|
236
|
+
$store: $store,
|
|
237
|
+
$el: element,
|
|
238
|
+
$my: element,
|
|
239
|
+
$refs: $refs,
|
|
240
|
+
$dispatch: (name, detail = {}) => {
|
|
241
|
+
element.dispatchEvent(new CustomEvent(name, {
|
|
242
|
+
detail,
|
|
243
|
+
bubbles: true,
|
|
244
|
+
cancelable: true
|
|
245
|
+
}));
|
|
246
|
+
},
|
|
247
|
+
$watch: (prop, callback) => {
|
|
248
|
+
const watchEffect = () => {
|
|
249
|
+
const value = data[prop];
|
|
250
|
+
callback(value);
|
|
251
|
+
};
|
|
252
|
+
if (data.__subscribers) {
|
|
253
|
+
if (!data.__subscribers.has(prop)) {
|
|
254
|
+
data.__subscribers.set(prop, new Set());
|
|
255
|
+
}
|
|
256
|
+
data.__subscribers.get(prop).add(watchEffect);
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
$nextTick: (fn) => {
|
|
260
|
+
queueMicrotask(fn);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Build the function with data properties spread as local variables
|
|
265
|
+
const dataKeys = Object.keys(typeof data === 'object' && data !== null ? data : {});
|
|
266
|
+
const magicKeys = Object.keys(magics);
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const fn = new Function(
|
|
270
|
+
...dataKeys,
|
|
271
|
+
...magicKeys,
|
|
272
|
+
`with(this) { return (${expression}); }`
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
return function() {
|
|
276
|
+
const dataValues = dataKeys.map(k => data[k]);
|
|
277
|
+
const magicValues = magicKeys.map(k => magics[k]);
|
|
278
|
+
return fn.call(data, ...dataValues, ...magicValues);
|
|
279
|
+
};
|
|
280
|
+
} catch (e) {
|
|
281
|
+
console.error(`[SenangStart] Failed to parse expression: ${expression}`, e);
|
|
282
|
+
return () => undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Create a function to execute a statement (not return value)
|
|
288
|
+
*/
|
|
289
|
+
function createExecutor(expression, scope, element) {
|
|
290
|
+
const { data, $refs, $store } = scope;
|
|
291
|
+
|
|
292
|
+
const magics = {
|
|
293
|
+
$data: data,
|
|
294
|
+
$store: $store,
|
|
295
|
+
$el: element,
|
|
296
|
+
$my: element,
|
|
297
|
+
$refs: $refs,
|
|
298
|
+
$dispatch: (name, detail = {}) => {
|
|
299
|
+
element.dispatchEvent(new CustomEvent(name, {
|
|
300
|
+
detail,
|
|
301
|
+
bubbles: true,
|
|
302
|
+
cancelable: true
|
|
303
|
+
}));
|
|
304
|
+
},
|
|
305
|
+
$watch: (prop, callback) => {
|
|
306
|
+
if (data.__subscribers) {
|
|
307
|
+
if (!data.__subscribers.has(prop)) {
|
|
308
|
+
data.__subscribers.set(prop, new Set());
|
|
309
|
+
}
|
|
310
|
+
data.__subscribers.get(prop).add(() => callback(data[prop]));
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
$nextTick: (fn) => {
|
|
314
|
+
queueMicrotask(fn);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const dataKeys = Object.keys(typeof data === 'object' && data !== null ? data : {});
|
|
319
|
+
const magicKeys = Object.keys(magics);
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const fn = new Function(
|
|
323
|
+
...dataKeys,
|
|
324
|
+
...magicKeys,
|
|
325
|
+
`with(this) { ${expression} }`
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
return function() {
|
|
329
|
+
const dataValues = dataKeys.map(k => data[k]);
|
|
330
|
+
const magicValues = magicKeys.map(k => magics[k]);
|
|
331
|
+
return fn.call(data, ...dataValues, ...magicValues);
|
|
332
|
+
};
|
|
333
|
+
} catch (e) {
|
|
334
|
+
console.error(`[SenangStart] Failed to parse expression: ${expression}`, e);
|
|
335
|
+
return () => {};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* SenangStart Actions - Attribute Handlers
|
|
341
|
+
* Handlers for basic ss-* attributes
|
|
342
|
+
*
|
|
343
|
+
* @module handlers/attributes
|
|
344
|
+
*/
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Handle ss-transition animations
|
|
349
|
+
*/
|
|
350
|
+
function handleTransition(el, show, originalDisplay) {
|
|
351
|
+
if (show) {
|
|
352
|
+
// Enter transition
|
|
353
|
+
el.classList.add('ss-enter-from');
|
|
354
|
+
el.classList.add('ss-enter-active');
|
|
355
|
+
el.style.display = originalDisplay;
|
|
356
|
+
|
|
357
|
+
requestAnimationFrame(() => {
|
|
358
|
+
el.classList.remove('ss-enter-from');
|
|
359
|
+
el.classList.add('ss-enter-to');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const onEnd = () => {
|
|
363
|
+
el.classList.remove('ss-enter-active', 'ss-enter-to');
|
|
364
|
+
el.removeEventListener('transitionend', onEnd);
|
|
365
|
+
};
|
|
366
|
+
el.addEventListener('transitionend', onEnd);
|
|
367
|
+
} else {
|
|
368
|
+
// Leave transition
|
|
369
|
+
el.classList.add('ss-leave-from');
|
|
370
|
+
el.classList.add('ss-leave-active');
|
|
371
|
+
|
|
372
|
+
requestAnimationFrame(() => {
|
|
373
|
+
el.classList.remove('ss-leave-from');
|
|
374
|
+
el.classList.add('ss-leave-to');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const onEnd = () => {
|
|
378
|
+
el.style.display = 'none';
|
|
379
|
+
el.classList.remove('ss-leave-active', 'ss-leave-to');
|
|
380
|
+
el.removeEventListener('transitionend', onEnd);
|
|
381
|
+
};
|
|
382
|
+
el.addEventListener('transitionend', onEnd);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Attribute handlers map
|
|
388
|
+
*/
|
|
389
|
+
const attributeHandlers = {
|
|
390
|
+
/**
|
|
391
|
+
* ss-text: Set element's innerText
|
|
392
|
+
*/
|
|
393
|
+
'ss-text': (el, expr, scope) => {
|
|
394
|
+
const update = () => {
|
|
395
|
+
const evaluator = createEvaluator(expr, scope, el);
|
|
396
|
+
el.innerText = evaluator() ?? '';
|
|
397
|
+
};
|
|
398
|
+
runEffect(update);
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* ss-html: Set element's innerHTML
|
|
403
|
+
*/
|
|
404
|
+
'ss-html': (el, expr, scope) => {
|
|
405
|
+
const update = () => {
|
|
406
|
+
const evaluator = createEvaluator(expr, scope, el);
|
|
407
|
+
el.innerHTML = evaluator() ?? '';
|
|
408
|
+
};
|
|
409
|
+
runEffect(update);
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* ss-show: Toggle visibility
|
|
414
|
+
*/
|
|
415
|
+
'ss-show': (el, expr, scope) => {
|
|
416
|
+
const originalDisplay = el.style.display || '';
|
|
417
|
+
|
|
418
|
+
const update = () => {
|
|
419
|
+
const evaluator = createEvaluator(expr, scope, el);
|
|
420
|
+
const show = !!evaluator();
|
|
421
|
+
|
|
422
|
+
if (el.hasAttribute('ss-transition')) {
|
|
423
|
+
handleTransition(el, show, originalDisplay);
|
|
424
|
+
} else {
|
|
425
|
+
el.style.display = show ? originalDisplay : 'none';
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
runEffect(update);
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* ss-model: Two-way binding for inputs
|
|
433
|
+
*/
|
|
434
|
+
'ss-model': (el, expr, scope) => {
|
|
435
|
+
const { data } = scope;
|
|
436
|
+
|
|
437
|
+
// Determine input type
|
|
438
|
+
const isCheckbox = el.type === 'checkbox';
|
|
439
|
+
const isRadio = el.type === 'radio';
|
|
440
|
+
const isSelect = el.tagName === 'SELECT';
|
|
441
|
+
|
|
442
|
+
// Set initial value
|
|
443
|
+
const setInitialValue = () => {
|
|
444
|
+
const evaluator = createEvaluator(expr, scope, el);
|
|
445
|
+
const value = evaluator();
|
|
446
|
+
|
|
447
|
+
if (isCheckbox) {
|
|
448
|
+
el.checked = !!value;
|
|
449
|
+
} else if (isRadio) {
|
|
450
|
+
el.checked = el.value === value;
|
|
451
|
+
} else if (isSelect) {
|
|
452
|
+
el.value = value ?? '';
|
|
453
|
+
} else {
|
|
454
|
+
el.value = value ?? '';
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
runEffect(setInitialValue);
|
|
459
|
+
|
|
460
|
+
// Listen for changes
|
|
461
|
+
const eventType = isCheckbox || isRadio ? 'change' : 'input';
|
|
462
|
+
el.addEventListener(eventType, () => {
|
|
463
|
+
let newValue;
|
|
464
|
+
|
|
465
|
+
if (isCheckbox) {
|
|
466
|
+
newValue = el.checked;
|
|
467
|
+
} else if (isRadio) {
|
|
468
|
+
if (el.checked) newValue = el.value;
|
|
469
|
+
else return; // Don't update if not checked
|
|
470
|
+
} else {
|
|
471
|
+
newValue = el.value;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Set the value on the data object
|
|
475
|
+
data[expr] = newValue;
|
|
476
|
+
});
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* ss-ref: Register element reference
|
|
481
|
+
*/
|
|
482
|
+
'ss-ref': (el, name, scope) => {
|
|
483
|
+
scope.$refs[name] = el;
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* ss-init: Run initialization code
|
|
488
|
+
*/
|
|
489
|
+
'ss-init': (el, expr, scope) => {
|
|
490
|
+
const executor = createExecutor(expr, scope, el);
|
|
491
|
+
executor();
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* ss-effect: Run reactive effect
|
|
496
|
+
*/
|
|
497
|
+
'ss-effect': (el, expr, scope) => {
|
|
498
|
+
const update = () => {
|
|
499
|
+
const executor = createExecutor(expr, scope, el);
|
|
500
|
+
executor();
|
|
501
|
+
};
|
|
502
|
+
runEffect(update);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* SenangStart Actions - Bind Handler
|
|
508
|
+
* Handler for ss-bind:[attr] dynamic attribute binding
|
|
509
|
+
*
|
|
510
|
+
* @module handlers/bind
|
|
511
|
+
*/
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Handle ss-bind:[attr] dynamically
|
|
516
|
+
*/
|
|
517
|
+
function handleBind(el, attrName, expr, scope) {
|
|
518
|
+
const attr = attrName.replace('ss-bind:', '');
|
|
519
|
+
|
|
520
|
+
const update = () => {
|
|
521
|
+
const evaluator = createEvaluator(expr, scope, el);
|
|
522
|
+
const value = evaluator();
|
|
523
|
+
|
|
524
|
+
if (attr === 'class') {
|
|
525
|
+
if (typeof value === 'string') {
|
|
526
|
+
el.className = value;
|
|
527
|
+
} else if (typeof value === 'object') {
|
|
528
|
+
// Object syntax: { 'class-name': condition }
|
|
529
|
+
Object.entries(value).forEach(([className, condition]) => {
|
|
530
|
+
el.classList.toggle(className, !!condition);
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
} else if (attr === 'style') {
|
|
534
|
+
if (typeof value === 'string') {
|
|
535
|
+
el.style.cssText = value;
|
|
536
|
+
} else if (typeof value === 'object') {
|
|
537
|
+
Object.assign(el.style, value);
|
|
538
|
+
}
|
|
539
|
+
} else if (value === false || value === null || value === undefined) {
|
|
540
|
+
el.removeAttribute(attr);
|
|
541
|
+
} else if (value === true) {
|
|
542
|
+
el.setAttribute(attr, '');
|
|
543
|
+
} else {
|
|
544
|
+
el.setAttribute(attr, value);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
runEffect(update);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* SenangStart Actions - Event Handler
|
|
553
|
+
* Handler for ss-on:[event] with modifiers
|
|
554
|
+
*
|
|
555
|
+
* @module handlers/events
|
|
556
|
+
*/
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Handle ss-on:[event] dynamically
|
|
561
|
+
*/
|
|
562
|
+
function handleEvent(el, attrName, expr, scope) {
|
|
563
|
+
const parts = attrName.replace('ss-on:', '').split('.');
|
|
564
|
+
const eventName = parts[0];
|
|
565
|
+
const modifiers = parts.slice(1);
|
|
566
|
+
|
|
567
|
+
const executor = createExecutor(expr, scope, el);
|
|
568
|
+
|
|
569
|
+
const handler = (event) => {
|
|
570
|
+
// Handle modifiers
|
|
571
|
+
if (modifiers.includes('prevent')) event.preventDefault();
|
|
572
|
+
if (modifiers.includes('stop')) event.stopPropagation();
|
|
573
|
+
if (modifiers.includes('self') && event.target !== el) return;
|
|
574
|
+
if (modifiers.includes('once')) {
|
|
575
|
+
el.removeEventListener(eventName, handler);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// For keyboard events, check key modifiers
|
|
579
|
+
if (event instanceof KeyboardEvent) {
|
|
580
|
+
const key = event.key.toLowerCase();
|
|
581
|
+
const keyModifiers = ['enter', 'escape', 'tab', 'space', 'up', 'down', 'left', 'right'];
|
|
582
|
+
const hasKeyModifier = modifiers.some(m => keyModifiers.includes(m));
|
|
583
|
+
|
|
584
|
+
if (hasKeyModifier) {
|
|
585
|
+
const keyMap = {
|
|
586
|
+
'enter': 'enter',
|
|
587
|
+
'escape': 'escape',
|
|
588
|
+
'tab': 'tab',
|
|
589
|
+
'space': ' ',
|
|
590
|
+
'up': 'arrowup',
|
|
591
|
+
'down': 'arrowdown',
|
|
592
|
+
'left': 'arrowleft',
|
|
593
|
+
'right': 'arrowright'
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const shouldFire = modifiers.some(m => keyMap[m] === key);
|
|
597
|
+
if (!shouldFire) return;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Execute the expression with $event available
|
|
602
|
+
scope.data.$event = event;
|
|
603
|
+
executor();
|
|
604
|
+
delete scope.data.$event;
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// Special window/document events
|
|
608
|
+
if (modifiers.includes('window')) {
|
|
609
|
+
window.addEventListener(eventName, handler);
|
|
610
|
+
} else if (modifiers.includes('document')) {
|
|
611
|
+
document.addEventListener(eventName, handler);
|
|
612
|
+
} else {
|
|
613
|
+
el.addEventListener(eventName, handler);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* SenangStart Actions - Directive Handlers
|
|
619
|
+
* Handlers for ss-for and ss-if template directives
|
|
620
|
+
*
|
|
621
|
+
* @module handlers/directives
|
|
622
|
+
*/
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
// Forward declaration - will be set by walker.js
|
|
626
|
+
let walkFn = null;
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Set the walk function reference (to avoid circular imports)
|
|
630
|
+
*/
|
|
631
|
+
function setWalkFunction(fn) {
|
|
632
|
+
walkFn = fn;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Handle ss-for directive
|
|
637
|
+
*/
|
|
638
|
+
function handleFor(templateEl, expr, scope) {
|
|
639
|
+
// Parse expression: "item in items" or "(item, index) in items"
|
|
640
|
+
const match = expr.match(/^\s*(?:\(([^,]+),\s*([^)]+)\)|([^\s]+))\s+in\s+(.+)$/);
|
|
641
|
+
if (!match) {
|
|
642
|
+
console.error('[SenangStart] Invalid ss-for expression:', expr);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const itemName = match[1] || match[3];
|
|
647
|
+
const indexName = match[2] || 'index';
|
|
648
|
+
const arrayExpr = match[4];
|
|
649
|
+
|
|
650
|
+
const parent = templateEl.parentNode;
|
|
651
|
+
const anchor = document.createComment(`ss-for: ${expr}`);
|
|
652
|
+
parent.insertBefore(anchor, templateEl);
|
|
653
|
+
templateEl.remove();
|
|
654
|
+
|
|
655
|
+
let currentNodes = [];
|
|
656
|
+
let lastItemsJSON = '';
|
|
657
|
+
|
|
658
|
+
const update = () => {
|
|
659
|
+
const evaluator = createEvaluator(arrayExpr, scope, templateEl);
|
|
660
|
+
const items = evaluator() || [];
|
|
661
|
+
|
|
662
|
+
// Check if items actually changed (shallow comparison)
|
|
663
|
+
const itemsJSON = JSON.stringify(items);
|
|
664
|
+
if (itemsJSON === lastItemsJSON) {
|
|
665
|
+
return; // No change, skip re-render
|
|
666
|
+
}
|
|
667
|
+
lastItemsJSON = itemsJSON;
|
|
668
|
+
|
|
669
|
+
// Remove old nodes
|
|
670
|
+
currentNodes.forEach(node => node.remove());
|
|
671
|
+
currentNodes = [];
|
|
672
|
+
|
|
673
|
+
// Create new nodes
|
|
674
|
+
items.forEach((item, index) => {
|
|
675
|
+
const clone = templateEl.content.cloneNode(true);
|
|
676
|
+
const nodes = Array.from(clone.childNodes).filter(n => n.nodeType === 1);
|
|
677
|
+
|
|
678
|
+
// Create child scope with item and index - use parent scope's data for non-item properties
|
|
679
|
+
const itemScope = {
|
|
680
|
+
data: createReactive({
|
|
681
|
+
...scope.data,
|
|
682
|
+
[itemName]: item,
|
|
683
|
+
[indexName]: index
|
|
684
|
+
}, () => {}),
|
|
685
|
+
$refs: scope.$refs,
|
|
686
|
+
$store: scope.$store,
|
|
687
|
+
parentData: scope.data // Keep reference to parent data
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
nodes.forEach(node => {
|
|
691
|
+
parent.insertBefore(node, anchor);
|
|
692
|
+
currentNodes.push(node);
|
|
693
|
+
if (walkFn) walkFn(node, itemScope);
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
runEffect(update);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Handle ss-if directive
|
|
703
|
+
*/
|
|
704
|
+
function handleIf(templateEl, expr, scope) {
|
|
705
|
+
const parent = templateEl.parentNode;
|
|
706
|
+
const anchor = document.createComment(`ss-if: ${expr}`);
|
|
707
|
+
parent.insertBefore(anchor, templateEl);
|
|
708
|
+
templateEl.remove();
|
|
709
|
+
|
|
710
|
+
let currentNodes = [];
|
|
711
|
+
|
|
712
|
+
const update = () => {
|
|
713
|
+
const evaluator = createEvaluator(expr, scope, templateEl);
|
|
714
|
+
const condition = !!evaluator();
|
|
715
|
+
|
|
716
|
+
// Remove old nodes
|
|
717
|
+
currentNodes.forEach(node => node.remove());
|
|
718
|
+
currentNodes = [];
|
|
719
|
+
|
|
720
|
+
if (condition) {
|
|
721
|
+
const clone = templateEl.content.cloneNode(true);
|
|
722
|
+
const nodes = Array.from(clone.childNodes).filter(n => n.nodeType === 1);
|
|
723
|
+
|
|
724
|
+
nodes.forEach(node => {
|
|
725
|
+
parent.insertBefore(node, anchor);
|
|
726
|
+
currentNodes.push(node);
|
|
727
|
+
if (walkFn) walkFn(node, scope);
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
runEffect(update);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* SenangStart Actions - DOM Walker
|
|
737
|
+
* Recursive DOM traversal and initialization
|
|
738
|
+
*
|
|
739
|
+
* @module walker
|
|
740
|
+
*/
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
// Store references
|
|
744
|
+
let registeredDataFactories$1 = {};
|
|
745
|
+
let stores$1 = {};
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Set external references (called from index.js)
|
|
749
|
+
*/
|
|
750
|
+
function setReferences(dataFactories, storeRef) {
|
|
751
|
+
registeredDataFactories$1 = dataFactories;
|
|
752
|
+
stores$1 = storeRef;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Walk the DOM tree and initialize SenangStart attributes
|
|
757
|
+
*/
|
|
758
|
+
function walk(el, parentScope = null) {
|
|
759
|
+
// Skip non-element nodes
|
|
760
|
+
if (el.nodeType !== 1) return;
|
|
761
|
+
|
|
762
|
+
// Skip if element has ss-ignore
|
|
763
|
+
if (el.hasAttribute('ss-ignore')) return;
|
|
764
|
+
|
|
765
|
+
let scope = parentScope;
|
|
766
|
+
|
|
767
|
+
// Check for ss-data to create new scope
|
|
768
|
+
if (el.hasAttribute('ss-data')) {
|
|
769
|
+
const dataExpr = el.getAttribute('ss-data').trim();
|
|
770
|
+
let initialData = {};
|
|
771
|
+
|
|
772
|
+
if (dataExpr) {
|
|
773
|
+
// Check if it's a registered data factory
|
|
774
|
+
if (registeredDataFactories$1[dataExpr]) {
|
|
775
|
+
initialData = registeredDataFactories$1[dataExpr]();
|
|
776
|
+
} else {
|
|
777
|
+
// Parse as object literal - use Function for safety
|
|
778
|
+
try {
|
|
779
|
+
initialData = new Function(`return (${dataExpr})`)();
|
|
780
|
+
} catch (e) {
|
|
781
|
+
console.error('[SenangStart] Failed to parse ss-data:', dataExpr, e);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
scope = {
|
|
787
|
+
data: createReactive(initialData, () => {}),
|
|
788
|
+
$refs: {},
|
|
789
|
+
$store: stores$1
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
// Store scope on element for MutationObserver
|
|
793
|
+
el.__ssScope = scope;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// If no scope, skip processing directives
|
|
797
|
+
if (!scope) {
|
|
798
|
+
// Still walk children in case they have ss-data
|
|
799
|
+
Array.from(el.children).forEach(child => walk(child, null));
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Handle ss-for (must be on template element)
|
|
804
|
+
if (el.tagName === 'TEMPLATE' && el.hasAttribute('ss-for')) {
|
|
805
|
+
handleFor(el, el.getAttribute('ss-for'), scope);
|
|
806
|
+
return; // ss-for handles its own children
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Handle ss-if (must be on template element)
|
|
810
|
+
if (el.tagName === 'TEMPLATE' && el.hasAttribute('ss-if')) {
|
|
811
|
+
handleIf(el, el.getAttribute('ss-if'), scope);
|
|
812
|
+
return; // ss-if handles its own children
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Process all ss-* attributes
|
|
816
|
+
const attributes = Array.from(el.attributes);
|
|
817
|
+
|
|
818
|
+
for (const attr of attributes) {
|
|
819
|
+
const name = attr.name;
|
|
820
|
+
const value = attr.value;
|
|
821
|
+
|
|
822
|
+
// Skip ss-data (already processed) and ss-describe (metadata only)
|
|
823
|
+
if (name === 'ss-data' || name === 'ss-describe') continue;
|
|
824
|
+
|
|
825
|
+
// Handle standard attributes
|
|
826
|
+
if (attributeHandlers[name]) {
|
|
827
|
+
attributeHandlers[name](el, value, scope);
|
|
828
|
+
}
|
|
829
|
+
// Handle ss-bind:[attr]
|
|
830
|
+
else if (name.startsWith('ss-bind:')) {
|
|
831
|
+
handleBind(el, name, value, scope);
|
|
832
|
+
}
|
|
833
|
+
// Handle ss-on:[event]
|
|
834
|
+
else if (name.startsWith('ss-on:')) {
|
|
835
|
+
handleEvent(el, name, value, scope);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Remove ss-cloak after processing
|
|
840
|
+
if (el.hasAttribute('ss-cloak')) {
|
|
841
|
+
el.removeAttribute('ss-cloak');
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Walk children
|
|
845
|
+
Array.from(el.children).forEach(child => walk(child, scope));
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Set the walk function reference in directives module
|
|
849
|
+
setWalkFunction(walk);
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* SenangStart Actions - MutationObserver
|
|
853
|
+
* Watch for dynamically added elements
|
|
854
|
+
*
|
|
855
|
+
* @module observer
|
|
856
|
+
*/
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Set up observer for dynamically added elements
|
|
861
|
+
*/
|
|
862
|
+
function setupObserver() {
|
|
863
|
+
const observer = new MutationObserver((mutations) => {
|
|
864
|
+
for (const mutation of mutations) {
|
|
865
|
+
for (const node of mutation.addedNodes) {
|
|
866
|
+
if (node.nodeType === 1) {
|
|
867
|
+
// Check if node or any ancestor already has scope
|
|
868
|
+
let current = node;
|
|
869
|
+
let parentScope = null;
|
|
870
|
+
|
|
871
|
+
while (current.parentElement) {
|
|
872
|
+
current = current.parentElement;
|
|
873
|
+
if (current.__ssScope) {
|
|
874
|
+
parentScope = current.__ssScope;
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
walk(node, parentScope);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
observer.observe(document.body, {
|
|
886
|
+
childList: true,
|
|
887
|
+
subtree: true
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
return observer;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* SenangStart Actions v0.1.0
|
|
895
|
+
* Declarative UI framework for humans and AI agents
|
|
896
|
+
*
|
|
897
|
+
* @license MIT
|
|
898
|
+
* @author Bookklik
|
|
899
|
+
* @module senangstart-actions
|
|
900
|
+
*/
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
// =========================================================================
|
|
904
|
+
// CSS Injection for ss-cloak
|
|
905
|
+
// =========================================================================
|
|
906
|
+
const style = document.createElement('style');
|
|
907
|
+
style.textContent = '[ss-cloak] { display: none !important; }';
|
|
908
|
+
document.head.appendChild(style);
|
|
909
|
+
|
|
910
|
+
// =========================================================================
|
|
911
|
+
// Internal State
|
|
912
|
+
// =========================================================================
|
|
913
|
+
const registeredDataFactories = {}; // SenangStart.data() registrations
|
|
914
|
+
const stores = {}; // SenangStart.store() registrations
|
|
915
|
+
|
|
916
|
+
// Set references in walker module
|
|
917
|
+
setReferences(registeredDataFactories, stores);
|
|
918
|
+
|
|
919
|
+
// =========================================================================
|
|
920
|
+
// Public API
|
|
921
|
+
// =========================================================================
|
|
922
|
+
|
|
923
|
+
const SenangStart = {
|
|
924
|
+
/**
|
|
925
|
+
* Register a reusable data component
|
|
926
|
+
* @param {string} name - Component name
|
|
927
|
+
* @param {Function} factory - Factory function returning data object
|
|
928
|
+
*/
|
|
929
|
+
data(name, factory) {
|
|
930
|
+
if (typeof factory !== 'function') {
|
|
931
|
+
console.error('[SenangStart] data() requires a factory function');
|
|
932
|
+
return this;
|
|
933
|
+
}
|
|
934
|
+
registeredDataFactories[name] = factory;
|
|
935
|
+
return this;
|
|
936
|
+
},
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Register a global reactive store
|
|
940
|
+
* @param {string} name - Store name
|
|
941
|
+
* @param {Object} data - Store data object
|
|
942
|
+
*/
|
|
943
|
+
store(name, data) {
|
|
944
|
+
if (typeof data !== 'object') {
|
|
945
|
+
console.error('[SenangStart] store() requires an object');
|
|
946
|
+
return this;
|
|
947
|
+
}
|
|
948
|
+
stores[name] = createReactive(data, () => {});
|
|
949
|
+
return this;
|
|
950
|
+
},
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Manually initialize a DOM tree
|
|
954
|
+
* @param {Element} root - Root element to initialize
|
|
955
|
+
*/
|
|
956
|
+
init(root = document.body) {
|
|
957
|
+
walk(root, null);
|
|
958
|
+
return this;
|
|
959
|
+
},
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Start the framework
|
|
963
|
+
*/
|
|
964
|
+
start() {
|
|
965
|
+
if (document.readyState === 'loading') {
|
|
966
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
967
|
+
this.init();
|
|
968
|
+
setupObserver();
|
|
969
|
+
});
|
|
970
|
+
} else {
|
|
971
|
+
this.init();
|
|
972
|
+
setupObserver();
|
|
973
|
+
}
|
|
974
|
+
return this;
|
|
975
|
+
},
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Version
|
|
979
|
+
*/
|
|
980
|
+
version: '0.1.0'
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
// =========================================================================
|
|
984
|
+
// Auto-start
|
|
985
|
+
// =========================================================================
|
|
986
|
+
|
|
987
|
+
// Expose globally
|
|
988
|
+
if (typeof window !== 'undefined') {
|
|
989
|
+
window.SenangStart = SenangStart;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Auto-start when script loads
|
|
993
|
+
SenangStart.start();
|
|
994
|
+
|
|
995
|
+
return SenangStart;
|
|
996
|
+
|
|
997
|
+
})();
|