fusee-framework 1.2.1 → 1.4.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.
@@ -2,9 +2,77 @@ import { signal, effect, batch } from './signal.js'
2
2
  import { evaluateExpression } from './evaluator.js'
3
3
  import { compileNode } from './compiler.js'
4
4
 
5
+ const customDirectives = new Map()
6
+
7
+ export function directive(name, definition) {
8
+ if (typeof name !== 'string' || !name.trim()) {
9
+ console.error('[framework] directive() requires a non-empty name')
10
+ return
11
+ }
12
+ if (!definition || typeof definition !== 'object') {
13
+ console.error(`[framework] directive("${name}") requires a definition object`)
14
+ return
15
+ }
16
+ customDirectives.set(name, definition)
17
+ }
18
+
19
+ export function processCustomDirectives(el, context, effects) {
20
+ if (el.nodeType !== 1 || !el.hasAttribute || !el.attributes) return
21
+
22
+ for (const attr of [...el.attributes]) {
23
+ const match = attr.name.match(/^f-([a-zA-Z0-9-]+)(?::([a-zA-Z0-9-]+))?(?:\.(.+))?$/)
24
+
25
+ if (!match) continue
26
+
27
+ const directiveName = match[1]
28
+ const arg = match[2] || null
29
+ const modifiersStr = match[3]
30
+ const modifiers = modifiersStr ? modifiersStr.split('.') : []
31
+
32
+ const definition = customDirectives.get(directiveName)
33
+ if (!definition) {
34
+ continue
35
+ }
36
+
37
+ el.removeAttribute(attr.name)
38
+
39
+ const value = evaluateExpression(attr.value, context)
40
+ const expression = attr.value
41
+
42
+ const binding = {
43
+ value,
44
+ expression,
45
+ arg,
46
+ modifiers
47
+ }
48
+
49
+ if (typeof definition.mounted === 'function') {
50
+ definition.mounted(el, binding)
51
+ }
52
+
53
+ if (typeof definition.updated === 'function') {
54
+ effects.push(effect(() => {
55
+ const newValue = evaluateExpression(expression, context)
56
+
57
+ if (!Object.is(binding.value, newValue)) {
58
+ binding.value = newValue
59
+ definition.updated(el, binding)
60
+ }
61
+ }))
62
+ }
63
+
64
+ if (typeof definition.unmounted === 'function') {
65
+ effects.push(() => {
66
+ definition.unmounted(el, binding)
67
+ })
68
+ }
69
+ }
70
+ }
71
+
5
72
  export function processDirectives(root, context, components, effects) {
6
73
  if (processFor(root, context, components, effects)) return true
7
74
  if (processIf(root, context, components, effects)) return true
75
+ if (processIs(root, context, components, effects)) return true
8
76
 
9
77
  processShow(root, context, effects)
10
78
  processModel(root, context, effects)
@@ -12,6 +80,7 @@ export function processDirectives(root, context, components, effects) {
12
80
  processHtml(root, context, effects)
13
81
  processText(root, context, effects)
14
82
  processEvents(root, context, effects)
83
+ processCustomDirectives(root, context, effects)
15
84
  processCloak(root)
16
85
 
17
86
  return false
@@ -275,6 +344,110 @@ export function processIf(el, context, components, effects) {
275
344
  return true
276
345
  }
277
346
 
347
+ export function processIs(el, context, components, effects) {
348
+ if (el.nodeType !== 1 || !el.hasAttribute('f-is')) return false
349
+
350
+ // 1. Prepare directive state
351
+ const expr = el.getAttribute('f-is')
352
+ const keepAlive = processKeepAlive(el)
353
+
354
+ el.removeAttribute('f-is')
355
+
356
+ const anchor = document.createComment('f-keepAlive-anchor')
357
+ el.parentNode.insertBefore(anchor, el)
358
+
359
+ // Keep a clone to reuse attributes (props/@events) for every dynamic instance
360
+ const templateNode = el.cloneNode(true)
361
+ el.remove()
362
+
363
+ const cache = new Map()
364
+ let activeKey = null
365
+ let activeCleanup = []
366
+
367
+ // 2. Reactive loop
368
+ effects.push(effect(() => {
369
+ const rawValue = evaluateExpression(expr, context)
370
+
371
+ // Skip if same component is already active
372
+ if (rawValue === activeKey) return
373
+
374
+ // 3. Handle exiting component
375
+ if (activeKey !== null) {
376
+ const currentEntry = cache.get(activeKey)
377
+ if (currentEntry) {
378
+ if (keepAlive) {
379
+ currentEntry.node.remove()
380
+ } else {
381
+ for (const cleanup of activeCleanup) if (typeof cleanup === 'function') cleanup()
382
+ currentEntry.node.remove()
383
+ cache.delete(activeKey)
384
+ activeCleanup = []
385
+ }
386
+ }
387
+ }
388
+
389
+ if (!rawValue) {
390
+ activeKey = null
391
+ return
392
+ }
393
+
394
+ // 4. Handle entering component
395
+ let entry = cache.get(rawValue)
396
+
397
+ if (entry && keepAlive) {
398
+ // Restore from cache
399
+ anchor.parentNode.insertBefore(entry.node, anchor.nextSibling)
400
+ activeCleanup = entry.cleanup
401
+ } else {
402
+ // Create new instance
403
+ let ComponentFn = typeof rawValue === 'string' ? components[rawValue] : rawValue
404
+
405
+ if (typeof ComponentFn !== 'function') {
406
+ console.warn(`[framework] f-is: Component "${rawValue}" not found or invalid.`)
407
+ return
408
+ }
409
+
410
+ const compEl = templateNode.cloneNode(true)
411
+ compEl._isDynamicComponent = true
412
+
413
+ const tempName = '__dynamic_component__'
414
+ compEl.setAttribute('data-component', tempName)
415
+
416
+ anchor.parentNode.insertBefore(compEl, anchor.nextSibling)
417
+
418
+ const childCleanup = []
419
+ const dynamicComponents = { ...components, [tempName]: ComponentFn }
420
+
421
+ // Trigger the internal compiler to bind props and events
422
+ compileNode(compEl, context, dynamicComponents, childCleanup)
423
+
424
+ entry = { node: compEl, cleanup: childCleanup }
425
+ cache.set(rawValue, entry)
426
+ activeCleanup = childCleanup
427
+ }
428
+
429
+ activeKey = rawValue
430
+ }))
431
+
432
+ // Cleanup all cached components when this directive is destroyed
433
+ effects.push(() => {
434
+ for (const entry of cache.values()) {
435
+ for (const c of entry.cleanup) if (typeof c === 'function') c()
436
+ entry.node.remove()
437
+ }
438
+ cache.clear()
439
+ })
440
+
441
+ return true
442
+ }
443
+
444
+ function processKeepAlive(el) {
445
+ if (el.nodeType !== 1) return false
446
+ const hasKeepAlive = el.hasAttribute('f-keepAlive')
447
+ if (hasKeepAlive) el.removeAttribute('f-keepAlive')
448
+ return hasKeepAlive
449
+ }
450
+
278
451
  export function processShow(el, context, effects) {
279
452
  if (el.nodeType !== 1 || !el.hasAttribute('f-show')) return
280
453
 
@@ -304,6 +477,7 @@ export function processHtml(el, context, effects) {
304
477
  el.innerHTML = String(val ?? '')
305
478
  }))
306
479
  }
