@fun-land/fun-web 0.3.0 → 0.3.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/examples/counter/bundle.js +54 -62
- package/examples/todo-app/AddTodoForm.ts +46 -0
- package/examples/todo-app/DraggableTodoList.ts +173 -0
- package/examples/todo-app/README.md +172 -0
- package/examples/todo-app/Todo.ts +77 -30
- package/examples/todo-app/TodoApp.js +585 -0
- package/examples/todo-app/TodoApp.ts +63 -0
- package/examples/todo-app/TodoAppState.ts +32 -0
- package/examples/todo-app/TodoState.ts +5 -0
- package/examples/todo-app/index.html +2 -131
- package/examples/todo-app/todo-app.css +294 -0
- package/package.json +8 -6
- package/wip/Screenshot 2026-01-13 at 11.56.08 AM.png +0 -0
- package/wip/Screenshot 2026-01-13 at 12.08.22 PM.png +0 -0
- package/wip/Screenshot 2026-01-13 at 12.11.14 PM.png +0 -0
- package/wip/Screenshot 2026-01-13 at 2.49.08 AM.png +0 -0
- package/examples/todo-app/todo-app.ts +0 -117
- package/examples/todo-app/todo-bundle.js +0 -410
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
(() => {
|
|
3
|
-
// ../
|
|
3
|
+
// ../accessor/dist/esm/util.js
|
|
4
4
|
var flow = (f, g) => (x) => g(f(x));
|
|
5
5
|
var K = (a) => (_b) => a;
|
|
6
6
|
var flatmap = (f) => (xs) => {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
return out;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
// ../
|
|
14
|
+
// ../accessor/dist/esm/accessor.js
|
|
15
15
|
var prop = () => (k) => ({
|
|
16
16
|
query: (obj) => [obj[k]],
|
|
17
17
|
mod: (transform) => (obj) => Object.assign(Object.assign({}, obj), { [k]: transform(obj[k]) })
|
|
@@ -24,67 +24,67 @@
|
|
|
24
24
|
return accs.reduce(_comp);
|
|
25
25
|
}
|
|
26
26
|
var set = (acc) => flow(K, acc.mod);
|
|
27
|
+
var get = (acc) => (s) => {
|
|
28
|
+
var _a;
|
|
29
|
+
return (_a = acc.query(s)) === null || _a === void 0 ? void 0 : _a[0];
|
|
30
|
+
};
|
|
31
|
+
var unit = () => ({
|
|
32
|
+
query: (x) => [x],
|
|
33
|
+
mod: (transform) => (x) => transform(x)
|
|
34
|
+
});
|
|
27
35
|
|
|
28
36
|
// ../fun-state/dist/esm/src/FunState.js
|
|
29
|
-
var pureState = ({ getState, modState, subscribe }) => {
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
var pureState = ({ getState, modState, subscribe }) => mkFunState({ getState, modState, subscribe }, unit());
|
|
38
|
+
function mkFunState(engine, viewAcc) {
|
|
39
|
+
const select = get(viewAcc);
|
|
40
|
+
const _get = () => {
|
|
41
|
+
const v = select(engine.getState());
|
|
42
|
+
return v;
|
|
32
43
|
};
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
const _query = (acc) => comp(viewAcc, acc).query(engine.getState());
|
|
45
|
+
const _mod = (f) => engine.modState(viewAcc.mod(f));
|
|
46
|
+
const _set = (val) => engine.modState(set(viewAcc)(val));
|
|
47
|
+
const _focus = (acc) => {
|
|
48
|
+
return mkFunState(engine, comp(viewAcc, acc));
|
|
37
49
|
};
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
query: (acc) => acc.query(getState()),
|
|
41
|
-
mod: modState,
|
|
42
|
-
set: setState,
|
|
43
|
-
focus,
|
|
44
|
-
prop: flow(prop(), focus),
|
|
45
|
-
subscribe: subscribeToState
|
|
50
|
+
const _prop = (key) => {
|
|
51
|
+
return _focus(prop()(key));
|
|
46
52
|
};
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const subscribeToState = (signal, callback) => {
|
|
68
|
-
let lastValue = _get();
|
|
69
|
-
const unsubscribe = subscribe((parentState) => {
|
|
70
|
-
const newValue = accessor.query(parentState)[0];
|
|
71
|
-
if (newValue !== lastValue) {
|
|
72
|
-
lastValue = newValue;
|
|
73
|
-
callback(newValue);
|
|
53
|
+
const _watch = (signal, callback) => {
|
|
54
|
+
let last = select(engine.getState());
|
|
55
|
+
callback(last);
|
|
56
|
+
const unsubscribe = engine.subscribe((rootState) => {
|
|
57
|
+
const next = select(rootState);
|
|
58
|
+
if (!Object.is(next, last)) {
|
|
59
|
+
last = next;
|
|
60
|
+
callback(next);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
signal.addEventListener("abort", unsubscribe, { once: true });
|
|
64
|
+
};
|
|
65
|
+
const _watchAll = (signal, callback) => {
|
|
66
|
+
let last = viewAcc.query(engine.getState());
|
|
67
|
+
callback(last);
|
|
68
|
+
const unsubscribe = engine.subscribe((rootState) => {
|
|
69
|
+
const next = viewAcc.query(rootState);
|
|
70
|
+
if (last.length !== next.length || next.some((v, i) => !Object.is(v, last[i]))) {
|
|
71
|
+
last = next;
|
|
72
|
+
callback(next);
|
|
74
73
|
}
|
|
75
74
|
});
|
|
76
75
|
signal.addEventListener("abort", unsubscribe, { once: true });
|
|
77
76
|
};
|
|
78
77
|
return {
|
|
79
78
|
get: _get,
|
|
80
|
-
query:
|
|
79
|
+
query: _query,
|
|
81
80
|
mod: _mod,
|
|
82
|
-
set:
|
|
83
|
-
focus,
|
|
81
|
+
set: _set,
|
|
82
|
+
focus: _focus,
|
|
84
83
|
prop: _prop,
|
|
85
|
-
|
|
84
|
+
watch: _watch,
|
|
85
|
+
watchAll: _watchAll
|
|
86
86
|
};
|
|
87
|
-
}
|
|
87
|
+
}
|
|
88
88
|
var standaloneEngine = (initialState) => {
|
|
89
89
|
let state = initialState;
|
|
90
90
|
const listeners = /* @__PURE__ */ new Set();
|
|
@@ -101,19 +101,12 @@
|
|
|
101
101
|
};
|
|
102
102
|
var funState = (initialState) => pureState(standaloneEngine(initialState));
|
|
103
103
|
|
|
104
|
-
// ../accessor/dist/esm/accessor.js
|
|
105
|
-
var prop2 = () => (k) => ({
|
|
106
|
-
query: (obj) => [obj[k]],
|
|
107
|
-
mod: (transform) => (obj) => Object.assign(Object.assign({}, obj), { [k]: transform(obj[k]) })
|
|
108
|
-
});
|
|
109
|
-
|
|
110
104
|
// src/dom.ts
|
|
111
105
|
var h = (tag, attrs2, children) => {
|
|
112
106
|
const element = document.createElement(tag);
|
|
113
107
|
if (attrs2) {
|
|
114
108
|
for (const [key, value] of Object.entries(attrs2)) {
|
|
115
|
-
if (value == null)
|
|
116
|
-
continue;
|
|
109
|
+
if (value == null) continue;
|
|
117
110
|
if (key.startsWith("on") && typeof value === "function") {
|
|
118
111
|
const eventName = key.slice(2).toLowerCase();
|
|
119
112
|
element.addEventListener(eventName, value);
|
|
@@ -165,7 +158,7 @@
|
|
|
165
158
|
const incrementBtn = h("button", { textContent: "+" });
|
|
166
159
|
const decrementBtn = h("button", { textContent: "-" });
|
|
167
160
|
const resetBtn = h("button", { textContent: "Reset" });
|
|
168
|
-
state.prop("count").
|
|
161
|
+
state.prop("count").watch(signal, (count) => {
|
|
169
162
|
display.textContent = String(count);
|
|
170
163
|
});
|
|
171
164
|
incrementBtn.addEventListener(
|
|
@@ -195,7 +188,7 @@
|
|
|
195
188
|
counterValue: { count: 0 }
|
|
196
189
|
});
|
|
197
190
|
const heading = h("h1", { textContent: state.get().title });
|
|
198
|
-
state.prop("title").
|
|
191
|
+
state.prop("title").watch(signal, (title) => {
|
|
199
192
|
heading.textContent = title;
|
|
200
193
|
});
|
|
201
194
|
const counter = Counter(
|
|
@@ -204,15 +197,14 @@
|
|
|
204
197
|
{
|
|
205
198
|
label: "Click Counter",
|
|
206
199
|
onReset: () => state.prop("counterValue").set({ count: 0 }),
|
|
207
|
-
state: state.focus(
|
|
200
|
+
state: state.focus(prop()("counterValue"))
|
|
208
201
|
}
|
|
209
202
|
);
|
|
210
203
|
return h("div", { className: "app" }, [heading, counter]);
|
|
211
204
|
};
|
|
212
205
|
var runExample = () => {
|
|
213
206
|
const container = document.getElementById("app");
|
|
214
|
-
if (!container)
|
|
215
|
-
throw new Error("No #app element found");
|
|
207
|
+
if (!container) throw new Error("No #app element found");
|
|
216
208
|
const mounted = mount(App, {}, container);
|
|
217
209
|
return () => mounted.unmount();
|
|
218
210
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { flow } from "@fun-land/accessor";
|
|
2
|
+
import { FunState } from "@fun-land/fun-state";
|
|
3
|
+
import { Component, enhance, h, bindPropertyTo, onTo } from "../../src";
|
|
4
|
+
import { TodoAppState, clearValue, addItem } from "./TodoAppState";
|
|
5
|
+
|
|
6
|
+
export const AddTodoForm: Component<{ state: FunState<TodoAppState> }> = (
|
|
7
|
+
signal,
|
|
8
|
+
{ state }
|
|
9
|
+
) => {
|
|
10
|
+
const input = enhance(
|
|
11
|
+
h("input", {
|
|
12
|
+
type: "text",
|
|
13
|
+
placeholder: "Add a todo...",
|
|
14
|
+
className: "todo-input",
|
|
15
|
+
}),
|
|
16
|
+
bindPropertyTo("value", state.prop("value"), signal),
|
|
17
|
+
onTo(
|
|
18
|
+
"input",
|
|
19
|
+
(e) => {
|
|
20
|
+
state.prop("value").set(e.currentTarget.value);
|
|
21
|
+
},
|
|
22
|
+
signal
|
|
23
|
+
)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return enhance(
|
|
27
|
+
h("form", { className: "todo-form" }, [
|
|
28
|
+
input,
|
|
29
|
+
h("button", {
|
|
30
|
+
type: "submit",
|
|
31
|
+
textContent: "Add",
|
|
32
|
+
className: "add-btn",
|
|
33
|
+
}),
|
|
34
|
+
]),
|
|
35
|
+
onTo(
|
|
36
|
+
"submit",
|
|
37
|
+
(e) => {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
if (state.get().value.trim()) {
|
|
40
|
+
state.mod(flow(addItem, clearValue));
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
signal
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { h, keyedChildren, type Component } from "../../src/index";
|
|
2
|
+
import type { FunState } from "../../src/state";
|
|
3
|
+
import { Todo } from "./Todo";
|
|
4
|
+
import { type TodoState } from "./TodoState";
|
|
5
|
+
|
|
6
|
+
const ANIMATION_DURATION = 300;
|
|
7
|
+
|
|
8
|
+
interface DraggableTodoListProps {
|
|
9
|
+
items: FunState<TodoState[]>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const getElementByKey = (key: string) =>
|
|
13
|
+
document.querySelector(`[data-key="${key}"]`);
|
|
14
|
+
|
|
15
|
+
// Complex component to show off how you can use all the normal DOM and CSS techniques without having to figure out frameworks or do special stuff
|
|
16
|
+
export const DraggableTodoList: Component<DraggableTodoListProps> = (
|
|
17
|
+
signal,
|
|
18
|
+
{ items }
|
|
19
|
+
) => {
|
|
20
|
+
let draggedKey: string | null = null;
|
|
21
|
+
let lastTargetKey: string | null = null;
|
|
22
|
+
let previousItemCount = items.get().length;
|
|
23
|
+
|
|
24
|
+
// Watch for new items being added and animate them sliding down
|
|
25
|
+
items.watch(signal, (currentItems) => {
|
|
26
|
+
const currentCount = currentItems.length;
|
|
27
|
+
if (currentCount > previousItemCount) {
|
|
28
|
+
// New item added - capture positions and animate
|
|
29
|
+
const positions = new Map<string, DOMRect>();
|
|
30
|
+
currentItems.forEach((item) => {
|
|
31
|
+
const el = getElementByKey(item.key);
|
|
32
|
+
if (el) positions.set(item.key, el.getBoundingClientRect());
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
requestAnimationFrame(() => {
|
|
36
|
+
positions.forEach((first, key) => {
|
|
37
|
+
const el = getElementByKey(key);
|
|
38
|
+
if (!el) return;
|
|
39
|
+
const last = el.getBoundingClientRect();
|
|
40
|
+
const deltaY = first.top - last.top;
|
|
41
|
+
if (deltaY) {
|
|
42
|
+
el.animate(
|
|
43
|
+
[
|
|
44
|
+
{ transform: `translateY(${deltaY}px)` },
|
|
45
|
+
{ transform: "translateY(0)" },
|
|
46
|
+
],
|
|
47
|
+
{
|
|
48
|
+
duration: ANIMATION_DURATION,
|
|
49
|
+
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
previousItemCount = currentCount;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const handleDragStart = (key: string) => {
|
|
60
|
+
draggedKey = key;
|
|
61
|
+
lastTargetKey = null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handleDragOver = (targetKey: string) => {
|
|
65
|
+
if (!draggedKey || draggedKey === targetKey || lastTargetKey === targetKey)
|
|
66
|
+
return;
|
|
67
|
+
|
|
68
|
+
lastTargetKey = targetKey;
|
|
69
|
+
|
|
70
|
+
// Reorder items
|
|
71
|
+
const allItems = items.get();
|
|
72
|
+
const draggedIndex = allItems.findIndex((item) => item.key === draggedKey);
|
|
73
|
+
const targetIndex = allItems.findIndex((item) => item.key === targetKey);
|
|
74
|
+
|
|
75
|
+
if (draggedIndex !== -1 && targetIndex !== -1) {
|
|
76
|
+
const newItems = [...allItems];
|
|
77
|
+
const [draggedItem] = newItems.splice(draggedIndex, 1);
|
|
78
|
+
newItems.splice(targetIndex, 0, draggedItem);
|
|
79
|
+
items.set(newItems);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleDragEnd = () => {
|
|
84
|
+
if (lastTargetKey) {
|
|
85
|
+
// FLIP animation: capture positions before, then animate after state change
|
|
86
|
+
const positions = new Map<string, DOMRect>();
|
|
87
|
+
items.get().forEach((item) => {
|
|
88
|
+
const el = getElementByKey(item.key);
|
|
89
|
+
if (el) positions.set(item.key, el.getBoundingClientRect());
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
requestAnimationFrame(() => {
|
|
93
|
+
positions.forEach((first, key) => {
|
|
94
|
+
const el = getElementByKey(key);
|
|
95
|
+
if (!el) return;
|
|
96
|
+
|
|
97
|
+
const last = el.getBoundingClientRect();
|
|
98
|
+
const deltaX = first.left - last.left;
|
|
99
|
+
const deltaY = first.top - last.top;
|
|
100
|
+
|
|
101
|
+
if (deltaX || deltaY) {
|
|
102
|
+
el.animate(
|
|
103
|
+
[
|
|
104
|
+
{ transform: `translate(${deltaX}px, ${deltaY}px)` },
|
|
105
|
+
{ transform: "translate(0, 0)" },
|
|
106
|
+
],
|
|
107
|
+
{
|
|
108
|
+
duration: ANIMATION_DURATION,
|
|
109
|
+
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
draggedKey = null;
|
|
118
|
+
lastTargetKey = null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const todoList = h("ul", { className: "todo-list" });
|
|
122
|
+
|
|
123
|
+
keyedChildren(todoList, signal, items, (row) =>
|
|
124
|
+
Todo(row.signal, {
|
|
125
|
+
removeItem: () => {
|
|
126
|
+
const element = getElementByKey(row.state.get().key);
|
|
127
|
+
if (element) {
|
|
128
|
+
element.classList.add("todo-item-exit");
|
|
129
|
+
setTimeout(() => {
|
|
130
|
+
// Capture positions before removal
|
|
131
|
+
const positions = new Map<string, DOMRect>();
|
|
132
|
+
items.get().forEach((item) => {
|
|
133
|
+
const el = getElementByKey(item.key);
|
|
134
|
+
if (el) positions.set(item.key, el.getBoundingClientRect());
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
row.remove();
|
|
138
|
+
|
|
139
|
+
// Animate remaining items
|
|
140
|
+
requestAnimationFrame(() => {
|
|
141
|
+
positions.forEach((first, key) => {
|
|
142
|
+
const el = getElementByKey(key);
|
|
143
|
+
if (!el) return;
|
|
144
|
+
const last = el.getBoundingClientRect();
|
|
145
|
+
const deltaY = first.top - last.top;
|
|
146
|
+
if (deltaY) {
|
|
147
|
+
el.animate(
|
|
148
|
+
[
|
|
149
|
+
{ transform: `translateY(${deltaY}px)` },
|
|
150
|
+
{ transform: "translateY(0)" },
|
|
151
|
+
],
|
|
152
|
+
{
|
|
153
|
+
duration: ANIMATION_DURATION,
|
|
154
|
+
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}, ANIMATION_DURATION);
|
|
161
|
+
} else {
|
|
162
|
+
row.remove();
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
state: row.state,
|
|
166
|
+
onDragStart: handleDragStart,
|
|
167
|
+
onDragEnd: handleDragEnd,
|
|
168
|
+
onDragOver: handleDragOver,
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return todoList;
|
|
173
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Advanced Todo App - Showcasing Direct DOM Access
|
|
2
|
+
|
|
3
|
+
This Todo app demonstrates the advantages of **direct DOM access** over virtual DOM frameworks like React. Without a render loop getting in the way, we can leverage native browser APIs that are typically challenging in React.
|
|
4
|
+
|
|
5
|
+
## 🚀 Advanced Features
|
|
6
|
+
|
|
7
|
+
### 1. **Mouse-based Drag & Drop Reordering**
|
|
8
|
+
- Uses native mouse events (mousedown/mousemove/mouseup) for reliable drag behavior
|
|
9
|
+
- Smooth reordering with real-time feedback
|
|
10
|
+
- Custom drag ghost that follows cursor perfectly
|
|
11
|
+
- Visual indicators during drag operations (opacity, scale, shadow)
|
|
12
|
+
- No library dependencies needed!
|
|
13
|
+
|
|
14
|
+
**Why this is hard in React:**
|
|
15
|
+
- Virtual DOM diffing can interfere with drag state
|
|
16
|
+
- Event handlers need careful management to avoid re-render issues
|
|
17
|
+
- Maintaining drag ghost and visual feedback requires complex state management
|
|
18
|
+
- HTML5 drag API is unreliable - mouse events give full control
|
|
19
|
+
|
|
20
|
+
### 2. **FLIP Animations**
|
|
21
|
+
- **F**irst: Capture element positions before state change
|
|
22
|
+
- **L**ast: Capture positions after DOM update
|
|
23
|
+
- **I**nvert: Apply transform to appear at old position
|
|
24
|
+
- **P**lay: Animate to new position
|
|
25
|
+
|
|
26
|
+
This creates butter-smooth position animations when items are reordered or added.
|
|
27
|
+
|
|
28
|
+
**Why this is hard in React:**
|
|
29
|
+
- Need to coordinate animations across render cycles
|
|
30
|
+
- Virtual DOM reconciliation can destroy/recreate elements mid-animation
|
|
31
|
+
- Requires refs, useLayoutEffect, and careful timing
|
|
32
|
+
|
|
33
|
+
### 3. **Enter/Exit Animations**
|
|
34
|
+
- New items slide in from the left
|
|
35
|
+
- Deleted items fade out and slide away
|
|
36
|
+
- CSS animations work seamlessly because elements persist
|
|
37
|
+
|
|
38
|
+
**Why this is hard in React:**
|
|
39
|
+
- Elements unmount immediately on removal
|
|
40
|
+
- Need special libraries (react-transition-group, framer-motion)
|
|
41
|
+
- Complex coordination between state and animation lifecycle
|
|
42
|
+
|
|
43
|
+
### 4. **Web Animations API**
|
|
44
|
+
- Using native `element.animate()` for smooth transitions
|
|
45
|
+
- Hardware accelerated transforms
|
|
46
|
+
- No CSS-in-JS overhead
|
|
47
|
+
|
|
48
|
+
**Why this is natural here:**
|
|
49
|
+
- We have stable element references (no re-renders destroying them)
|
|
50
|
+
- Can directly call animate() whenever we want
|
|
51
|
+
- Animations persist across state changes
|
|
52
|
+
|
|
53
|
+
### 5. **Persistent Event Listeners**
|
|
54
|
+
- Event handlers attached once, never recreated
|
|
55
|
+
- Using `{ signal }` for automatic cleanup via AbortController
|
|
56
|
+
- No closure issues or stale state problems
|
|
57
|
+
|
|
58
|
+
**Why this is hard in React:**
|
|
59
|
+
- Event handlers recreated on every render (unless useCallback)
|
|
60
|
+
- Dependencies need careful management
|
|
61
|
+
- Easy to create memory leaks with manual listeners
|
|
62
|
+
|
|
63
|
+
## 🎨 Interaction Details
|
|
64
|
+
|
|
65
|
+
### Visual Feedback
|
|
66
|
+
- **Drag handle** (⋮⋮) changes color and scales on hover
|
|
67
|
+
- **Dragging item** becomes semi-transparent and scales down
|
|
68
|
+
- **Drop target** highlights with purple border and background
|
|
69
|
+
- **All buttons** have hover effects with transforms
|
|
70
|
+
- **Priority indicator**: High priority items show red left border
|
|
71
|
+
|
|
72
|
+
### Animations
|
|
73
|
+
- **Add item**: Slides in from left with fade
|
|
74
|
+
- **Remove item**: Fades out and slides left, then other items smoothly move up
|
|
75
|
+
- **Reorder**: Items smoothly animate to new positions (FLIP)
|
|
76
|
+
- **All Done**: Celebration text bounces in
|
|
77
|
+
|
|
78
|
+
### Touch & Accessibility
|
|
79
|
+
- Drag handles are clear and visible
|
|
80
|
+
- Color-coded priority system
|
|
81
|
+
- High contrast for readability
|
|
82
|
+
- Smooth focus states on form controls
|
|
83
|
+
|
|
84
|
+
## 🛠 Technical Architecture
|
|
85
|
+
|
|
86
|
+
### Component Pattern
|
|
87
|
+
Components are **run-once functions** that:
|
|
88
|
+
1. Create DOM elements
|
|
89
|
+
2. Set up subscriptions with AbortSignal
|
|
90
|
+
3. Return the element
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
const Todo: Component<TodoProps> = (signal, props) => {
|
|
94
|
+
// Create elements once
|
|
95
|
+
const li = h("li", { className: "todo-item" });
|
|
96
|
+
|
|
97
|
+
// Set up subscriptions (cleaned up via signal)
|
|
98
|
+
props.state.watch(signal, (data) => {
|
|
99
|
+
// Direct DOM updates, no re-render
|
|
100
|
+
input.value = data.label;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return li; // Element persists until unmounted
|
|
104
|
+
};
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### State Management
|
|
108
|
+
Using `keyedChildren()` for efficient list rendering:
|
|
109
|
+
- Preserves DOM elements across reorders (crucial for animations!)
|
|
110
|
+
- Each item gets its own AbortController for cleanup
|
|
111
|
+
- Only affected elements update, others remain untouched
|
|
112
|
+
|
|
113
|
+
### FLIP Implementation
|
|
114
|
+
```typescript
|
|
115
|
+
// Before state change: capture positions
|
|
116
|
+
const captureFlipFirst = () => {
|
|
117
|
+
items.forEach(item => {
|
|
118
|
+
const el = document.querySelector(`[data-key="${item.key}"]`);
|
|
119
|
+
positions.set(item.key, el.getBoundingClientRect());
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// After state change: animate from old to new position
|
|
124
|
+
const applyFlipAnimation = () => {
|
|
125
|
+
requestAnimationFrame(() => {
|
|
126
|
+
positions.forEach((first, key) => {
|
|
127
|
+
const el = document.querySelector(`[data-key="${key}"]`);
|
|
128
|
+
const last = el.getBoundingClientRect();
|
|
129
|
+
const deltaX = first.left - last.left;
|
|
130
|
+
const deltaY = first.top - last.top;
|
|
131
|
+
|
|
132
|
+
// Animate using Web Animations API
|
|
133
|
+
el.animate([
|
|
134
|
+
{ transform: `translate(${deltaX}px, ${deltaY}px)` },
|
|
135
|
+
{ transform: "translate(0, 0)" }
|
|
136
|
+
], { duration: 300, easing: "cubic-bezier(0.4, 0, 0.2, 1)" });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## 💡 Key Differences from React
|
|
143
|
+
|
|
144
|
+
| Feature | fun-web | React |
|
|
145
|
+
|---------|---------|-------|
|
|
146
|
+
| **Drag & Drop** | Mouse events with custom ghost, full control | Complex, needs libraries or fights with virtual DOM |
|
|
147
|
+
| **FLIP Animations** | Natural - elements persist | Hard - elements recreate, need refs + useLayoutEffect |
|
|
148
|
+
| **Enter/Exit** | CSS animations just work | Need react-transition-group or framer-motion |
|
|
149
|
+
| **Event Handlers** | Attach once, AbortSignal cleanup | Recreate every render (unless useCallback) |
|
|
150
|
+
| **Performance** | Update only changed properties | Full reconciliation pass on state change |
|
|
151
|
+
| **Mental Model** | Direct DOM manipulation | Declarative state → UI |
|
|
152
|
+
|
|
153
|
+
## 🎯 What This Demonstrates
|
|
154
|
+
|
|
155
|
+
1. **No Virtual DOM overhead** - Direct DOM updates are fast
|
|
156
|
+
2. **Stable references** - Elements persist, animations work naturally
|
|
157
|
+
3. **Native APIs** - Use browser features without fighting framework
|
|
158
|
+
4. **Simpler mental model** - See exactly what DOM operations happen
|
|
159
|
+
5. **Better for interactions** - Complex animations and effects are straightforward
|
|
160
|
+
|
|
161
|
+
## 🏃♂️ Try It Out
|
|
162
|
+
|
|
163
|
+
Open `index.html` in a browser and:
|
|
164
|
+
- **Drag items** by the handle (⋮⋮) to reorder
|
|
165
|
+
- **Add items** and watch them slide in
|
|
166
|
+
- **Delete items** and watch them fade out
|
|
167
|
+
- **Check all** and see the celebration
|
|
168
|
+
- Notice how **smooth** everything feels!
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
This is what's possible when you embrace direct DOM access instead of fighting a render loop. 🎉
|