fusee-framework 1.5.2 โ†’ 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.2 โ€” 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',
@@ -9,29 +9,67 @@ export const Home = defineComponent({
9
9
  setup() {
10
10
  const title = signal('Welcome!')
11
11
  inspect(title)
12
-
12
+
13
+ const isActive = signal(false)
14
+ const isDisabled = signal(false)
15
+ const isLarge = signal(false)
16
+ const basicClasses = computed(() => ({
17
+ active: isActive(),
18
+ disabled: isDisabled(),
19
+ 'is-large': isLarge()
20
+ }))
21
+
22
+ const toggleActive = () => isActive(!isActive())
23
+ const toggleDisabled = () => isDisabled(!isDisabled())
24
+ const toggleLarge = () => isLarge(!isLarge())
25
+
13
26
  return {
14
27
  title,
15
28
  changeTitle: () => title('Changed Title!'),
16
-
29
+
30
+ isActive,
31
+ isDisabled,
32
+ isLarge,
33
+ basicClasses,
34
+ toggleActive,
35
+ toggleDisabled,
36
+ toggleLarge,
37
+
17
38
  // ๐Ÿงช Emit Event Handlers
18
39
  handleCounterChange: (value) => console.log('๐Ÿ“ข Counter changed to:', value),
19
40
  handleCounterReset: () => console.log('๐Ÿ”„ Counter reset requested!'),
20
41
  handleCustomEvent: (count, multiplier) => console.log('๐ŸŽฏ Custom event:', { count, multiplier }),
21
-
42
+
22
43
  // ๐Ÿงช Emit Test Handlers
23
44
  handleSimpleClick: () => console.log('โœ… SIMPLE CLICK received from child!'),
24
45
  handleClickWithData: (count, label, timestamp) => console.log('โœ… CLICK WITH DATA:', { count, label, timestamp }),
25
46
  handleObjectEvent: (data) => console.log('โœ… OBJECT EVENT received:', data),
26
-
47
+
27
48
  template: `
28
49
  <div class="page">
29
50
  <h1>{{ title.toUpperCase() }}</h1>
30
51
  <p>This is an SPA built with my own JavaScript custom framework!</p>
31
52
  <p>Made with passion by Sebastian ศ˜omu</p>
32
-
53
+
33
54
  <button @click="changeTitle" class="primary-btn" style="margin-bottom: 20px;">Change App Title</button>
34
-
55
+
56
+ <!-- ๐ŸŽจ f-classList Demo -->
57
+ <h3 style="margin-top: 40px; margin-bottom: 20px; color: #f59e0b;">๐ŸŽจ f-classList Demo</h3>
58
+ <div class="demo-box" f-classList="basicClasses">
59
+ Demo Box (hover me!)
60
+ </div>
61
+ <div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem;">
62
+ <button @click="toggleActive" :class="isActive ? 'btn-on' : 'btn-off'" style="padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer;">
63
+ {{ isActive ? 'โœ“ Active' : 'โ—‹ Active' }}
64
+ </button>
65
+ <button @click="toggleDisabled" :class="isDisabled ? 'btn-on' : 'btn-off'" style="padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer;">
66
+ {{ isDisabled ? 'โœ“ Disabled' : 'โ—‹ Disabled' }}
67
+ </button>
68
+ <button @click="toggleLarge" :class="isLarge ? 'btn-on' : 'btn-off'" style="padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer;">
69
+ {{ isLarge ? 'โœ“ Large' : 'โ—‹ Large' }}
70
+ </button>
71
+ </div>
72
+
35
73
  {{ UserForm }}
36
74
 
37
75
  {{ CounterSection @counter-change="handleCounterChange" @counter-reset="handleCounterReset" @custom-event="handleCustomEvent" :pageTitle="title" }}
@@ -4,26 +4,72 @@ import { CounterSection } from '../components/CounterSection.js'
4
4
  type HomeResult = {
5
5
  title: Signal<string>
6
6
  changeTitle: () => void
7
+ isActive: Signal<boolean>
8
+ isDisabled: Signal<boolean>
9
+ isLarge: Signal<boolean>
10
+ basicClasses: Computed<ClassListObject>
11
+ toggleActive: () => void
12
+ toggleDisabled: () => void
13
+ toggleLarge: () => void
7
14
  template: string
8
15
  }
9
16
 
17
+ type ClassListObject = Record<string, boolean>
18
+
10
19
  export const Home = defineComponent({
11
20
  components: { UserForm, CounterSection },
12
21
  setup(): HomeResult {
13
22
  const title = signal<string>('Welcome!')
23
+
24
+ const isActive = signal(false)
25
+ const isDisabled = signal(false)
26
+ const isLarge = signal(false)
27
+ const basicClasses = computed(() => ({
28
+ active: isActive(),
29
+ disabled: isDisabled(),
30
+ 'is-large': isLarge()
31
+ }))
32
+
33
+ const toggleActive = () => isActive(!isActive())
34
+ const toggleDisabled = () => isDisabled(!isDisabled())
35
+ const toggleLarge = () => isLarge(!isLarge())
36
+
14
37
  return {
15
38
  title,
16
39
  changeTitle: () => title('Changed Title!'),
40
+ isActive,
41
+ isDisabled,
42
+ isLarge,
43
+ basicClasses,
44
+ toggleActive,
45
+ toggleDisabled,
46
+ toggleLarge,
17
47
  template: `
18
48
  <div class="page">
19
49
  <h1>{{ title.toUpperCase() }}</h1>
20
50
  <p>This is an SPA built with my own (TypeScript!) custom framework!</p>
21
51
  <p>Made with passion by Sebastian ศ˜omu</p>
22
-
52
+
23
53
  <button @click="changeTitle" class="primary-btn" style="margin-bottom: 20px;">Change App Title</button>
24
-
54
+
55
+ <h3 style="margin-top: 40px; margin-bottom: 20px; color: #f59e0b;">๐ŸŽจ f-classList Demo</h3>
56
+ <div class="demo-box" f-classList="basicClasses">
57
+ Demo Box (hover me!)
58
+ </div>
59
+ <div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem;">
60
+ <button @click="toggleActive" :class="isActive ? 'btn-on' : 'btn-off'" style="padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer;">
61
+ {{ isActive ? 'โœ“ Active' : 'โ—‹ Active' }}
62
+ </button>
63
+ <button @click="toggleDisabled" :class="isDisabled ? 'btn-on' : 'btn-off'" style="padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer;">
64
+ {{ isDisabled ? 'โœ“ Disabled' : 'โ—‹ Disabled' }}
65
+ </button>
66
+ <button @click="toggleLarge" :class="isLarge ? 'btn-on' : 'btn-off'" style="padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer;">
67
+ {{ isLarge ? 'โœ“ Large' : 'โ—‹ Large' }}
68
+ </button>
69
+ </div>
70
+
25
71
  {{ UserForm }}
26
-
72
+
27
73
  {{ CounterSection :pageTitle="title" }}
28
74
  </div>
29
75
  `
@@ -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
 
@@ -81,6 +82,7 @@ export function processDirectives(root, context, components, effects) {
81
82
  processText(root, context, effects)
82
83
  processEvents(root, context, effects)
83
84
  processCustomDirectives(root, context, effects)
85
+ processClassList(root, context, effects)
84
86
  processCloak(root)
85
87
 
86
88
  return false
@@ -468,8 +470,9 @@ export function processEvents(el, context, effects) {
468
470
  if (el.nodeType !== 1 || !el.attributes) return
469
471
 
470
472
  for (const attr of [...el.attributes]) {
471
- if (attr.name.startsWith('@')) {
472
- 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
473
476
  const parts = fullEventName.split('.')
474
477
  const eventName = parts[0]
475
478
  const modifiers = parts.slice(1)
@@ -490,7 +493,7 @@ export function processEvents(el, context, effects) {
490
493
  if (next.endsWith('ms')) return parseInt(next) || 250
491
494
  if (next.endsWith('s')) return (parseFloat(next) || 0.25) * 1000
492
495
  }
493
- return 250
496
+ return 250
494
497
  }
495
498
 
496
499
  const debounceIdx = modifiers.indexOf('debounce')
@@ -568,6 +571,158 @@ export function processEvents(el, context, effects) {
568
571
  if (throttleTimeoutId) clearTimeout(throttleTimeoutId)
569
572
  })
570
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
+ }
571
726
  }
572
727
  }
573
728
  }
@@ -589,3 +744,43 @@ export function processCloak(el) {
589
744
  const els = el.querySelectorAll('[f-cloak]')
590
745
  for (const e of els) e.removeAttribute('f-cloak')
591
746
  }
747
+
748
+ export function processClassList(el, context, effects) {
749
+ if (el.nodeType !== 1 || !el.hasAttribute('f-classList')) return
750
+
751
+ const expr = el.getAttribute('f-classList')
752
+ el.removeAttribute('f-classList')
753
+
754
+ const appliedClasses = new Set()
755
+
756
+ const e = effect(() => {
757
+ const result = evaluateExpression(expr, context)
758
+
759
+ if (!result || typeof result !== 'object') {
760
+ console.warn(`[framework] f-classList must evaluate to an object, got: ${typeof result}`)
761
+ return
762
+ }
763
+
764
+ for (const [className, shouldApply] of Object.entries(result)) {
765
+ const isApplied = appliedClasses.has(className)
766
+
767
+ if (shouldApply && !isApplied) {
768
+ el.classList.add(className)
769
+ appliedClasses.add(className)
770
+ } else if (!shouldApply && isApplied) {
771
+ el.classList.remove(className)
772
+ appliedClasses.delete(className)
773
+ }
774
+ }
775
+
776
+ const currentClasses = new Set(Object.keys(result))
777
+ for (const className of [...appliedClasses]) {
778
+ if (!currentClasses.has(className)) {
779
+ el.classList.remove(className)
780
+ appliedClasses.delete(className)
781
+ }
782
+ }
783
+ })
784
+
785
+ effects.push(e)
786
+ }
@@ -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
+ }
@@ -61,6 +61,7 @@ export function signal(initialValue) {
61
61
  export function effect(fn) {
62
62
  let active = true
63
63
  let running = false
64
+ const nestedEffects = new Set()
64
65
 
65
66
  const run = () => {
66
67
  if (!active || running) {
@@ -69,14 +70,28 @@ export function effect(fn) {
69
70
  }
70
71
 
71
72
  running = true
73
+
74
+ for (const childCleanup of nestedEffects) {
75
+ childCleanup()
76
+ }
77
+ nestedEffects.clear()
78
+
72
79
  for (const dep of run.deps) dep.delete(run)
73
80
  run.deps.clear()
74
81
 
75
82
  const prevEffect = currentEffect
83
+ const prevOnEffectCreated = onEffectCreated
84
+
85
+ onEffectCreated = (childCleanup) => {
86
+ nestedEffects.add(childCleanup)
87
+ if (prevOnEffectCreated) prevOnEffectCreated(childCleanup)
88
+ }
89
+
76
90
  currentEffect = run
77
91
  try { fn() }
78
92
  finally {
79
93
  currentEffect = prevEffect
94
+ onEffectCreated = prevOnEffectCreated
80
95
  running = false
81
96
  }
82
97
  }
@@ -88,6 +103,11 @@ export function effect(fn) {
88
103
  pendingEffects.delete(run)
89
104
  for (const dep of run.deps) dep.delete(run)
90
105
  run.deps.clear()
106
+
107
+ for (const childCleanup of nestedEffects) {
108
+ childCleanup()
109
+ }
110
+ nestedEffects.clear()
91
111
  }
92
112
 
93
113
  if (onEffectCreated) onEffectCreated(cleanup)
@@ -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 = {
@@ -36,6 +36,7 @@ export function processShow(root: HTMLElement, context: ComponentResult, effects
36
36
  export function processHtml(root: HTMLElement, context: ComponentResult, effects: (() => void)[]): void
37
37
  export function processText(root: HTMLElement, context: ComponentResult, effects: (() => void)[]): void
38
38
  export function processEvents(root: HTMLElement, context: ComponentResult): void
39
+ export function processClassList(root: HTMLElement, context: ComponentResult, effects: (() => void)[]): void
39
40
  export function processCloak(root: HTMLElement): void
40
41
 
41
42
  export interface DirectiveBinding<V = any> {
@@ -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
@@ -293,6 +293,322 @@
293
293
  .cmp-input:focus {
294
294
  border-color: #8b8bff;
295
295
  }
296
+
297
+ /* โ”€โ”€ f-classList demo styles (Home page) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
298
+ .demo-box {
299
+ width: 200px;
300
+ height: 100px;
301
+ background: #1a1a24;
302
+ border: 2px solid #3a3a5a;
303
+ border-radius: 8px;
304
+ display: flex;
305
+ align-items: center;
306
+ justify-content: center;
307
+ font-weight: 600;
308
+ transition: all 0.3s ease;
309
+ margin-top: 1rem;
310
+ }
311
+
312
+ .demo-box.active {
313
+ border-color: #8b8bff;
314
+ background: rgba(139, 139, 255, 0.1);
315
+ box-shadow: 0 0 20px rgba(139, 139, 255, 0.3);
316
+ }
317
+
318
+ .demo-box.disabled {
319
+ opacity: 0.5;
320
+ cursor: not-allowed;
321
+ }
322
+
323
+ .demo-box.is-large {
324
+ width: 300px;
325
+ height: 150px;
326
+ font-size: 1.2rem;
327
+ }
328
+
329
+ .btn-on {
330
+ background: #8b8bff !important;
331
+ border-color: #8b8bff !important;
332
+ color: #fff !important;
333
+ }
334
+
335
+ .btn-off {
336
+ background: #1a1a24 !important;
337
+ border-color: #555 !important;
338
+ color: #888 !important;
339
+ }
340
+
341
+ .test-classlist h1 {
342
+ color: #f59e0b;
343
+ text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
344
+ }
345
+
346
+ .test-classlist .subtitle {
347
+ color: #aaa;
348
+ font-size: 0.9rem;
349
+ margin-bottom: 2rem;
350
+ }
351
+
352
+ .demo-section {
353
+ background: #13131d;
354
+ border: 1px solid #2a2a3a;
355
+ border-radius: 12px;
356
+ padding: 1.5rem;
357
+ margin-bottom: 1.5rem;
358
+ }
359
+
360
+ .demo-section h2 {
361
+ font-size: 1rem;
362
+ font-weight: 600;
363
+ color: #e8e8f0;
364
+ margin-bottom: 1rem;
365
+ }
366
+
367
+ .demo-box {
368
+ width: 200px;
369
+ height: 100px;
370
+ background: #1a1a24;
371
+ border: 2px solid #3a3a5a;
372
+ border-radius: 8px;
373
+ display: flex;
374
+ align-items: center;
375
+ justify-content: center;
376
+ font-weight: 600;
377
+ transition: all 0.3s ease;
378
+ margin-bottom: 1rem;
379
+ }
380
+
381
+ .demo-box.active {
382
+ border-color: #8b8bff;
383
+ background: rgba(139, 139, 255, 0.1);
384
+ box-shadow: 0 0 20px rgba(139, 139, 255, 0.3);
385
+ }
386
+
387
+ .demo-box.disabled {
388
+ opacity: 0.5;
389
+ cursor: not-allowed;
390
+ }
391
+
392
+ .demo-box.is-large {
393
+ width: 300px;
394
+ height: 150px;
395
+ font-size: 1.2rem;
396
+ }
397
+
398
+ .controls {
399
+ display: flex;
400
+ gap: 0.5rem;
401
+ flex-wrap: wrap;
402
+ }
403
+
404
+ .controls button {
405
+ padding: 0.5rem 1rem;
406
+ border: 1px solid #3a3a5a;
407
+ background: #0f0f1a;
408
+ color: #e8e8f0;
409
+ border-radius: 6px;
410
+ cursor: pointer;
411
+ transition: all 0.15s;
412
+ }
413
+
414
+ .controls button:hover {
415
+ background: #2a2a3a;
416
+ }
417
+
418
+ .controls button.btn-on {
419
+ background: #8b8bff;
420
+ border-color: #8b8bff;
421
+ color: #fff;
422
+ }
423
+
424
+ .controls button.btn-off {
425
+ background: #1a1a24;
426
+ border-color: #555;
427
+ color: #888;
428
+ }
429
+
430
+ .theme-box {
431
+ padding: 1.5rem;
432
+ border-radius: 8px;
433
+ border: 2px solid #3a3a5a;
434
+ transition: all 0.3s ease;
435
+ margin-bottom: 1rem;
436
+ }
437
+
438
+ .theme-box.theme-light {
439
+ background: rgba(245, 158, 11, 0.1);
440
+ border-color: #f59e0b;
441
+ color: #f59e0b;
442
+ }
443
+
444
+ .theme-box.theme-dark {
445
+ background: rgba(139, 139, 255, 0.1);
446
+ border-color: #8b8bff;
447
+ color: #8b8bff;
448
+ }
449
+
450
+ .theme-toggle {
451
+ padding: 0.6rem 1.25rem;
452
+ background: #f59e0b;
453
+ color: #000;
454
+ border: none;
455
+ border-radius: 6px;
456
+ font-weight: 600;
457
+ cursor: pointer;
458
+ transition: background 0.15s;
459
+ }
460
+
461
+ .theme-toggle:hover {
462
+ background: #d97706;
463
+ }
464
+
465
+ .action-btn {
466
+ padding: 0.75rem 1.5rem;
467
+ border: 2px solid #3a3a5a;
468
+ background: #1a1a24;
469
+ color: #e8e8f0;
470
+ border-radius: 8px;
471
+ font-weight: 600;
472
+ cursor: pointer;
473
+ transition: all 0.3s ease;
474
+ margin-bottom: 1rem;
475
+ }
476
+
477
+ .action-btn.btn-primary {
478
+ border-color: #8b8bff;
479
+ color: #8b8bff;
480
+ }
481
+
482
+ .action-btn.btn-primary:hover {
483
+ background: rgba(139, 139, 255, 0.1);
484
+ }
485
+
486
+ .action-btn.btn-loading {
487
+ border-color: #f59e0b;
488
+ color: #f59e0b;
489
+ animation: pulse 1.5s infinite;
490
+ }
491
+
492
+ .action-btn.btn-error {
493
+ border-color: #ff3333;
494
+ background: rgba(255, 51, 51, 0.1);
495
+ color: #ff3333;
496
+ }
497
+
498
+ .action-btn.btn-active {
499
+ transform: scale(0.95);
500
+ }
501
+
502
+ @keyframes pulse {
503
+ 0%, 100% { opacity: 1; }
504
+ 50% { opacity: 0.5; }
505
+ }
506
+
507
+ .info-section {
508
+ background: #0f0f1a;
509
+ border: 1px solid #2a2a3a;
510
+ border-radius: 12px;
511
+ padding: 1.5rem;
512
+ margin-top: 2rem;
513
+ }
514
+
515
+ .info-section h2 {
516
+ color: #34d399;
517
+ margin-bottom: 1rem;
518
+ }
519
+
520
+ .info-section ul {
521
+ margin-left: 1.5rem;
522
+ margin-bottom: 1.5rem;
523
+ }
524
+
525
+ .info-section li {
526
+ color: #aaa;
527
+ margin-bottom: 0.5rem;
528
+ line-height: 1.5;
529
+ }
530
+
531
+ .info-section li strong {
532
+ color: #e8e8f0;
533
+ }
534
+
535
+ .code-example {
536
+ background: #1a1a24;
537
+ border: 1px solid #2a2a3a;
538
+ border-radius: 8px;
539
+ padding: 1rem;
540
+ }
541
+
542
+ .code-example pre {
543
+ color: #8b8bff;
544
+ font-family: 'Courier New', monospace;
545
+ font-size: 0.85rem;
546
+ margin: 0;
547
+ overflow-x: auto;
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
+ }
296
612
  </style>
297
613
  </head>
298
614
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fusee-framework",
3
- "version": "1.5.2",
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",