480
+
307
481
  export function processEvents(el, context, effects) {
308
482
  if (el.nodeType !== 1 || !el.attributes) return
309
483
 
@@ -317,6 +491,39 @@ export function processEvents(el, context, effects) {
317
491
 
318
492
  el.removeAttribute(attr.name)
319
493
 
494
+ let timeoutId
495
+ let lastRun = 0
496
+ let throttleTimeoutId
497
+
498
+ let debounceMs = 0
499
+ let throttleMs = 0
500
+
501
+ const parseDuration = (idx) => {
502
+ const next = modifiers[idx + 1]
503
+ if (next && /^\d/.test(next)) {
504
+ if (next.endsWith('ms')) return parseInt(next) || 250
505
+ if (next.endsWith('s')) return (parseFloat(next) || 0.25) * 1000
506
+ }
507
+ return 250 // Default
508
+ }
509
+
510
+ const debounceIdx = modifiers.indexOf('debounce')
511
+ if (debounceIdx > -1) debounceMs = parseDuration(debounceIdx)
512
+
513
+ const throttleIdx = modifiers.indexOf('throttle')
514
+ if (throttleIdx > -1) throttleMs = parseDuration(throttleIdx)
515
+
516
+ const executeLogic = (e) => {
517
+ batch(() => {
518
+ const potentialFn = context[expr]
519
+ if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
520
+ potentialFn(e)
521
+ } else {
522
+ evaluateExpression(expr, context, { $event: e }, false)
523
+ }
524
+ })
525
+ }
526
+
320
527
  const handler = (e) => {
321
528
  if (modifiers.includes('prevent')) e.preventDefault()
322
529
  if (modifiers.includes('stop')) e.stopPropagation()
@@ -336,14 +543,33 @@ export function processEvents(el, context, effects) {
336
543
  }
337
544
  }
338
545
 
339
- batch(() => {
340
- const potentialFn = context[expr]
341
- if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
342
- potentialFn(e)
546
+ // Throttle logic (ensures execution at most once every X ms)
547
+ if (throttleMs > 0) {
548
+ const now = Date.now()
549
+ const remaining = throttleMs - (now - lastRun)
550
+
551
+ if (remaining <= 0) {
552
+ clearTimeout(throttleTimeoutId)
553
+ lastRun = now
554
+ executeLogic(e)
343
555
  } else {
344
- evaluateExpression(expr, context, { $event: e }, false)
556
+ // Standard throttle with trailing edge support
557
+ clearTimeout(throttleTimeoutId)
558
+ throttleTimeoutId = setTimeout(() => {
559
+ lastRun = Date.now()
560
+ executeLogic(e)
561
+ }, remaining)
345
562
  }
346
- })
563
+ return
564
+ }
565
+
566
+ // Debounce logic (waits for X ms of inactivity)
567
+ if (debounceMs > 0) {
568
+ clearTimeout(timeoutId)
569
+ timeoutId = setTimeout(() => executeLogic(e), debounceMs)
570
+ } else {
571
+ executeLogic(e)
572
+ }
347
573
  }
