@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.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  (() => {
3
- // ../fun-state/node_modules/@fun-land/accessor/dist/esm/util.js
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
- // ../fun-state/node_modules/@fun-land/accessor/dist/esm/accessor.js
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
- const setState = (v) => {
31
- modState(() => v);
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 focus = (acc) => subState({ getState, modState, subscribe }, acc);
34
- const subscribeToState = (signal, callback) => {
35
- const unsubscribe = subscribe(callback);
36
- signal.addEventListener("abort", unsubscribe, { once: true });
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 fs = {
39
- get: getState,
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
- return fs;
48
- };
49
- var subState = ({ getState, modState, subscribe }, accessor) => {
50
- const props = prop();
51
- const _get = () => accessor.query(getState())[0];
52
- const _mod = flow(accessor.mod, modState);
53
- function createFocusedSubscribe() {
54
- return (listener) => {
55
- let lastValue = _get();
56
- return subscribe((parentState) => {
57
- const newValue = accessor.query(parentState)[0];
58
- if (newValue !== lastValue) {
59
- lastValue = newValue;
60
- listener(newValue);
61
- }
62
- });
63
- };
64
- }
65
- const focus = (acc) => subState({ getState: _get, modState: _mod, subscribe: createFocusedSubscribe() }, acc);
66
- const _prop = flow(props, focus);
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: (acc) => comp(accessor, acc).query(getState()),
79
+ query: _query,
81
80
  mod: _mod,
82
- set: flow(set(accessor), modState),
83
- focus,
81
+ set: _set,
82
+ focus: _focus,
84
83
  prop: _prop,
85
- subscribe: subscribeToState
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").subscribe(signal, (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").subscribe(signal, (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(prop2()("counterValue"))
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. 🎉