fusee-framework 1.5.3 → 1.6.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 CHANGED
@@ -1,5 +1,5 @@
1
1
  # Fusée Framework 🚀
2
- **v1.5.3 — Signals-First JS Framework | Atomic Reactivity | Peak Performance**
2
+ **v1.6.0 — Signals-First JS Framework | Atomic Reactivity | Peak Performance**
3
3
 
4
4
  Fusée is a custom, high-performance fine-grained reactive JavaScript framework built for speed and simplicity. It features a recursive, non-greedy compiler, a signals-based reactivity engine, Dependency Injection for components and a comprehensive CLI for instant application scaffolding.
5
5
 
@@ -80,7 +80,7 @@ export const Welcome = defineComponent({
80
80
 
81
81
  Fusée is built with a test-driven mindset to ensure the reliability of its reactivity engine and component lifecycle.
82
82
 
83
- - **Total Tests:** `360`
83
+ - **Total Tests:** `385`
84
84
  - **Status:** `All Passed`
85
85
  - **Framework:** [Vitest](https://vitest.dev/)
86
86
  - **Environment:** JSDOM (Browser simulation)
@@ -106,6 +106,7 @@ npx vitest
106
106
  * **Component System:** Props, Slots, Async Components, and Lifecycle Hooks.
107
107
  * **Dependency Injection:** Nested Provide/Inject and Shadowing.
108
108
  * **Directives:** `f-if`, `f-for`, `f-model`, and Event Modifiers.
109
+ * **Events:** Event delegation and native event handling.
109
110
  * **Memory Safety:** Explicit checks for memory leaks and automatic effect disposal.
110
111
  * **Type Safety:** Comprehensive TypeScript definitions and type tests.
111
112
  * **Integration Tests:** Advanced module combinations and edge cases.
@@ -23,7 +23,7 @@ export const Counter = defineComponent({
23
23
  initialValue: { type: String, default: '0' },
24
24
  parentTitle: { type: String, default: '' }
25
25
  },
26
- setup(props: CounterProps): CounterResult {
26
+ setup(props: CounterProps, { emit }: { emit: (eventName: string, ...args: any[]) => void }): CounterResult {
27
27
  const count = signal<number>(Number(props.initialValue) || 0)
28
28
  const multiplier = signal<number>(1)
29
29
  inspect(count, multiplier)
package/app/main.ts CHANGED
@@ -33,19 +33,19 @@ const routes: Route[] = [
33
33
  loadingComponent: Loading
34
34
  })
35
35
  },
36
- {
37
- path: '/test-for',
36
+ {
37
+ path: '/test-for',
38
38
  component: defineAsyncComponent({
39
39
  loader: () => import('./routes/TestFor.ts'),
40
40
  loadingComponent: Loading
41
- })
41
+ })
42
42
  },
43
- {
44
- path: '/dynamic',
43
+ {
44
+ path: '/dynamic',
45
45
  component: defineAsyncComponent({
46
46
  loader: () => import('./routes/TestDynamic.ts'),
47
47
  loadingComponent: Loading
48
- })
48
+ })
49
49
  },
50
50
  {
51
51
  path: '/composables',
@@ -1,6 +1,7 @@
1
1
  import { signal, effect, batch } from './signal.js'
2
2
  import { evaluateExpression } from './evaluator.js'
3
3
  import { compileNode } from './compiler.js'
4
+ import { registerDelegatedEvent, createEventHandler, isDelegatedEvent } from './event-delegation.js'
4
5
 
5
6
  const customDirectives = new Map()
6
7
 
@@ -469,8 +470,9 @@ export function processEvents(el, context, effects) {
469
470
  if (el.nodeType !== 1 || !el.attributes) return
470
471
 
471
472
  for (const attr of [...el.attributes]) {
472
- if (attr.name.startsWith('@')) {
473
- const fullEventName = attr.name.slice(1)
473
+ // Handle native events: on:click, on:scroll, on:custom-event
474
+ if (attr.name.startsWith('on:')) {
475
+ const fullEventName = attr.name.slice(3) // Remove 'on:' prefix
474
476
  const parts = fullEventName.split('.')
475
477
  const eventName = parts[0]
476
478
  const modifiers = parts.slice(1)
@@ -491,7 +493,7 @@ export function processEvents(el, context, effects) {
491
493
  if (next.endsWith('ms')) return parseInt(next) || 250
492
494
  if (next.endsWith('s')) return (parseFloat(next) || 0.25) * 1000
493
495
  }
494
- return 250
496
+ return 250
495
497
  }
496
498
 
497
499
  const debounceIdx = modifiers.indexOf('debounce')
@@ -569,6 +571,158 @@ export function processEvents(el, context, effects) {
569
571
  if (throttleTimeoutId) clearTimeout(throttleTimeoutId)
570
572
  })
571
573
  }
574
+ continue
575
+ }
576
+
577
+ // Handle delegated events: @click, @input, etc.
578
+ if (attr.name.startsWith('@')) {
579
+ const fullEventName = attr.name.slice(1) // Remove '@' prefix
580
+ const parts = fullEventName.split('.')
581
+ const eventName = parts[0]
582
+ const modifiers = parts.slice(1)
583
+ const expr = attr.value
584
+
585
+ el.removeAttribute(attr.name)
586
+
587
+ const targetNode = modifiers.includes('window') ? window : (modifiers.includes('document') ? document : null)
588
+
589
+ // Window/document events are always native (can't delegate to window/document from document)
590
+ if (targetNode) {
591
+ // Native handler for window/document events
592
+ const handler = createEventHandler(
593
+ typeof context[expr] === 'function' && !context[expr].isSignal
594
+ ? context[expr]
595
+ : expr,
596
+ modifiers,
597
+ context,
598
+ expr
599
+ )
600
+
601
+ const options = {
602
+ once: modifiers.includes('once'),
603
+ capture: modifiers.includes('capture') || modifiers.includes('away')
604
+ }
605
+
606
+ targetNode.addEventListener(eventName, handler, options)
607
+ if (effects) {
608
+ effects.push(() => {
609
+ targetNode.removeEventListener(eventName, handler, options)
610
+ })
611
+ }
612
+ } else if (isDelegatedEvent(eventName) && !modifiers.includes('capture') && !modifiers.includes('away')) {
613
+ // Use event delegation for supported events
614
+ const potentialFn = context[expr]
615
+ const isFunction = typeof potentialFn === 'function' && !potentialFn.isSignal
616
+
617
+ const wrappedHandler = createEventHandler(
618
+ isFunction ? potentialFn : expr,
619
+ modifiers,
620
+ context,
621
+ expr
622
+ )
623
+
624
+ const cleanup = registerDelegatedEvent(el, eventName, wrappedHandler, {
625
+ modifiers,
626
+ context,
627
+ expr
628
+ })
629
+
630
+ if (effects) {
631
+ effects.push(cleanup)
632
+ }
633
+ } else {
634
+ // Fallback to native for non-delegated events or when capture/away modifiers are used
635
+ let timeoutId
636
+ let lastRun = 0
637
+ let throttleTimeoutId
638
+
639
+ let debounceMs = 0
640
+ let throttleMs = 0
641
+
642
+ const parseDuration = (idx) => {
643
+ const next = modifiers[idx + 1]
644
+ if (next && /^\d/.test(next)) {
645
+ if (next.endsWith('ms')) return parseInt(next) || 250
646
+ if (next.endsWith('s')) return (parseFloat(next) || 0.25) * 1000
647
+ }
648
+ return 250
649
+ }
650
+
651
+ const debounceIdx = modifiers.indexOf('debounce')
652
+ if (debounceIdx > -1) debounceMs = parseDuration(debounceIdx)
653
+
654
+ const throttleIdx = modifiers.indexOf('throttle')
655
+ if (throttleIdx > -1) throttleMs = parseDuration(throttleIdx)
656
+
657
+ const executeLogic = (e) => {
658
+ batch(() => {
659
+ const potentialFn = context[expr]
660
+ if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
661
+ potentialFn(e)
662
+ } else {
663
+ evaluateExpression(expr, context, { $event: e }, false)
664
+ }
665
+ })
666
+ }
667
+
668
+ const handler = (e) => {
669
+ if (modifiers.includes('prevent')) e.preventDefault()
670
+ if (modifiers.includes('stop')) e.stopPropagation()
671
+ if (modifiers.includes('self') && e.target !== el) return
672
+
673
+ if (e instanceof KeyboardEvent) {
674
+ const key = e.key.toLowerCase()
675
+ const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
676
+ if (keyModifiers.length > 0) {
677
+ const match = keyModifiers.some(m => {
678
+ if (m === 'escape') return key === 'escape' || key === 'esc'
679
+ if (m === 'space') return key === ' ' || key === 'spacebar'
680
+ return key === m
681
+ })
682
+ if (!match) return
683
+ }
684
+ }
685
+
686
+ if (throttleMs > 0) {
687
+ const now = Date.now()
688
+ const remaining = throttleMs - (now - lastRun)
689
+
690
+ if (remaining <= 0) {
691
+ clearTimeout(throttleTimeoutId)
692
+ lastRun = now
693
+ executeLogic(e)
694
+ } else {
695
+ clearTimeout(throttleTimeoutId)
696
+ throttleTimeoutId = setTimeout(() => {
697
+ lastRun = Date.now()
698
+ executeLogic(e)
699
+ }, remaining)
700
+ }
701
+ return
702
+ }
703
+
704
+ if (debounceMs > 0) {
705
+ clearTimeout(timeoutId)
706
+ timeoutId = setTimeout(() => executeLogic(e), debounceMs)
707
+ } else {
708
+ executeLogic(e)
709
+ }
710
+ }
711
+
712
+ const options = {
713
+ once: modifiers.includes('once'),
714
+ capture: modifiers.includes('capture') || modifiers.includes('away')
715
+ }
716
+
717
+ el.addEventListener(eventName, handler, options)
718
+ if (effects) {
719
+ effects.push(() => {
720
+ el.removeEventListener(eventName, handler, options)
721
+ if (timeoutId) clearTimeout(timeoutId)
722
+ if (throttleTimeoutId) clearTimeout(throttleTimeoutId)
723
+ })
724
+ }
725
+ }
572
726
  }
573
727
  }
574
728
  }