348
574
 
349
575
  const targetNode = modifiers.includes('window') ? window : (modifiers.includes('document') ? document : el)
@@ -356,6 +582,8 @@ export function processEvents(el, context, effects) {
356
582
  if (effects) {
357
583
  effects.push(() => {
358
584
  targetNode.removeEventListener(eventName, handler, options)
585
+ if (timeoutId) clearTimeout(timeoutId)
586
+ if (throttleTimeoutId) clearTimeout(throttleTimeoutId)
359
587
  })
360
588
  }
361
589
  }
@@ -51,6 +51,64 @@ export function signal(initialValue) {
51
51
  }
52
52
 
53
53
  accessor.isSignal = true
54
+
55
+ if (Array.isArray(initialValue)) {
56
+ accessor.push = (...items) => {
57
+ const next = [...accessor(), ...items]
58
+ accessor(next)
59
+ return next.length
60
+ }
61
+
62
+ accessor.pop = () => {
63
+ const arr = accessor()
64
+ if (arr.length === 0) return undefined
65
+ const item = arr[arr.length - 1]
66
+ accessor(arr.slice(0, -1))
67
+ return item
68
+ }
69
+
70
+ accessor.shift = () => {
71
+ const arr = accessor()
72
+ if (arr.length === 0) return undefined
73
+ const item = arr[0]
74
+ accessor(arr.slice(1))
75
+ return item
76
+ }
77
+
78
+ accessor.unshift = (...items) => {
79
+ const next = [...items, ...accessor()]
80
+ accessor(next)
81
+ return next.length
82
+ }
83
+
84
+ accessor.splice = (start, deleteCount, ...items) => {
85
+ const arr = [...accessor()]
86
+ const removed = arr.splice(start, deleteCount, ...items)
87
+ accessor(arr)
88
+ return removed
89
+ }
90
+
91
+ accessor.remove = (predicate) => {
92
+ accessor(accessor().filter((item, i) => !predicate(item, i)))
93
+ }
94
+
95
+ accessor.clear = () => {
96
+ accessor([])
97
+ }
98
+
99
+ accessor.sort = (compareFn) => {
100
+ const next = [...accessor()].sort(compareFn)
101
+ accessor(next)
102
+ return accessor
103
+ }
104
+
105
+ accessor.reverse = () => {
106
+ const next = [...accessor()].reverse()
107
+ accessor(next)
108
+ return accessor
109
+ }
110
+ }
111
+
54
112
  return accessor
