fusee-framework 1.3.0 → 1.4.1
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 +35 -12
- 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 +35 -8
- package/app/routes/TestFor.ts +34 -8
- 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 +92 -5
- package/framework/index.js +4 -2
- package/framework/router/router.js +4 -4
- 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 +38 -3
- package/index.html +134 -0
- package/package.json +1 -1
|
@@ -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
|
@@ -5,33 +5,60 @@ export const TestFor = defineComponent({
|
|
|
5
5
|
{ id: 2, name: 'Item 2' },
|
|
6
6
|
{ id: 3, name: 'Item 3' }
|
|
7
7
|
])
|
|
8
|
+
const search = signal('')
|
|
9
|
+
|
|
10
|
+
// ✨ NEW: Using the reactive .filter() method on the signal!
|
|
11
|
+
// This returns a Computed signal that updates automatically.
|
|
12
|
+
const filteredItems = items.filter(item =>
|
|
13
|
+
item.name.toLowerCase().includes(search().toLowerCase())
|
|
14
|
+
)
|
|
15
|
+
|
|
8
16
|
inspect(items)
|
|
9
17
|
|
|
10
18
|
return {
|
|
11
19
|
items,
|
|
20
|
+
search,
|
|
21
|
+
filteredItems,
|
|
22
|
+
// 🔗 Chaining test: filter -> map -> join
|
|
23
|
+
namesSummary: filteredItems.map(i => i.name).join(', '),
|
|
24
|
+
|
|
25
|
+
updateItem: (id, newName) => {
|
|
26
|
+
// We update the SOURCE signal so the .filter() can see the new values!
|
|
27
|
+
items(items().map(i => i.id === id ? { ...i, name: newName } : i))
|
|
28
|
+
},
|
|
12
29
|
addItem: () => {
|
|
13
30
|
const nextId = Math.max(0, ...items().map(i => i.id)) + 1
|
|
14
|
-
items(
|
|
31
|
+
items.push({ id: nextId, name: `Item ${nextId}` })
|
|
15
32
|
},
|
|
16
33
|
shuffleItems: () => {
|
|
17
|
-
|
|
18
|
-
items(shuffled)
|
|
34
|
+
items.sort(() => Math.random() - 0.5)
|
|
19
35
|
},
|
|
20
36
|
template: `
|
|
21
37
|
<div class="page">
|
|
22
38
|
<h1>Keyed f-for Test</h1>
|
|
23
39
|
<p>Focus an input below, then click "Shuffle" or "Add". The focus should stay!</p>
|
|
40
|
+
<p style="color: #8b8bff; background: #8b8bff11; padding: 10px; border-radius: 4px; border-left: 3px solid #8b8bff;">
|
|
41
|
+
🔗 <strong>Reactive Summary:</strong> {{ namesSummary || 'No items found' }}
|
|
42
|
+
</p>
|
|
24
43
|
|
|
25
|
-
<div style="margin-bottom: 20px; display: flex; gap: 10px;">
|
|
26
|
-
<
|
|
27
|
-
|
|
44
|
+
<div style="margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px;">
|
|
45
|
+
<input type="text" f-model="search" placeholder="🔍 Filter items..."
|
|
46
|
+
style="padding: 10px; border-radius: 4px; border: 1px solid #444; background: #111; color: #fff; width: 100%;" />
|
|
47
|
+
|
|
48
|
+
<div style="display: flex; gap: 10px;">
|
|
49
|
+
<button @click="addItem" class="primary-btn">Add Item</button>
|
|
50
|
+
<button @click="shuffleItems" class="primary-btn">Shuffle Items</button>
|
|
51
|
+
</div>
|
|
28
52
|
</div>
|
|
29
53
|
|
|
30
54
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
|
31
|
-
<div f-for="item in
|
|
55
|
+
<div f-for="item in filteredItems" :key="item.id"
|
|
32
56
|
style="padding: 10px; border: 1px solid #333; display: flex; gap: 10px; align-items: center;">
|
|
33
57
|
<span>ID: {{ item.id }}</span>
|
|
34
|
-
<input type="text"
|
|
58
|
+
<input type="text"
|
|
59
|
+
:value="item.name"
|
|
60
|
+
@input="updateItem(item().id, $event.target.value)"
|
|
61
|
+
placeholder="Type here..."
|
|
35
62
|
style="background: #111; color: #fff; border: 1px solid #444; padding: 5px;" />
|
|
36
63
|
</div>
|
|
37
64
|
</div>
|
package/app/routes/TestFor.ts
CHANGED
|
@@ -5,6 +5,10 @@ type Item = {
|
|
|
5
5
|
|
|
6
6
|
type TestForResult = {
|
|
7
7
|
items: Signal<Item[]>
|
|
8
|
+
search: Signal<string>
|
|
9
|
+
filteredItems: Computed<Item[]>
|
|
10
|
+
namesSummary: Computed<string>
|
|
11
|
+
updateItem: (id: number, name: string) => void
|
|
8
12
|
addItem: () => void
|
|
9
13
|
shuffleItems: () => void
|
|
10
14
|
template: string
|
|
@@ -17,33 +21,55 @@ export const TestFor = defineComponent({
|
|
|
17
21
|
{ id: 2, name: 'Item 2' },
|
|
18
22
|
{ id: 3, name: 'Item 3' }
|
|
19
23
|
])
|
|
24
|
+
const search = signal('')
|
|
25
|
+
const filteredItems = items.filter(i =>
|
|
26
|
+
i.name.toLowerCase().includes(search().toLowerCase())
|
|
27
|
+
)
|
|
28
|
+
const namesSummary = filteredItems.map(i => i.name).join(', ')
|
|
20
29
|
|
|
21
30
|
return {
|
|
22
31
|
items,
|
|
32
|
+
search,
|
|
33
|
+
filteredItems,
|
|
34
|
+
namesSummary,
|
|
35
|
+
updateItem: (id, newName) => {
|
|
36
|
+
items(items().map(i => i.id === id ? { ...i, name: newName } : i))
|
|
37
|
+
},
|
|
23
38
|
addItem: () => {
|
|
24
39
|
const nextIds = items().map(i => i.id)
|
|
25
40
|
const nextId = nextIds.length > 0 ? Math.max(...nextIds) + 1 : 1
|
|
26
|
-
items(
|
|
41
|
+
items.push({ id: nextId, name: `Item ${nextId}` })
|
|
27
42
|
},
|
|
28
43
|
shuffleItems: () => {
|
|
29
|
-
|
|
30
|
-
items(shuffled)
|
|
44
|
+
items.sort(() => Math.random() - 0.5)
|
|
31
45
|
},
|
|
32
46
|
template: `
|
|
33
47
|
<div class="page">
|
|
34
48
|
<h1>Keyed f-for Test (TypeScript)</h1>
|
|
35
49
|
<p>Focus an input below, then click "Shuffle" or "Add". The focus should stay!</p>
|
|
50
|
+
|
|
51
|
+
<p style="color: #8b8bff; background: #8b8bff11; padding: 10px; border-radius: 4px; border-left: 3px solid #8b8bff;">
|
|
52
|
+
🔗 <strong>Reactive Summary:</strong> {{ namesSummary || 'No items found' }}
|
|
53
|
+
</p>
|
|
36
54
|
|
|
37
|
-
<div style="margin-bottom: 20px; display: flex; gap: 10px;">
|
|
38
|
-
<
|
|
39
|
-
|
|
55
|
+
<div style="margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px;">
|
|
56
|
+
<input type="text" f-model="search" placeholder="🔍 Filter items..."
|
|
57
|
+
style="padding: 10px; border-radius: 4px; border: 1px solid #444; background: #111; color: #fff; width: 100%;" />
|
|
58
|
+
|
|
59
|
+
<div style="display: flex; gap: 10px;">
|
|
60
|
+
<button @click="addItem" class="primary-btn">Add Item</button>
|
|
61
|
+
<button @click="shuffleItems" class="primary-btn">Shuffle Items</button>
|
|
62
|
+
</div>
|
|
40
63
|
</div>
|
|
41
64
|
|
|
42
65
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
|
43
|
-
<div f-for="item in
|
|
66
|
+
<div f-for="item in filteredItems" :key="item.id"
|
|
44
67
|
style="padding: 10px; border: 1px solid #333; display: flex; gap: 10px; align-items: center;">
|
|
45
68
|
<span>ID: {{ item.id }}</span>
|
|
46
|
-
<input type="text"
|
|
69
|
+
<input type="text"
|
|
70
|
+
:value="item.name"
|
|
71
|
+
@input="updateItem(item().id, $event.target.value)"
|
|
72
|
+
placeholder="Type here..."
|
|
47
73
|
style="background: #111; color: #fff; border: 1px solid #444; padding: 5px;" />
|
|
48
74
|
</div>
|
|
49
75
|
</div>
|
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,10 @@ export function signal(initialValue) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
accessor.isSignal = true
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(initialValue)) addMutatingArrayMethods(accessor)
|
|
56
|
+
addReactiveArrayMethods(accessor)
|
|
57
|
+
|
|
54
58
|
return accessor
|
|
55
59
|
}
|
|
56
60
|
|
|
@@ -125,6 +129,9 @@ export function computed(fn) {
|
|
|
125
129
|
|
|
126
130
|
accessor.isSignal = true
|
|
127
131
|
accessor.readonly = true
|
|
132
|
+
|
|
133
|
+
addReactiveArrayMethods(accessor)
|
|
134
|
+
|
|
128
135
|
return accessor
|
|
129
136
|
}
|
|
130
137
|
|
|
@@ -138,11 +145,6 @@ export function untrack(fn) {
|
|
|
138
145
|
}
|
|
139
146
|
}
|
|
140
147
|
|
|
141
|
-
/**
|
|
142
|
-
* Reactive log helper similar to Svelte 5's $inspect.
|
|
143
|
-
* Watches signals or functions and logs their current values on change.
|
|
144
|
-
* Only active in development mode.
|
|
145
|
-
*/
|
|
146
148
|
export function inspect(...args) {
|
|
147
149
|
const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
|
|
148
150
|
if (!isDev) return
|
|
@@ -157,3 +159,88 @@ export function inspect(...args) {
|
|
|
157
159
|
console.log('[inspect]', ...values)
|
|
158
160
|
})
|
|
159
161
|
}
|
|
162
|
+
|
|
163
|
+
function addReactiveArrayMethods(accessor) {
|
|
164
|
+
// Transformations
|
|
165
|
+
accessor.map = (fn) => computed(() => accessor()?.map?.(fn) ?? [])
|
|
166
|
+
accessor.filter = (fn) => computed(() => accessor()?.filter?.(fn) ?? [])
|
|
167
|
+
accessor.slice = (...args) => computed(() => accessor()?.slice?.(...args) ?? [])
|
|
168
|
+
accessor.concat = (...args) => computed(() => accessor()?.concat?.(...args) ?? [])
|
|
169
|
+
accessor.flat = (depth) => computed(() => accessor()?.flat?.(depth) ?? [])
|
|
170
|
+
accessor.flatMap = (fn) => computed(() => accessor()?.flatMap?.(fn) ?? [])
|
|
171
|
+
|
|
172
|
+
// Searches
|
|
173
|
+
accessor.find = (fn) => computed(() => accessor()?.find?.(fn))
|
|
174
|
+
accessor.findLast = (fn) => computed(() => accessor()?.findLast?.(fn))
|
|
175
|
+
accessor.findIndex = (fn) => computed(() => accessor()?.findIndex?.(fn))
|
|
176
|
+
accessor.findLastIndex = (fn) => computed(() => accessor()?.findLastIndex?.(fn))
|
|
177
|
+
accessor.indexOf = (searchElement, fromIdx) => computed(() => accessor()?.indexOf?.(searchElement, fromIdx))
|
|
178
|
+
accessor.lastIndexOf = (searchElement, fromIdx) => computed(() => accessor()?.lastIndexOf?.(searchElement, fromIdx))
|
|
179
|
+
accessor.includes = (searchElement, fromIdx) => computed(() => accessor()?.includes?.(searchElement, fromIdx))
|
|
180
|
+
|
|
181
|
+
// Validations
|
|
182
|
+
accessor.every = (fn) => computed(() => accessor()?.every?.(fn))
|
|
183
|
+
accessor.some = (fn) => computed(() => accessor()?.some?.(fn))
|
|
184
|
+
|
|
185
|
+
// Accumulators & Access
|
|
186
|
+
accessor.reduce = (...args) => computed(() => accessor()?.reduce?.(...args))
|
|
187
|
+
accessor.at = (index) => computed(() => accessor()?.at?.(index))
|
|
188
|
+
accessor.join = (separator) => computed(() => accessor()?.join?.(separator) ?? '')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function addMutatingArrayMethods(accessor) {
|
|
192
|
+
accessor.push = (...items) => {
|
|
193
|
+
const next = [...accessor(), ...items]
|
|
194
|
+
accessor(next)
|
|
195
|
+
return next.length
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
accessor.pop = () => {
|
|
199
|
+
const arr = accessor()
|
|
200
|
+
if (arr.length === 0) return undefined
|
|
201
|
+
const item = arr[arr.length - 1]
|
|
202
|
+
accessor(arr.slice(0, -1))
|
|
203
|
+
return item
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
accessor.shift = () => {
|
|
207
|
+
const arr = accessor()
|
|
208
|
+
if (arr.length === 0) return undefined
|
|
209
|
+
const item = arr[0]
|
|
210
|
+
accessor(arr.slice(1))
|
|
211
|
+
return item
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
accessor.unshift = (...items) => {
|
|
215
|
+
const next = [...items, ...accessor()]
|
|
216
|
+
accessor(next)
|
|
217
|
+
return next.length
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
accessor.splice = (start, deleteCount, ...items) => {
|
|
221
|
+
const arr = [...accessor()]
|
|
222
|
+
const removed = arr.splice(start, deleteCount, ...items)
|
|
223
|
+
accessor(arr)
|
|
224
|
+
return removed
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
accessor.remove = (predicate) => {
|
|
228
|
+
accessor(accessor().filter((item, i) => !predicate(item, i)))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
accessor.clear = () => {
|
|
232
|
+
accessor([])
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
accessor.sort = (compareFn) => {
|
|
236
|
+
const next = [...accessor()].sort(compareFn)
|
|
237
|
+
accessor(next)
|
|
238
|
+
return accessor
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
accessor.reverse = () => {
|
|
242
|
+
const next = [...accessor()].reverse()
|
|
243
|
+
accessor(next)
|
|
244
|
+
return accessor
|
|
245
|
+
}
|
|
246
|
+
}
|
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()')
|