@@ -0,0 +1,172 @@
1
+ import { batch } from './signal.js'
2
+ import { evaluateExpression } from './evaluator.js'
3
+
4
+ export const DELEGATED_EVENTS = new Set([
5
+ 'beforeinput',
6
+ 'click',
7
+ 'dblclick',
8
+ 'contextmenu',
9
+ 'focusin',
10
+ 'focusout',
11
+ 'input',
12
+ 'keydown',
13
+ 'keyup',
14
+ 'mousedown',
15
+ 'mousemove',
16
+ 'mouseout',
17
+ 'mouseover',
18
+ 'mouseup',
19
+ 'pointerdown',
20
+ 'pointermove',
21
+ 'pointerout',
22
+ 'pointerover',
23
+ 'pointerup',
24
+ 'touchend',
25
+ 'touchmove',
26
+ 'touchstart'
27
+ ])
28
+
29
+ const delegatedListeners = new Map()
30
+ const documentListeners = new Map()
31
+
32
+ export function isDelegatedEvent(eventName) {
33
+ return DELEGATED_EVENTS.has(eventName.toLowerCase())
34
+ }
35
+
36
+ function ensureDocumentListener(eventType) {
37
+ if (documentListeners.has(eventType)) {
38
+ return
39
+ }
40
+
41
+ const handler = (e) => {
42
+ const listeners = delegatedListeners.get(eventType)
43
+ if (!listeners || listeners.size === 0) return
44
+
45
+ let target = e.target
46
+ while (target && target !== document.body && target !== document.documentElement) {
47
+ for (const entry of listeners) {
48
+ if (entry.element === target) {
49
+ entry.handler(e)
50
+ }
51
+ }
52
+ target = target.parentNode
53
+ }
54
+ }
55
+
56
+ document.addEventListener(eventType, handler, true)
57
+ documentListeners.set(eventType, handler)
58
+ }
59
+
60
+ export function registerDelegatedEvent(element, eventType, handler, options = {}) {
61
+ const { modifiers = [], context, expr } = options
62
+ const normalizedEvent = eventType.toLowerCase()
63
+
64
+ ensureDocumentListener(normalizedEvent)
65
+
66
+ if (!delegatedListeners.has(normalizedEvent))
67
+ delegatedListeners.set(normalizedEvent, new Set())
68
+
69
+ const entry = {
70
+ element,
71
+ handler,
72
+ modifiers,
73
+ context,
74
+ expr,
75
+ cleanup: null
76
+ }
77
+
78
+ delegatedListeners.get(normalizedEvent).add(entry)
79
+
80
+ return function cleanup() {
81
+ const listeners = delegatedListeners.get(normalizedEvent)
82
+ if (listeners) {
83
+ listeners.delete(entry)
84
+ if (listeners.size === 0) {
85
+ const docHandler = documentListeners.get(normalizedEvent)
86
+ if (docHandler) {
87
+ document.removeEventListener(normalizedEvent, docHandler, true)
88
+ documentListeners.delete(normalizedEvent)
89
+ }
90
+ delegatedListeners.delete(normalizedEvent)
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ export function createEventHandler(handler, modifiers, context, expr) {
97
+ let timeoutId
98
+ let lastRun = 0
99
+ let throttleTimeoutId
100
+
101
+ let debounceMs = 0
102
+ let throttleMs = 0
103
+
104
+ const parseDuration = (idx) => {
105
+ const next = modifiers[idx + 1]
106
+ if (next && /^\d/.test(next)) {
107
+ if (next.endsWith('ms')) return parseInt(next) || 250
108
+ if (next.endsWith('s')) return (parseFloat(next) || 0.25) * 1000
109
+ }
110
+ return 250
111
+ }
112
+
113
+ const debounceIdx = modifiers.indexOf('debounce')
114
+ if (debounceIdx > -1) debounceMs = parseDuration(debounceIdx)
115
+
116
+ const throttleIdx = modifiers.indexOf('throttle')
117
+ if (throttleIdx > -1) throttleMs = parseDuration(throttleIdx)
118
+
119
+ const executeLogic = (e) => {
120
+ batch(() => {
121
+ if (typeof handler === 'function') {
122
+ handler(e)
123
+ } else {
124
+ evaluateExpression(expr, context, { $event: e }, false)
125
+ }
126
+ })
127
+ }
128
+
129
+ return (e) => {
130
+ if (modifiers.includes('prevent')) e.preventDefault()
131
+ if (modifiers.includes('stop')) e.stopPropagation()
132
+ if (modifiers.includes('self') && e.target !== e.currentTarget) return
133
+
134
+ if (e instanceof KeyboardEvent) {
135
+ const key = e.key.toLowerCase()
136
+ const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
137
+ if (keyModifiers.length > 0) {
138
+ const match = keyModifiers.some(m => {
139
+ if (m === 'escape') return key === 'escape' || key === 'esc'
140
+ if (m === 'space') return key === ' ' || key === 'spacebar'
141
+ return key === m
142
+ })
143
+ if (!match) return
144
+ }
145
+ }
146
+
147
+ if (throttleMs > 0) {
148
+ const now = Date.now()
149
+ const remaining = throttleMs - (now - lastRun)
150
+
151
+ if (remaining <= 0) {
152
+ clearTimeout(throttleTimeoutId)
153
+ lastRun = now
154
+ executeLogic(e)
155
+ } else {
156
+ clearTimeout(throttleTimeoutId)
157
+ throttleTimeoutId = setTimeout(() => {
158
+ lastRun = Date.now()
159
+ executeLogic(e)
160
+ }, remaining)
161
+ }
162
+ return
163
+ }
164
+
165
+ if (debounceMs > 0) {
166
+ clearTimeout(timeoutId)
167
+ timeoutId = setTimeout(() => executeLogic(e), debounceMs)
168
+ } else {
169
+ executeLogic(e)
170
+ }
171
+ }
172
+ }
@@ -104,7 +104,6 @@ export function effect(fn) {
104
104
  for (const dep of run.deps) dep.delete(run)
105
105
  run.deps.clear()
106
106
 
107
- // Cleanup all nested effects when parent stops
108
107
  for (const childCleanup of nestedEffects) {
109
108
  childCleanup()
110
109
  }
@@ -1,9 +1,5 @@
1
- // ─── Component Types ─────────────────────────────────────────────────────────
2
-
3
1
  export type ComponentProps = Record<string, any>
4
-
5
2
  export type EmptyProps = Record<string, never>
6
-
7
3
  export type PropSchema = string[] | Record<string, PropConfig>
8
4
 
9
5
  export type PropConfig = {
@@ -0,0 +1,90 @@
1
+ // ─── Event Delegation Types ────────────────────────────────────────────────
2
+
3
+ /**
4
+ * Set of standard events that support delegation.
5
+ * These events bubble and are commonly used in UI interactions.
6
+ */
7
+ export const DELEGATED_EVENTS: Set<string>
8
+
9
+ /**
10
+ * Check if an event type is delegated.
11
+ * @param eventName - The event name to check (e.g., 'click', 'keydown')
12
+ * @returns True if the event can be delegated
13
+ */
14
+ export function isDelegatedEvent(eventName: string): boolean
15
+
16
+ /**
17
+ * Options for registering a delegated event.
18
+ */
19
+ export interface DelegatedEventOptions {
20
+ /** Event modifiers (e.g., 'prevent', 'stop', 'debounce', 'throttle') */
21
+ modifiers?: string[]
22
+ /** Component context for evaluating expressions */
23
+ context?: Record<string, any>
24
+ /** Expression string to evaluate */
25
+ expr?: string
26
+ }
27
+
28
+ /**
29
+ * Register a delegated event handler for an element.
30
+ * Creates a single document-level listener if not already present.
31
+ *
32
+ * @param element - The DOM element to register
33
+ * @param eventType - The event type (e.g., 'click', 'input')
34
+ * @param handler - The event handler function
35
+ * @param options - Optional configuration
36
+ * @returns Cleanup function to remove the handler
37
+ */
38
+ export function registerDelegatedEvent(
39
+ element: Element,
40
+ eventType: string,
41
+ handler: (event: Event) => void,
42
+ options?: DelegatedEventOptions
43
+ ): () => void
44
+
45
+ /**
46
+ * Create a wrapped event handler with modifier support.
47
+ * Supports: prevent, stop, self, debounce, throttle, keyboard modifiers.
48
+ *
49
+ * @param handler - The original handler (function or expression)
50
+ * @param modifiers - Array of modifier names
51
+ * @param context - Component context for evaluating expressions
52
+ * @param expr - Expression string (when handler is a string)
53
+ * @returns Wrapped handler function
54
+ */
55
+ export function createEventHandler(
56
+ handler: Function | string,
57
+ modifiers: string[],
58
+ context: Record<string, any>,
59
+ expr: string
60
+ ): (event: Event) => void
61
+
62
+ /**
63
+ * Event handler syntax preference.
64
+ * Use @ prefix for delegated events (optimized, single document listener).
65
+ * Use on: prefix for native events (individual element listeners, full event control).
66
+ *
67
+ * @example
68
+ * // Delegated - efficient for many elements
69
+ * <button @click="handleClick">Click</button>
70
+ *
71
+ * @example
72
+ * // Native - full control including stopPropagation
73
+ * <button on:click="handleClick">Click</button>
74
+ *
75
+ * @example
76
+ * // With modifiers
77
+ * <form @submit.prevent="handleSubmit">Submit</form>
78
+ * <input @keydown.enter="handleEnter" />
79
+ * <input @input.debounce="handleInput" />
80
+ */
81
+ export interface EventHandlerSyntax {
82
+ /** Delegated event: @click, @input, @keydown, etc. */
83
+ delegated: `@${string}`
84
+ /** Native event: on:click, on:input, on:custom, etc. */
85
+ native: `on:${string}`
86
+ }
87
+
88
+ declare global {
89
+ const isDelegatedEvent: typeof import('./event-delegation').isDelegatedEvent
90
+ }
@@ -5,6 +5,7 @@ export * from './router'
5
5
  export * from './compiler'
6
6
  export * from './directives'
7
7
  export * from './store'
8
+ export * from './event-delegation'
8
9
 
9
10
  declare global {
10
11
  type Signal<T = any> = import('./signal').Signal<T>
@@ -13,7 +14,9 @@ declare global {
13
14
  type Composable<T extends (...args: any[]) => any> = import('./composable').Composable<T>
14
15
  type DirectiveBinding<V = any> = import('./directives').DirectiveBinding<V>
15
16
  type DirectiveHooks<V = any> = import('./directives').DirectiveHooks<V>
17
+ type DelegatedEventOptions = import('./event-delegation').DelegatedEventOptions
16
18
  const defineStore: typeof import('./store').defineStore
19
+ const isDelegatedEvent: typeof import('./event-delegation').isDelegatedEvent
17
20
  }
18
21
 
19
22
  export as namespace Fusee
package/index.html CHANGED
@@ -546,6 +546,69 @@
546
546
  margin: 0;
547
547
  overflow-x: auto;
548
548
  }
549
+
550
+ /* ── Event Delegation Demo Styles ───────────────────────────────── */
551
+ .test-events h1 {
552
+ color: #ff3333;
553
+ text-shadow: 0 0 20px rgba(255, 51, 51, 0.4);
554
+ }
555
+
556
+ .test-events .subtitle {
557
+ color: #aaa;
558
+ font-size: 0.9rem;
559
+ margin-bottom: 2rem;
560
+ }
561
+
562
+ .button-grid {
563
+ display: grid;
564
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
565
+ gap: 0.75rem;
566
+ margin-bottom: 1rem;
567
+ }
568
+
569
+ .demo-btn {
570
+ padding: 0.75rem 1rem;
571
+ border: 2px solid #3a3a5a;
572
+ background: #0f0f1a;
573
+ color: #e8e8f0;
574
+ border-radius: 8px;
575
+ cursor: pointer;
576
+ font-weight: 600;
577
+ transition: all 0.15s;
578
+ }
579
+
580
+ .demo-btn:hover {
581
+ background: #2a2a3a;
582
+ transform: translateY(-2px);
583
+ }
584
+
585
+ .demo-btn.native {
586
+ border-color: #34d399;
587
+ color: #34d399;
588
+ }
589
+
590
+ .demo-btn.native:hover {
591
+ background: rgba(52, 211, 153, 0.1);
592
+ }
593
+
594
+ .counter-display {
595
+ font-size: 1.1rem;
596
+ color: #aaa;
597
+ }
598
+
599
+ .counter-display strong {
600
+ color: #e8e8f0;
601
+ font-size: 1.3rem;
602
+ }
603
+
604
+ .stop-demo {
605
+ cursor: pointer;
606
+ transition: background 0.15s;
607
+ }
608
+
609
+ .stop-demo:hover {
610
+ background: #252535;
611
+ }
549
612
  </style>
550
613
  </head>
551
614
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fusee-framework",
3
- "version": "1.5.3",
3
+ "version": "1.6.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",