55
113
  }
56
114
 
@@ -1,8 +1,10 @@
1
1
  // ─── Framework Public API ─────────────────────────────────────────────────────
2
2
  export { signal, computed, effect, batch, untrack, inspect } from './core/signal.js'
3
- export { defineComponent, onMount, onUnmount, parseSlots, provide, inject } from './core/component.js'
3
+ export { defineComponent, defineAsyncComponent, onMount, onUnmount, parseSlots, provide, inject, getCurrentInstance } from './core/component.js'
4
4
  export { createRouter, navigate, mountOutlet } from './router/router.js'
5
5
  export { mountTemplate } from './core/compiler.js'
6
6
 
7
- // Emit is a placeholder - actual emit function is created per component
7
+ export { defineComposable, assertSetupContext } from './core/composable.js'
8
+ export { directive } from './core/directives.js'
9
+
8
10
  export const emit = () => console.warn('[framework] emit() can only be used inside component setup()')
@@ -70,3 +70,14 @@ export declare function inject<T>(key: string): T | null
70
70
  export declare function defineComponent<TProps = EmptyProps>(
71
71
  options: ComponentOptions<TProps>
72
72
  ): ComponentFactory<TProps>
73
+
74
+ export type AsyncComponentLoader<TProps = ComponentProps> = () => Promise<ComponentFactory<TProps> | { default: ComponentFactory<TProps> } | Record<string, any>>
75
+
76
+ export type AsyncComponentOptions<TProps = ComponentProps> = {
77
+ loader: AsyncComponentLoader<TProps>
78
+ loadingComponent?: ComponentFactory<any>
79
+ }
80
+
81
+ export declare function defineAsyncComponent<TProps = ComponentProps>(
82
+ loaderOrOptions: AsyncComponentLoader<TProps> | AsyncComponentOptions<TProps>
83
+ ): ComponentFactory<TProps>
@@ -0,0 +1,14 @@
1
+ export interface Composable<T = any> {
2
+ (...args: any[]): T
3
+ readonly _isComposable: true
4
+ }
5
+
6
+ declare global {
7
+ interface Composable<T = any> {
8
+ (...args: any[]): T
9
+ readonly _isComposable: true
10
+ }
11
+ }
12
+
13
+ export function defineComposable<T>(fn: (...args: any[]) => T): Composable<T>
14
+ export function assertSetupContext(name?: string): any
@@ -25,8 +25,30 @@ export function processIf(
25
25
  effects: (() => void)[]
26
26
  ): void
27
27
 
28
+ export function processIs(
29
+ root: HTMLElement,
30
+ context: ComponentResult,
31
+ components: Record<string, ComponentFactory>,
32
+ effects: (() => void)[]
33
+ ): void
34
+
28
35
  export function processShow(root: HTMLElement, context: ComponentResult, effects: (() => void)[]): void
29
36
  export function processHtml(root: HTMLElement, context: ComponentResult, effects: (() => void)[]): void
30
37
  export function processText(root: HTMLElement, context: ComponentResult, effects: (() => void)[]): void
31
38
  export function processEvents(root: HTMLElement, context: ComponentResult): void
32
39
  export function processCloak(root: HTMLElement): void
