fusee-framework 1.3.0 → 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.
- package/README.md +1 -1
- package/app/composables/demo.js +29 -0
- package/app/composables/demo.ts +30 -0
- package/app/composables/useFetch.js +53 -0
- package/app/composables/useFetch.ts +63 -0
- package/app/composables/useLocalStorage.js +31 -0
- package/app/composables/useLocalStorage.ts +26 -0
- package/app/main.js +23 -0
- package/app/main.ts +27 -4
- package/app/routes/TestComposables.js +158 -0
- package/app/routes/TestComposables.ts +158 -0
- package/app/routes/TestFor.js +2 -3
- package/app/routes/TestFor.ts +2 -3
- package/auto-imports.d.ts +1 -0
- package/framework/auto-import-preset.js +1 -0
- package/framework/core/component.js +7 -21
- package/framework/core/composable.js +27 -0
- package/framework/core/directives.js +69 -1
- package/framework/core/signal.js +58 -0
- package/framework/index.js +4 -2
- package/framework/types/composable.d.ts +14 -0
- package/framework/types/directives.d.ts +15 -0
- package/framework/types/index.d.ts +5 -4
- package/framework/types/signal.d.ts +15 -2
- package/index.html +135 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Fusée Framework 🚀
|
|
2
|
-
**v1.
|
|
2
|
+
**v1.4.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
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineComposable, assertSetupContext, signal, computed, onMount, onUnmount } from '../../framework/index.js'
|
|
2
|
+
|
|
3
|
+
// ─── counterLogic ─────────────────────────────────────────────────────────────
|
|
4
|
+
export const counterLogic = defineComposable((initial = 0, step = 1) => {
|
|
5
|
+
const count = signal(initial)
|
|
6
|
+
const doubled = computed(() => count() * 2)
|
|
7
|
+
const isEven = computed(() => count() % 2 === 0)
|
|
8
|
+
|
|
9
|
+
const increment = () => count(count() + step)
|
|
10
|
+
const decrement = () => count(count() - step)
|
|
11
|
+
const reset = () => count(initial)
|
|
12
|
+
|
|
13
|
+
return { count, doubled, isEven, increment, decrement, reset }
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
// ─── mouseTracker ─────────────────────────────────────────────────────────────
|
|
17
|
+
export const mouseTracker = defineComposable(() => {
|
|
18
|
+
assertSetupContext('mouseTracker')
|
|
19
|
+
|
|
20
|
+
const x = signal(0)
|
|
21
|
+
const y = signal(0)
|
|
22
|
+
|
|
23
|
+
const onMove = (e) => { x(e.clientX); y(e.clientY) }
|
|
24
|
+
|
|
25
|
+
onMount(() => window.addEventListener('mousemove', onMove))
|
|
26
|
+
onUnmount(() => window.removeEventListener('mousemove', onMove))
|
|
27
|
+
|
|
28
|
+
return { x, y }
|
|
29
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineComposable, assertSetupContext, signal, computed, onMount, onUnmount } from '../../framework/index.js'
|
|
2
|
+
|
|
3
|
+
export const counterLogic = defineComposable((initial: number = 0, step: number = 1) => {
|
|
4
|
+
const count = signal(initial)
|
|
5
|
+
const doubled = computed(() => count() * 2)
|
|
6
|
+
const isEven = computed(() => count() % 2 === 0)
|
|
7
|
+
|
|
8
|
+
const increment = () => count(count() + step)
|
|
9
|
+
const decrement = () => count(count() - step)
|
|
10
|
+
const reset = () => count(initial)
|
|
11
|
+
|
|
12
|
+
return { count, doubled, isEven, increment, decrement, reset }
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export const mouseTracker = defineComposable(() => {
|
|
16
|
+
assertSetupContext('mouseTracker')
|
|
17
|
+
|
|
18
|
+
const x = signal(0)
|
|
19
|
+
const y = signal(0)
|
|
20
|
+
|
|
21
|
+
const onMove = (e: MouseEvent) => {
|
|
22
|
+
x(e.clientX)
|
|
23
|
+
y(e.clientY)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onMount(() => window.addEventListener('mousemove', onMove))
|
|
27
|
+
onUnmount(() => window.removeEventListener('mousemove', onMove))
|
|
28
|
+
|
|
29
|
+
return { x, y }
|
|
30
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { defineComposable, signal, effect } from '../../framework/index.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @template T
|
|
5
|
+
* @param {string | (() => string)} urlOrSignal
|
|
6
|
+
* @param {{ immediate?: boolean, transform?: (raw: any) => T }} [options]
|
|
7
|
+
*/
|
|
8
|
+
export const useFetch = defineComposable((urlOrSignal, options = {}) => {
|
|
9
|
+
const { immediate = true, transform = null } = options
|
|
10
|
+
|
|
11
|
+
const data = signal(null)
|
|
12
|
+
const loading = signal(false)
|
|
13
|
+
const error = signal(null)
|
|
14
|
+
|
|
15
|
+
let controller = null
|
|
16
|
+
|
|
17
|
+
const execute = () => {
|
|
18
|
+
const url = typeof urlOrSignal === 'function' ? urlOrSignal() : urlOrSignal
|
|
19
|
+
if (!url) return
|
|
20
|
+
|
|
21
|
+
if (controller) controller.abort()
|
|
22
|
+
controller = new AbortController()
|
|
23
|
+
|
|
24
|
+
loading(true)
|
|
25
|
+
error(null)
|
|
26
|
+
|
|
27
|
+
fetch(url, { signal: controller.signal })
|
|
28
|
+
.then(res => {
|
|
29
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
|
30
|
+
return res.json()
|
|
31
|
+
})
|
|
32
|
+
.then(raw => {
|
|
33
|
+
data(transform ? transform(raw) : raw)
|
|
34
|
+
loading(false)
|
|
35
|
+
})
|
|
36
|
+
.catch(err => {
|
|
37
|
+
if (err.name === 'AbortError') return
|
|
38
|
+
error(err.message ?? String(err))
|
|
39
|
+
loading(false)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof urlOrSignal === 'function') {
|
|
44
|
+
effect(() => {
|
|
45
|
+
urlOrSignal()
|
|
46
|
+
execute()
|
|
47
|
+
})
|
|
48
|
+
} else if (immediate) {
|
|
49
|
+
execute()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { data, loading, error, refetch: execute }
|
|
53
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineComposable, signal, effect } from '../../framework/index.js'
|
|
2
|
+
|
|
3
|
+
export interface FetchOptions<T> {
|
|
4
|
+
immediate?: boolean
|
|
5
|
+
transform?: (raw: any) => T
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface FetchResult<T> {
|
|
9
|
+
data: Signal<T | null>
|
|
10
|
+
loading: Signal<boolean>
|
|
11
|
+
error: Signal<string | null>
|
|
12
|
+
refetch: () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const useFetch = defineComposable(<T = any>(
|
|
16
|
+
urlOrSignal: string | (() => string),
|
|
17
|
+
options: FetchOptions<T> = {}
|
|
18
|
+
): FetchResult<T> => {
|
|
19
|
+
const { immediate = true, transform = null } = options
|
|
20
|
+
|
|
21
|
+
const data = signal<T | null>(null)
|
|
22
|
+
const loading = signal(false)
|
|
23
|
+
const error = signal<string | null>(null)
|
|
24
|
+
|
|
25
|
+
let controller: AbortController | null = null
|
|
26
|
+
|
|
27
|
+
const execute = () => {
|
|
28
|
+
const url = typeof urlOrSignal === 'function' ? urlOrSignal() : urlOrSignal
|
|
29
|
+
if (!url) return
|
|
30
|
+
|
|
31
|
+
if (controller) controller.abort()
|
|
32
|
+
controller = new AbortController()
|
|
33
|
+
|
|
34
|
+
loading(true)
|
|
35
|
+
error(null)
|
|
36
|
+
|
|
37
|
+
fetch(url, { signal: controller.signal })
|
|
38
|
+
.then(res => {
|
|
39
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
|
40
|
+
return res.json()
|
|
41
|
+
})
|
|
42
|
+
.then(raw => {
|
|
43
|
+
data(transform ? transform(raw) : raw)
|
|
44
|
+
loading(false)
|
|
45
|
+
})
|
|
46
|
+
.catch(err => {
|
|
47
|
+
if (err.name === 'AbortError') return
|
|
48
|
+
error(err.message ?? String(err))
|
|
49
|
+
loading(false)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (typeof urlOrSignal === 'function') {
|
|
54
|
+
effect(() => {
|
|
55
|
+
urlOrSignal()
|
|
56
|
+
execute()
|
|
57
|
+
})
|
|
58
|
+
} else if (immediate) {
|
|
59
|
+
execute()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { data, loading, error, refetch: execute }
|
|
63
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineComposable, signal, effect } from '../../framework/index.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @template T
|
|
5
|
+
* @param {string} key
|
|
6
|
+
* @param {T} defaultValue
|
|
7
|
+
*/
|
|
8
|
+
export const useLocalStorage = defineComposable((key, defaultValue = null) => {
|
|
9
|
+
const stored = localStorage.getItem(key)
|
|
10
|
+
let initial
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
initial = stored !== null ? JSON.parse(stored) : defaultValue
|
|
14
|
+
} catch {
|
|
15
|
+
initial = defaultValue
|
|
16
|
+
localStorage.removeItem(key)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const value = signal(initial)
|
|
20
|
+
|
|
21
|
+
effect(() => {
|
|
22
|
+
const current = value()
|
|
23
|
+
if (current === null || current === undefined) {
|
|
24
|
+
localStorage.removeItem(key)
|
|
25
|
+
} else {
|
|
26
|
+
localStorage.setItem(key, JSON.stringify(current))
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return value
|
|
31
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineComposable, signal, effect } from '../../framework/index.js'
|
|
2
|
+
|
|
3
|
+
export const useLocalStorage = defineComposable(<T>(key: string, defaultValue: T | null = null): Signal<T | null> => {
|
|
4
|
+
const stored = localStorage.getItem(key)
|
|
5
|
+
let initial: T | null
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
initial = stored !== null ? JSON.parse(stored) : defaultValue
|
|
9
|
+
} catch {
|
|
10
|
+
initial = defaultValue
|
|
11
|
+
localStorage.removeItem(key)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const value = signal<T | null>(initial)
|
|
15
|
+
|
|
16
|
+
effect(() => {
|
|
17
|
+
const current = value()
|
|
18
|
+
if (current === null || current === undefined) {
|
|
19
|
+
localStorage.removeItem(key)
|
|
20
|
+
} else {
|
|
21
|
+
localStorage.setItem(key, JSON.stringify(current))
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return value
|
|
26
|
+
})
|
package/app/main.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { createRouter, mountOutlet, defineAsyncComponent } from '../framework/index.js'
|
|
2
2
|
import { Loading } from './components/Loading.js'
|
|
3
3
|
|
|
4
|
+
directive('focus', {
|
|
5
|
+
mounted(el) {
|
|
6
|
+
el.focus()
|
|
7
|
+
}
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
directive('highlight', {
|
|
11
|
+
mounted(el, binding) {
|
|
12
|
+
el.style.backgroundColor = binding.value || '#ffff0033'
|
|
13
|
+
el.style.transition = 'background-color 0.3s'
|
|
14
|
+
},
|
|
15
|
+
updated(el, binding) {
|
|
16
|
+
el.style.backgroundColor = binding.value || '#ffff0033'
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
4
20
|
const routes = [
|
|
5
21
|
{
|
|
6
22
|
path: '/',
|
|
@@ -30,6 +46,13 @@ const routes = [
|
|
|
30
46
|
loadingComponent: Loading
|
|
31
47
|
})
|
|
32
48
|
},
|
|
49
|
+
{
|
|
50
|
+
path: '/composables',
|
|
51
|
+
component: defineAsyncComponent({
|
|
52
|
+
loader: () => import('./routes/TestComposables.js'),
|
|
53
|
+
loadingComponent: Loading
|
|
54
|
+
})
|
|
55
|
+
},
|
|
33
56
|
]
|
|
34
57
|
|
|
35
58
|
createRouter(routes)
|
package/app/main.ts
CHANGED
|
@@ -2,35 +2,58 @@ import { createRouter, mountOutlet, defineAsyncComponent } from '../framework/in
|
|
|
2
2
|
import type { Route } from '../framework/types/index.js'
|
|
3
3
|
import { Loading } from './components/Loading.js'
|
|
4
4
|
|
|
5
|
+
directive('focus', {
|
|
6
|
+
mounted(el) {
|
|
7
|
+
el.focus()
|
|
8
|
+
}
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
directive('highlight', {
|
|
12
|
+
mounted(el, binding) {
|
|
13
|
+
el.style.backgroundColor = binding.value || '#ffff0033'
|
|
14
|
+
el.style.transition = 'background-color 0.3s'
|
|
15
|
+
},
|
|
16
|
+
updated(el, binding) {
|
|
17
|
+
el.style.backgroundColor = binding.value || '#ffff0033'
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
5
21
|
const routes: Route[] = [
|
|
6
22
|
{
|
|
7
23
|
path: '/',
|
|
8
24
|
component: defineAsyncComponent({
|
|
9
|
-
loader: () => import('./routes/Home.
|
|
25
|
+
loader: () => import('./routes/Home.ts'),
|
|
10
26
|
loadingComponent: Loading
|
|
11
27
|
})
|
|
12
28
|
},
|
|
13
29
|
{
|
|
14
30
|
path: '/about',
|
|
15
31
|
component: defineAsyncComponent({
|
|
16
|
-
loader: () => import('./routes/About.
|
|
32
|
+
loader: () => import('./routes/About.ts'),
|
|
17
33
|
loadingComponent: Loading
|
|
18
34
|
})
|
|
19
35
|
},
|
|
20
36
|
{
|
|
21
37
|
path: '/test-for',
|
|
22
38
|
component: defineAsyncComponent({
|
|
23
|
-
loader: () => import('./routes/TestFor.
|
|
39
|
+
loader: () => import('./routes/TestFor.ts'),
|
|
24
40
|
loadingComponent: Loading
|
|
25
41
|
})
|
|
26
42
|
},
|
|
27
43
|
{
|
|
28
44
|
path: '/dynamic',
|
|
29
45
|
component: defineAsyncComponent({
|
|
30
|
-
loader: () => import('./routes/TestDynamic.
|
|
46
|
+
loader: () => import('./routes/TestDynamic.ts'),
|
|
31
47
|
loadingComponent: Loading
|
|
32
48
|
})
|
|
33
49
|
},
|
|
50
|
+
{
|
|
51
|
+
path: '/composables',
|
|
52
|
+
component: defineAsyncComponent({
|
|
53
|
+
loader: () => import('./routes/TestComposables.ts'),
|
|
54
|
+
loadingComponent: Loading
|
|
55
|
+
})
|
|
56
|
+
},
|
|
34
57
|
]
|
|
35
58
|
|
|
36
59
|
createRouter(routes)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { counterLogic, mouseTracker } from '../composables/demo.js'
|
|
2
|
+
import { useFetch } from '../composables/useFetch.js'
|
|
3
|
+
import { useLocalStorage } from '../composables/useLocalStorage.js'
|
|
4
|
+
|
|
5
|
+
export const TestComposables = defineComponent({
|
|
6
|
+
setup() {
|
|
7
|
+
// ── 1. counterLogic — pure signal composable, no context needed ──────
|
|
8
|
+
const counter = counterLogic(0, 1)
|
|
9
|
+
const bigCounter = counterLogic(100, 10)
|
|
10
|
+
|
|
11
|
+
// ── 2. mouseTracker — lifecycle composable (needs setup context) ─────
|
|
12
|
+
const mouse = mouseTracker()
|
|
13
|
+
|
|
14
|
+
// ── 3. useLocalStorage — persists across page reloads ────────────────
|
|
15
|
+
const theme = useLocalStorage('demo-theme', 'dark')
|
|
16
|
+
const nickname = useLocalStorage('demo-nickname', '')
|
|
17
|
+
|
|
18
|
+
const toggleTheme = () => theme(theme() === 'dark' ? 'light' : 'dark')
|
|
19
|
+
|
|
20
|
+
// ── 4. useFetch — reactive data fetching ─────────────────────────────
|
|
21
|
+
const { data: posts, loading, error } = useFetch(
|
|
22
|
+
'https://jsonplaceholder.typicode.com/posts?_limit=3'
|
|
23
|
+
)
|
|
24
|
+
// ── 5. Custom Directives Test ────────────────────────────────────────
|
|
25
|
+
const hColor = signal('#8b8bff33')
|
|
26
|
+
const colors = ['#8b8bff33', '#10b98133', '#f59e0b33', '#ef444433']
|
|
27
|
+
let colorIdx = 0
|
|
28
|
+
const nextColor = () => hColor(colors[++colorIdx % colors.length])
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
hColor,
|
|
32
|
+
nextColor,
|
|
33
|
+
|
|
34
|
+
// counter
|
|
35
|
+
count: counter.count,
|
|
36
|
+
doubled: counter.doubled,
|
|
37
|
+
isEven: counter.isEven,
|
|
38
|
+
increment: counter.increment,
|
|
39
|
+
decrement: counter.decrement,
|
|
40
|
+
reset: counter.reset,
|
|
41
|
+
|
|
42
|
+
// big counter
|
|
43
|
+
bigCount: bigCounter.count,
|
|
44
|
+
bigIncrement: bigCounter.increment,
|
|
45
|
+
bigDecrement: bigCounter.decrement,
|
|
46
|
+
|
|
47
|
+
// mouse
|
|
48
|
+
mouseX: mouse.x,
|
|
49
|
+
mouseY: mouse.y,
|
|
50
|
+
|
|
51
|
+
// localStorage
|
|
52
|
+
theme,
|
|
53
|
+
nickname,
|
|
54
|
+
toggleTheme,
|
|
55
|
+
|
|
56
|
+
// fetch
|
|
57
|
+
posts,
|
|
58
|
+
loading,
|
|
59
|
+
error,
|
|
60
|
+
|
|
61
|
+
template: `
|
|
62
|
+
<div class="page">
|
|
63
|
+
<h1>🧩 Composables Demo</h1>
|
|
64
|
+
<p style="color:#888; margin-bottom: 2rem;">
|
|
65
|
+
Each section below is powered by a <code style="color:#ff3333">defineComposable()</code> function.
|
|
66
|
+
No naming rules — just reactive logic.
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
<!-- ── 1. counterLogic ─────────────────────────────────────── -->
|
|
70
|
+
<section class="demo-card">
|
|
71
|
+
<h2>📦 counterLogic(0, step=1)</h2>
|
|
72
|
+
<p class="demo-desc">Pure signal composable — works without setup() context.</p>
|
|
73
|
+
<div class="counter-display">
|
|
74
|
+
<span class="big-num" :class="isEven ? 'even' : 'odd'">{{ count }}</span>
|
|
75
|
+
<span class="derived-label">× 2 = {{ doubled }}</span>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="btn-row">
|
|
78
|
+
<button @click="decrement" class="cmp-btn minus">−</button>
|
|
79
|
+
<button @click="reset" class="cmp-btn reset">↺</button>
|
|
80
|
+
<button @click="increment" class="cmp-btn plus">+</button>
|
|
81
|
+
</div>
|
|
82
|
+
<p class="badge" :class="isEven ? 'badge-blue' : 'badge-red'">{{ isEven ? 'Even' : 'Odd' }}</p>
|
|
83
|
+
</section>
|
|
84
|
+
|
|
85
|
+
<!-- ── 1b. Second independent counter (step=10) ───────────── -->
|
|
86
|
+
<section class="demo-card">
|
|
87
|
+
<h2>📦 counterLogic(100, step=10)</h2>
|
|
88
|
+
<p class="demo-desc">Same composable, different instance. Fully isolated state.</p>
|
|
89
|
+
<div class="counter-display">
|
|
90
|
+
<span class="big-num">{{ bigCount }}</span>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="btn-row">
|
|
93
|
+
<button @click="bigDecrement" class="cmp-btn minus">−10</button>
|
|
94
|
+
<button @click="bigIncrement" class="cmp-btn plus">+10</button>
|
|
95
|
+
</div>
|
|
96
|
+
</section>
|
|
97
|
+
|
|
98
|
+
<!-- ── 2. mouseTracker ────────────────────────────────────── -->
|
|
99
|
+
<section class="demo-card">
|
|
100
|
+
<h2>🖱️ mouseTracker()</h2>
|
|
101
|
+
<p class="demo-desc">Lifecycle composable — registers window listener on mount, cleans up on unmount.</p>
|
|
102
|
+
<div class="mouse-display">
|
|
103
|
+
<span>X: <strong>{{ mouseX }}</strong></span>
|
|
104
|
+
<span>Y: <strong>{{ mouseY }}</strong></span>
|
|
105
|
+
</div>
|
|
106
|
+
</section>
|
|
107
|
+
|
|
108
|
+
<!-- ── 3. useLocalStorage ─────────────────────────────────── -->
|
|
109
|
+
<section class="demo-card">
|
|
110
|
+
<h2>💾 useLocalStorage()</h2>
|
|
111
|
+
<p class="demo-desc">State persists across page reloads. Try changing the nickname, then refresh.</p>
|
|
112
|
+
<div style="margin-bottom: 12px;">
|
|
113
|
+
<input f-model="nickname"
|
|
114
|
+
class="cmp-input"
|
|
115
|
+
placeholder="Your nickname…" />
|
|
116
|
+
<p style="margin-top: 8px; color: #888; font-size: 0.85rem;">
|
|
117
|
+
Stored: <strong style="color:#ff3333">{{ nickname || '(empty)' }}</strong>
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
<button @click="toggleTheme" class="cmp-btn reset">
|
|
121
|
+
Theme: {{ theme }}
|
|
122
|
+
</button>
|
|
123
|
+
</section>
|
|
124
|
+
|
|
125
|
+
<!-- ── 4. useFetch ────────────────────────────────────────── -->
|
|
126
|
+
<section class="demo-card">
|
|
127
|
+
<h2>🌐 useFetch()</h2>
|
|
128
|
+
<p class="demo-desc">Fetches from JSONPlaceholder. Loading state handled reactively.</p>
|
|
129
|
+
<div f-if="loading">
|
|
130
|
+
<p style="color: #888;">⏳ Loading posts…</p>
|
|
131
|
+
</div>
|
|
132
|
+
<div f-elif="error">
|
|
133
|
+
<p style="color: #ff6b6b;">❌ {{ error }}</p>
|
|
134
|
+
</div>
|
|
135
|
+
<div f-else>
|
|
136
|
+
<div f-for="post in posts" style="margin-bottom: 10px; padding: 10px; background:#1a1a24; border-radius:6px; border-left: 3px solid #ff3333;">
|
|
137
|
+
<strong style="color:#ff9999">{{ post.title }}</strong>
|
|
138
|
+
<p style="margin-top: 4px; font-size: 0.8rem; color: #666;">{{ post.body }}</p>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</section>
|
|
142
|
+
|
|
143
|
+
<!-- ── 6. Custom Directives ────────────────────────────────────── -->
|
|
144
|
+
<section class="demo-card">
|
|
145
|
+
<h2>🛠️ Custom Directives</h2>
|
|
146
|
+
<p class="demo-desc">Testing <code>f-focus</code> and <code>f-highlight</code>.</p>
|
|
147
|
+
|
|
148
|
+
<div f-highlight="hColor" style="padding: 15px; border-radius: 8px; margin-bottom: 10px;">
|
|
149
|
+
<input f-focus class="cmp-input" placeholder="I have f-focus!" />
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<button @click="nextColor" class="cmp-btn plus">Change Highlight Color</button>
|
|
153
|
+
</section>
|
|
154
|
+
</div>
|
|
155
|
+
`
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { defineComponent } from '../../framework/index.js'
|
|
2
|
+
import { counterLogic, mouseTracker } from '../composables/demo.js'
|
|
3
|
+
import { useFetch } from '../composables/useFetch.js'
|
|
4
|
+
import { useLocalStorage } from '../composables/useLocalStorage.js'
|
|
5
|
+
|
|
6
|
+
export const TestComposables = defineComponent({
|
|
7
|
+
setup() {
|
|
8
|
+
// ── 1. counterLogic — pure signal composable, no context needed ──────
|
|
9
|
+
const counter = counterLogic(0, 1)
|
|
10
|
+
const bigCounter = counterLogic(100, 10)
|
|
11
|
+
|
|
12
|
+
// ── 2. mouseTracker — lifecycle composable (needs setup context) ─────
|
|
13
|
+
const mouse = mouseTracker()
|
|
14
|
+
|
|
15
|
+
// ── 3. useLocalStorage — persists across page reloads ────────────────
|
|
16
|
+
const theme = useLocalStorage<string>('demo-theme', 'dark')
|
|
17
|
+
const nickname = useLocalStorage<string>('demo-nickname', '')
|
|
18
|
+
|
|
19
|
+
const toggleTheme = () => theme(theme() === 'dark' ? 'light' : 'dark')
|
|
20
|
+
|
|
21
|
+
// ── 4. useFetch — reactive data fetching ─────────────────────────────
|
|
22
|
+
const { data: posts, loading, error } = useFetch(
|
|
23
|
+
'https://jsonplaceholder.typicode.com/posts?_limit=3'
|
|
24
|
+
)
|
|
25
|
+
// ── 5. Custom Directives Test ────────────────────────────────────────
|
|
26
|
+
const hColor = signal('#8b8bff33')
|
|
27
|
+
const colors = ['#8b8bff33', '#10b98133', '#f59e0b33', '#ef444433']
|
|
28
|
+
let colorIdx = 0
|
|
29
|
+
const nextColor = () => hColor(colors[++colorIdx % colors.length])
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
hColor,
|
|
33
|
+
nextColor,
|
|
34
|
+
|
|
35
|
+
// counter
|
|
36
|
+
count: counter.count,
|
|
37
|
+
doubled: counter.doubled,
|
|
38
|
+
isEven: counter.isEven,
|
|
39
|
+
increment: counter.increment,
|
|
40
|
+
decrement: counter.decrement,
|
|
41
|
+
reset: counter.reset,
|
|
42
|
+
|
|
43
|
+
// big counter
|
|
44
|
+
bigCount: bigCounter.count,
|
|
45
|
+
bigIncrement: bigCounter.increment,
|
|
46
|
+
bigDecrement: bigCounter.decrement,
|
|
47
|
+
|
|
48
|
+
// mouse
|
|
49
|
+
mouseX: mouse.x,
|
|
50
|
+
mouseY: mouse.y,
|
|
51
|
+
|
|
52
|
+
// localStorage
|
|
53
|
+
theme,
|
|
54
|
+
nickname,
|
|
55
|
+
toggleTheme,
|
|
56
|
+
|
|
57
|
+
// fetch
|
|
58
|
+
posts,
|
|
59
|
+
loading,
|
|
60
|
+
error,
|
|
61
|
+
|
|
62
|
+
template: `
|
|
63
|
+
<div class="page">
|
|
64
|
+
<h1>🧩 Composables Demo</h1>
|
|
65
|
+
<p style="color:#888; margin-bottom: 2rem;">
|
|
66
|
+
Each section below is powered by a <code style="color:#ff3333">defineComposable()</code> function.
|
|
67
|
+
No naming rules — just reactive logic.
|
|
68
|
+
</p>
|
|
69
|
+
|
|
70
|
+
<!-- ── 1. counterLogic ─────────────────────────────────────── -->
|
|
71
|
+
<section class="demo-card">
|
|
72
|
+
<h2>📦 counterLogic(0, step=1)</h2>
|
|
73
|
+
<p class="demo-desc">Pure signal composable — works without setup() context.</p>
|
|
74
|
+
<div class="counter-display">
|
|
75
|
+
<span class="big-num" :class="isEven ? 'even' : 'odd'">{{ count }}</span>
|
|
76
|
+
<span class="derived-label">× 2 = {{ doubled }}</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="btn-row">
|
|
79
|
+
<button @click="decrement" class="cmp-btn minus">−</button>
|
|
80
|
+
<button @click="reset" class="cmp-btn reset">↺</button>
|
|
81
|
+
<button @click="increment" class="cmp-btn plus">+</button>
|
|
82
|
+
</div>
|
|
83
|
+
<p class="badge" :class="isEven ? 'badge-blue' : 'badge-red'">{{ isEven ? 'Even' : 'Odd' }}</p>
|
|
84
|
+
</section>
|
|
85
|
+
|
|
86
|
+
<!-- ── 1b. Second independent counter (step=10) ───────────── -->
|
|
87
|
+
<section class="demo-card">
|
|
88
|
+
<h2>📦 counterLogic(100, step=10)</h2>
|
|
89
|
+
<p class="demo-desc">Same composable, different instance. Fully isolated state.</p>
|
|
90
|
+
<div class="counter-display">
|
|
91
|
+
<span class="big-num">{{ bigCount }}</span>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="btn-row">
|
|
94
|
+
<button @click="bigDecrement" class="cmp-btn minus">−10</button>
|
|
95
|
+
<button @click="bigIncrement" class="cmp-btn plus">+10</button>
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
<!-- ── 2. mouseTracker ────────────────────────────────────── -->
|
|
100
|
+
<section class="demo-card">
|
|
101
|
+
<h2>🖱️ mouseTracker()</h2>
|
|
102
|
+
<p class="demo-desc">Lifecycle composable — registers window listener on mount, cleans up on unmount.</p>
|
|
103
|
+
<div class="mouse-display">
|
|
104
|
+
<span>X: <strong>{{ mouseX }}</strong></span>
|
|
105
|
+
<span>Y: <strong>{{ mouseY }}</strong></span>
|
|
106
|
+
</div>
|
|
107
|
+
</section>
|
|
108
|
+
|
|
109
|
+
<!-- ── 3. useLocalStorage ─────────────────────────────────── -->
|
|
110
|
+
<section class="demo-card">
|
|
111
|
+
<h2>💾 useLocalStorage()</h2>
|
|
112
|
+
<p class="demo-desc">State persists across page reloads. Try changing the nickname, then refresh.</p>
|
|
113
|
+
<div style="margin-bottom: 12px;">
|
|
114
|
+
<input f-model="nickname"
|
|
115
|
+
class="cmp-input"
|
|
116
|
+
placeholder="Your nickname…" />
|
|
117
|
+
<p style="margin-top: 8px; color: #888; font-size: 0.85rem;">
|
|
118
|
+
Stored: <strong style="color:#ff3333">{{ nickname || '(empty)' }}</strong>
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
<button @click="toggleTheme" class="cmp-btn reset">
|
|
122
|
+
Theme: {{ theme }}
|
|
123
|
+
</button>
|
|
124
|
+
</section>
|
|
125
|
+
|
|
126
|
+
<!-- ── 4. useFetch ────────────────────────────────────────── -->
|
|
127
|
+
<section class="demo-card">
|
|
128
|
+
<h2>🌐 useFetch()</h2>
|
|
129
|
+
<p class="demo-desc">Fetches from JSONPlaceholder. Loading state handled reactively.</p>
|
|
130
|
+
<div f-if="loading">
|
|
131
|
+
<p style="color: #888;">⏳ Loading posts…</p>
|
|
132
|
+
</div>
|
|
133
|
+
<div f-elif="error">
|
|
134
|
+
<p style="color: #ff6b6b;">❌ {{ error }}</p>
|
|
135
|
+
</div>
|
|
136
|
+
<div f-else>
|
|
137
|
+
<div f-for="post in posts" style="margin-bottom: 10px; padding: 10px; background:#1a1a24; border-radius:6px; border-left: 3px solid #ff3333;">
|
|
138
|
+
<strong style="color:#ff9999">{{ post.title }}</strong>
|
|
139
|
+
<p style="margin-top: 4px; font-size: 0.8rem; color: #666;">{{ post.body }}</p>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</section>
|
|
143
|
+
<!-- ── 6. Custom Directives ────────────────────────────────────── -->
|
|
144
|
+
<section class="demo-card">
|
|
145
|
+
<h2>🛠️ Custom Directives</h2>
|
|
146
|
+
<p class="demo-desc">Testing <code>f-focus</code> and <code>f-highlight</code>.</p>
|
|
147
|
+
|
|
148
|
+
<div f-highlight="hColor" style="padding: 15px; border-radius: 8px; margin-bottom: 10px;">
|
|
149
|
+
<input f-focus class="cmp-input" placeholder="I have f-focus!" />
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<button @click="nextColor" class="cmp-btn plus">Change Highlight Color</button>
|
|
153
|
+
</section>
|
|
154
|
+
</div>
|
|
155
|
+
`
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
})
|
package/app/routes/TestFor.js
CHANGED
|
@@ -11,11 +11,10 @@ export const TestFor = defineComponent({
|
|
|
11
11
|
items,
|
|
12
12
|
addItem: () => {
|
|
13
13
|
const nextId = Math.max(0, ...items().map(i => i.id)) + 1
|
|
14
|
-
items(
|
|
14
|
+
items.push({ id: nextId, name: `Item ${nextId}` })
|
|
15
15
|
},
|
|
16
16
|
shuffleItems: () => {
|
|
17
|
-
|
|
18
|
-
items(shuffled)
|
|
17
|
+
items.sort(() => Math.random() - 0.5)
|
|
19
18
|
},
|
|
20
19
|
template: `
|
|
21
20
|
<div class="page">
|
package/app/routes/TestFor.ts
CHANGED
|
@@ -23,11 +23,10 @@ export const TestFor = defineComponent({
|
|
|
23
23
|
addItem: () => {
|
|
24
24
|
const nextIds = items().map(i => i.id)
|
|
25
25
|
const nextId = nextIds.length > 0 ? Math.max(...nextIds) + 1 : 1
|
|
26
|
-
items(
|
|
26
|
+
items.push({ id: nextId, name: `Item ${nextId}` })
|
|
27
27
|
},
|
|
28
28
|
shuffleItems: () => {
|
|
29
|
-
|
|
30
|
-
items(shuffled)
|
|
29
|
+
items.sort(() => Math.random() - 0.5)
|
|
31
30
|
},
|
|
32
31
|
template: `
|
|
33
32
|
<div class="page">
|
package/auto-imports.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ declare global {
|
|
|
9
9
|
const batch: typeof import('fusee-framework').batch
|
|
10
10
|
const computed: typeof import('fusee-framework').computed
|
|
11
11
|
const defineComponent: typeof import('fusee-framework').defineComponent
|
|
12
|
+
const directive: typeof import('fusee-framework').directive
|
|
12
13
|
const effect: typeof import('fusee-framework').effect
|
|
13
14
|
const emit: typeof import('fusee-framework').emit
|
|
14
15
|
const inject: typeof import('fusee-framework').inject
|
|
@@ -7,6 +7,10 @@ setEffectHook(eff => {
|
|
|
7
7
|
if (currentInstance) currentInstance._effects.push(eff)
|
|
8
8
|
})
|
|
9
9
|
|
|
10
|
+
export function getCurrentInstance() {
|
|
11
|
+
return currentInstance
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
function resolveProps(schema, received) {
|
|
11
15
|
const isArray = Array.isArray(schema)
|
|
12
16
|
const resolved = {}
|
|
@@ -68,9 +72,6 @@ function resolveProps(schema, received) {
|
|
|
68
72
|
return resolved
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
// ── Emit ──────────────────────────────────────────────────────────────────────
|
|
72
|
-
// listeners: { change: fn, submit: fn, ... }
|
|
73
|
-
// emit('change', 42) → calls listeners.change(42)
|
|
74
75
|
function createEmit(listeners) {
|
|
75
76
|
return function emit(eventName, ...args) {
|
|
76
77
|
const handler = listeners[eventName]
|
|
@@ -82,10 +83,6 @@ function createEmit(listeners) {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
// ── Slots ─────────────────────────────────────────────────────────────────────
|
|
86
|
-
// Parses slot content from parent HTML string.
|
|
87
|
-
// Named slots: <template slot="header">...</template>
|
|
88
|
-
// Default slot: everything else
|
|
89
86
|
export function parseSlots(slotHTML) {
|
|
90
87
|
const slots = { default: '' }
|
|
91
88
|
if (!slotHTML || !slotHTML.trim()) return slots
|
|
@@ -105,19 +102,15 @@ export function parseSlots(slotHTML) {
|
|
|
105
102
|
return slots
|
|
106
103
|
}
|
|
107
104
|
|
|
108
|
-
// Replaces <slot> and <slot name="x"> in template with actual slot content
|
|
109
105
|
function resolveSlots(template, slots) {
|
|
110
|
-
// Handle named slots with any content between tags (including default content)
|
|
111
106
|
let result = template.replace(/<slot\s+name="([^"]+)"\s*>[\s\S]*?<\/slot>/gi, (match, name) => {
|
|
112
107
|
return slots[name] ?? ''
|
|
113
108
|
})
|
|
114
|
-
// Handle default slots with any content between tags
|
|
115
109
|
result = result.replace(/<slot\s*>[\s\S]*?<\/slot>/gi, () => {
|
|
116
110
|
return slots.default ?? ''
|
|
117
111
|
})
|
|
118
|
-
|
|
112
|
+
|
|
119
113
|
result = result.replace(/<slot\s+name="([^"]+)"\s*\/>/gi, (_, name) => slots[name] ?? '')
|
|
120
|
-
// Handle self-closing default slots
|
|
121
114
|
result = result.replace(/<slot\s*\/>/gi, () => slots.default ?? '')
|
|
122
115
|
return result
|
|
123
116
|
}
|
|
@@ -131,10 +124,6 @@ export function onUnmount(fn) {
|
|
|
131
124
|
}
|
|
132
125
|
|
|
133
126
|
export function defineComponent(options) {
|
|
134
|
-
// ComponentFactory now accepts props + optional { listeners, slots, parent }
|
|
135
|
-
// listeners: event handlers from parent @eventName="handler"
|
|
136
|
-
// slots: parsed slot HTML from parent
|
|
137
|
-
// parent: parent component instance for provide/inject
|
|
138
127
|
return function ComponentFactory(props = {}, { listeners = {}, slots = {}, parent = null } = {}) {
|
|
139
128
|
const instance = {
|
|
140
129
|
props: options.props ? resolveProps(options.props, props) : props,
|
|
@@ -149,16 +138,13 @@ export function defineComponent(options) {
|
|
|
149
138
|
const emit = createEmit(listeners)
|
|
150
139
|
|
|
151
140
|
currentInstance = instance
|
|
152
|
-
// setup() receives (props, { emit, slots })
|
|
153
141
|
const result = options.setup(instance.props, { emit, slots })
|
|
154
|
-
// Store instance reference on result for child component access
|
|
155
142
|
result._instance = instance
|
|
156
143
|
currentInstance = null
|
|
157
144
|
|
|
158
145
|
function render(container) {
|
|
159
146
|
instance._element = container
|
|
160
147
|
|
|
161
|
-
// Resolve slot placeholders before mounting
|
|
162
148
|
const resolvedTemplate = resolveSlots(result.template, slots)
|
|
163
149
|
|
|
164
150
|
const { effects } = mountTemplate(
|
|
@@ -201,8 +187,8 @@ export function inject(key) {
|
|
|
201
187
|
}
|
|
202
188
|
|
|
203
189
|
export function defineAsyncComponent(loaderOrOptions) {
|
|
204
|
-
const options = typeof loaderOrOptions === 'function'
|
|
205
|
-
? { loader: loaderOrOptions }
|
|
190
|
+
const options = typeof loaderOrOptions === 'function'
|
|
191
|
+
? { loader: loaderOrOptions }
|
|
206
192
|
: loaderOrOptions
|
|
207
193
|
|
|
208
194
|
return function AsyncComponentFactory(props = {}, { listeners = {}, slots = {}, parent = null } = {}) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getCurrentInstance } from './component.js'
|
|
2
|
+
|
|
3
|
+
export function defineComposable(fn) {
|
|
4
|
+
const composable = function (...args) {
|
|
5
|
+
return fn(...args)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
composable._isComposable = true
|
|
9
|
+
|
|
10
|
+
Object.defineProperty(composable, 'name', {
|
|
11
|
+
value: fn.name || 'composable',
|
|
12
|
+
configurable: true
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
return composable
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function assertSetupContext(name) {
|
|
19
|
+
const instance = getCurrentInstance()
|
|
20
|
+
if (!instance) {
|
|
21
|
+
console.warn(
|
|
22
|
+
`[framework] ${name ? name + ' ' : ''}composable was called outside of setup(). ` +
|
|
23
|
+
`Lifecycle hooks (onMount, onUnmount) and injections will not work.`
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
return instance
|
|
27
|
+
}
|
|
@@ -2,6 +2,73 @@ 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
|
|
@@ -13,6 +80,7 @@ export function processDirectives(root, context, components, effects) {
|
|
|
13
80
|
processHtml(root, context, effects)
|
|
14
81
|
processText(root, context, effects)
|
|
15
82
|
processEvents(root, context, effects)
|
|
83
|
+
processCustomDirectives(root, context, effects)
|
|
16
84
|
processCloak(root)
|
|
17
85
|
|
|
18
86
|
return false
|
|
@@ -282,7 +350,7 @@ export function processIs(el, context, components, effects) {
|
|
|
282
350
|
// 1. Prepare directive state
|
|
283
351
|
const expr = el.getAttribute('f-is')
|
|
284
352
|
const keepAlive = processKeepAlive(el)
|
|
285
|
-
|
|
353
|
+
|
|
286
354
|
el.removeAttribute('f-is')
|
|
287
355
|
|
|
288
356
|
const anchor = document.createComment('f-keepAlive-anchor')
|
package/framework/core/signal.js
CHANGED
|
@@ -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
|
|
package/framework/index.js
CHANGED
|
@@ -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, defineAsyncComponent, 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
|
-
|
|
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()')
|
|
@@ -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
|
|
@@ -37,3 +37,18 @@ export function processHtml(root: HTMLElement, context: ComponentResult, effects
|
|
|
37
37
|
export function processText(root: HTMLElement, context: ComponentResult, effects: (() => void)[]): void
|
|
38
38
|
export function processEvents(root: HTMLElement, context: ComponentResult): void
|
|
39
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):
|
|
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
|
|
|
@@ -169,9 +302,10 @@
|
|
|
169
302
|
<a href="#/about">About</a>
|
|
170
303
|
<a href="#/test-for">Loops Test</a>
|
|
171
304
|
<a href="#/dynamic" style="color: #ec4899;">Dynamic Components</a>
|
|
305
|
+
<a href="#/composables" style="color: #34d399;">Composables</a>
|
|
172
306
|
</nav>
|
|
173
307
|
<div id="app"></div>
|
|
174
|
-
<script type="module" src="./app/main.
|
|
308
|
+
<script type="module" src="./app/main.ts"></script>
|
|
175
309
|
</body>
|
|
176
310
|
|
|
177
311
|
</html>
|