40
+
41
+ export interface DirectiveBinding<V = any> {
42
+ value: V
43
+ expression: string
44
+ arg: string | null
45
+ modifiers: string[]
46
+ }
47
+
48
+ export interface DirectiveHooks<V = any> {
49
+ mounted?: (el: HTMLElement, binding: DirectiveBinding<V>) => void
50
+ updated?: (el: HTMLElement, binding: DirectiveBinding<V>) => void
51
+ unmounted?: (el: HTMLElement, binding: DirectiveBinding<V>) => void
52
+ }
53
+
54
+ export function directive<V = any>(name: string, hooks: DirectiveHooks<V>): void
@@ -1,16 +1,17 @@
1
- // ─── Fusée Framework Types ───────────────────────────────────────────────────
2
-
3
- // Public API
4
1
  export * from './signal'
5
2
  export * from './component'
3
+ export * from './composable'
6
4
  export * from './router'
7
5
  export * from './compiler'
6
+ export * from './directives'
8
7
 
9
8
  declare global {
10
9
  type Signal<T = any> = import('./signal').Signal<T>
11
10
  type Computed<T = any> = import('./signal').Computed<T>
12
11
  type SignalAccessor<T> = import('./signal').SignalAccessor<T>
12
+ type Composable<T extends (...args: any[]) => any> = import('./composable').Composable<T>
13
+ type DirectiveBinding<V = any> = import('./directives').DirectiveBinding<V>
14
+ type DirectiveHooks<V = any> = import('./directives').DirectiveHooks<V>
13
15
  }
14
16
 
15
- // Re-export as namespace for convenience
16
17
  export as namespace Fusee
@@ -6,13 +6,25 @@ export type SignalAccessor<T> = {
6
6
  isSignal: boolean;
7
7
  }
8
8
 
9
+ export type ArraySignalAccessor<T extends any[]> = SignalAccessor<T> & {
10
+ push(...items: T[number][]): number;
11
+ pop(): T[number] | undefined;
12
+ shift(): T[number] | undefined;
13
+ unshift(...items: T[number][]): number;
14
+ splice(start: number, deleteCount: number, ...items: T[number][]): T[number][];
15
+ remove(predicate: (item: T[number], index: number) => boolean): void;
16
+ clear(): void;
17
+ sort(compareFn?: (a: T[number], b: T[number]) => number): ArraySignalAccessor<T>;
18
+ reverse(): ArraySignalAccessor<T>;
19
+ }
20
+
9
21
  export type ComputedAccessor<T> = {
10
22
  (): T;
11
23
  isSignal: boolean;
12
24
  readonly: true;
13
25
  }
14
26
 
15
- export type Signal<T = any> = SignalAccessor<T>
27
+ export type Signal<T = any> = T extends any[] ? ArraySignalAccessor<T> : SignalAccessor<T>
16
28
  export type Computed<T = any> = ComputedAccessor<T>
17
29
 
18
30
  type EffectRunner = {
@@ -20,7 +32,8 @@ type EffectRunner = {
20
32
  deps: Set<Set<EffectRunner>>;
21
33
  }
22
34
 
23
- export declare function signal<T>(initialValue: T): Signal<T>
35
+ export declare function signal<T extends any[]>(initialValue: T): ArraySignalAccessor<T>
36
+ export declare function signal<T>(initialValue: T): SignalAccessor<T>
24
37
  export declare function effect(fn: () => void): () => void
25
38
  export declare function computed<T>(fn: () => T): Computed<T>
26
39
  export declare function batch<T>(fn: () => T): T
package/index.html CHANGED
@@ -160,6 +160,139 @@
160
160
  opacity: 0.3;
161
161
  cursor: not-allowed;
162
162
  }
163
+
164
+ /* ── Composables demo ──────────────────────────────────────────── */
165
+ .demo-card {
166
+ background: #13131d;
167
+ border: 1px solid #2a2a3a;
168
+ border-radius: 12px;
169
+ padding: 1.5rem;
170
+ margin-bottom: 1.5rem;
171
+ }
172
+
173
+ .demo-card h2 {
174
+ font-size: 1rem;
175
+ font-weight: 600;
176
+ color: #e8e8f0;
177
+ margin-bottom: 0.35rem;
178
+ }
179
+
180
+ .demo-desc {
181
+ font-size: 0.8rem;
182
+ color: #555 !important;
183
+ margin-bottom: 1rem !important;
184
+ }
185
+
186
+ .counter-display {
187
+ display: flex;
188
+ flex-direction: column;
189
+ align-items: center;
190
+ margin-bottom: 1rem;
191
+ }
192
+
193
+ .big-num {
194
+ font-size: 3.5rem;
195
+ font-weight: 700;
196
+ line-height: 1;
197
+ transition: color 0.2s;
198
+ }
199
+
200
+ .big-num.even {
201
+ color: #8b8bff;
202
+ }
203
+
204
+ .big-num.odd {
205
+ color: #ff3333;
206
+ }
207
+
208
+ .derived-label {
209
+ font-size: 0.8rem;
210
+ color: #555;
211
+ margin-top: 0.25rem;
212
+ }
213
+
214
+ .btn-row {
215
+ display: flex;
216
+ gap: 0.5rem;
217
+ justify-content: center;
218
+ margin-bottom: 0.75rem;
219
+ }
220
+
221
+ .cmp-btn {
222
+ padding: 0.45rem 1.1rem;
223
+ border-radius: 6px;
224
+ border: 1px solid #2a2a3a;
225
+ background: #0f0f1a;
226
+ color: #e8e8f0;
227
+ cursor: pointer;
228
+ font-size: 1rem;
229
+ transition: background 0.15s;
230
+ }
231
+
232
+ .cmp-btn:hover {
233
+ background: #1f1f30;
234
+ }
235
+
236
+ .cmp-btn.plus {
237
+ border-color: #8b8bff;
238
+ color: #8b8bff;
239
+ }
240
+
241
+ .cmp-btn.minus {
242
+ border-color: #ff3333;
243
+ color: #ff3333;
244
+ }
245
+
246
+ .cmp-btn.reset {
247
+ border-color: #555;
248
+ color: #888;
249
+ }
250
+
251
+ .badge {
252
+ display: inline-block;
253
+ padding: 0.2rem 0.65rem;
254
+ border-radius: 999px;
255
+ font-size: 0.75rem;
256
+ font-weight: 600;
257
+ }
258
+
259
+ .badge-blue {
260
+ background: rgba(139, 139, 255, 0.15);
261
+ color: #8b8bff;
262
+ }
263
+
264
+ .badge-red {
265
+ background: rgba(255, 51, 51, 0.12);
266
+ color: #ff3333;
267
+ }
268
+
269
+ .mouse-display {
270
+ display: flex;
271
+ gap: 2rem;
272
+ font-size: 1.1rem;
273
+ color: #aaa;
274
+ margin-bottom: 0.5rem;
275
+ }
276
+
277
+ .mouse-display strong {
278
+ color: #e8e8f0;
279
+ }
280
+
281
+ .cmp-input {
282
+ width: 100%;
283
+ padding: 0.5rem 0.75rem;
284
+ background: #0f0f1a;
285
+ border: 1px solid #2a2a3a;
286
+ border-radius: 6px;
287
+ color: #e8e8f0;
288
+ font-size: 0.95rem;
289
+ outline: none;
290
+ transition: border-color 0.15s;
291
+ }
292
+
293
+ .cmp-input:focus {
294
+ border-color: #8b8bff;
295
+ }
163
296
  </style>
164
297
  </head>
165
298
 
@@ -167,10 +300,12 @@
167
300
  <nav>
168
301
  <a href="#/">Home</a>
169
302
  <a href="#/about">About</a>
170
- <a href="#/test-for">Test f-for</a>
303
+ <a href="#/test-for">Loops Test</a>
304
+ <a href="#/dynamic" style="color: #ec4899;">Dynamic Components</a>
305
+ <a href="#/composables" style="color: #34d399;">Composables</a>
171
306
  </nav>
172
307
  <div id="app"></div>
173
- <script type="module" src="./app/main.js"></script>
308
+ <script type="module" src="./app/main.ts"></script>
174
309
  </body>
175
310
 
176
311
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fusee-framework",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Fusée - Signals-first JS Framework | Peak Performance",
5
5
  "main": "framework/index.js",
6
6
  "types": "framework/types/index.d.ts",