kayforms 0.1.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/LICENSE +21 -0
- package/README.md +337 -0
- package/examples/react-demo/README.md +337 -0
- package/examples/react-demo/eslint.config.js +22 -0
- package/examples/react-demo/index.html +13 -0
- package/examples/react-demo/package.json +33 -0
- package/examples/react-demo/public/apple-touch-icon.png +0 -0
- package/examples/react-demo/public/favicon-96x96.png +0 -0
- package/examples/react-demo/public/favicon.ico +0 -0
- package/examples/react-demo/public/favicon.svg +17 -0
- package/examples/react-demo/public/icons.svg +24 -0
- package/examples/react-demo/public/site.webmanifest +21 -0
- package/examples/react-demo/public/web-app-manifest-192x192.png +0 -0
- package/examples/react-demo/public/web-app-manifest-512x512.png +0 -0
- package/examples/react-demo/src/App.css +184 -0
- package/examples/react-demo/src/App.tsx +825 -0
- package/examples/react-demo/src/assets/hero.png +0 -0
- package/examples/react-demo/src/assets/react.svg +1 -0
- package/examples/react-demo/src/assets/vite.svg +1 -0
- package/examples/react-demo/src/index.css +627 -0
- package/examples/react-demo/src/main.tsx +10 -0
- package/examples/react-demo/tsconfig.app.json +25 -0
- package/examples/react-demo/tsconfig.json +7 -0
- package/examples/react-demo/tsconfig.node.json +24 -0
- package/examples/react-demo/vite.config.ts +7 -0
- package/kayforms.jpg +0 -0
- package/package.json +26 -0
- package/packages/angular/package.json +43 -0
- package/packages/angular/src/index.ts +198 -0
- package/packages/angular/tsconfig.json +8 -0
- package/packages/angular/tsup.config.ts +17 -0
- package/packages/core/README.md +337 -0
- package/packages/core/package.json +37 -0
- package/packages/core/src/batch.ts +106 -0
- package/packages/core/src/devtools.ts +329 -0
- package/packages/core/src/field.ts +167 -0
- package/packages/core/src/form.ts +448 -0
- package/packages/core/src/index.ts +71 -0
- package/packages/core/src/registry.ts +126 -0
- package/packages/core/src/signal.ts +399 -0
- package/packages/core/src/time-travel.ts +275 -0
- package/packages/core/src/validation.ts +243 -0
- package/packages/core/tsconfig.json +8 -0
- package/packages/core/tsup.config.ts +16 -0
- package/packages/devtools/extension/background.js +35 -0
- package/packages/devtools/extension/content-script.js +10 -0
- package/packages/devtools/extension/devtools.html +9 -0
- package/packages/devtools/extension/devtools.js +8 -0
- package/packages/devtools/extension/manifest.json +19 -0
- package/packages/devtools/extension/panel.css +505 -0
- package/packages/devtools/extension/panel.html +108 -0
- package/packages/devtools/extension/panel.js +354 -0
- package/packages/devtools/package.json +38 -0
- package/packages/devtools/src/index.ts +95 -0
- package/packages/devtools/src/panel.ts +226 -0
- package/packages/devtools/src/styles.ts +422 -0
- package/packages/devtools/src/timeline.ts +283 -0
- package/packages/devtools/tsconfig.json +8 -0
- package/packages/devtools/tsup.config.ts +17 -0
- package/packages/react/package.json +46 -0
- package/packages/react/src/index.ts +279 -0
- package/packages/react/tsconfig.json +8 -0
- package/packages/react/tsup.config.ts +17 -0
- package/packages/solid/package.json +42 -0
- package/packages/solid/src/index.ts +206 -0
- package/packages/solid/tsconfig.json +8 -0
- package/packages/solid/tsup.config.ts +17 -0
- package/packages/svelte/package.json +42 -0
- package/packages/svelte/src/index.ts +199 -0
- package/packages/svelte/tsconfig.json +8 -0
- package/packages/svelte/tsup.config.ts +17 -0
- package/packages/vanilla/package.json +38 -0
- package/packages/vanilla/src/index.ts +254 -0
- package/packages/vanilla/tsconfig.json +8 -0
- package/packages/vanilla/tsup.config.ts +17 -0
- package/packages/vue/package.json +42 -0
- package/packages/vue/src/index.ts +217 -0
- package/packages/vue/tsconfig.json +8 -0
- package/packages/vue/tsup.config.ts +17 -0
- package/pnpm-workspace.yaml +3 -0
- package/tsconfig.base.json +21 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kayforms/devtools — Timeline Component
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Renders the action log timeline with scrubber controls.
|
|
5
|
+
// Pure DOM manipulation — no framework dependencies.
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
import { createEffect, type DevToolsBridge, type HistoryEntry } from "@kayforms/core";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Timeline Renderer
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export function createTimeline(
|
|
15
|
+
container: HTMLElement,
|
|
16
|
+
bridge: DevToolsBridge
|
|
17
|
+
): () => void {
|
|
18
|
+
const cleanups: (() => void)[] = [];
|
|
19
|
+
|
|
20
|
+
// --- Timeline entries list ---
|
|
21
|
+
const listEl = document.createElement("div");
|
|
22
|
+
listEl.className = "kf-timeline";
|
|
23
|
+
container.appendChild(listEl);
|
|
24
|
+
|
|
25
|
+
// --- Scrubber bar ---
|
|
26
|
+
const scrubberEl = document.createElement("div");
|
|
27
|
+
scrubberEl.className = "kf-scrubber";
|
|
28
|
+
|
|
29
|
+
const controlsEl = document.createElement("div");
|
|
30
|
+
controlsEl.className = "kf-scrubber-controls";
|
|
31
|
+
|
|
32
|
+
const undoBtn = document.createElement("button");
|
|
33
|
+
undoBtn.textContent = "⏪";
|
|
34
|
+
undoBtn.title = "Undo";
|
|
35
|
+
undoBtn.onclick = () => bridge.undo();
|
|
36
|
+
|
|
37
|
+
const redoBtn = document.createElement("button");
|
|
38
|
+
redoBtn.textContent = "⏩";
|
|
39
|
+
redoBtn.title = "Redo";
|
|
40
|
+
redoBtn.onclick = () => bridge.redo();
|
|
41
|
+
|
|
42
|
+
const resumeBtn = document.createElement("button");
|
|
43
|
+
resumeBtn.textContent = "▶";
|
|
44
|
+
resumeBtn.title = "Resume (live)";
|
|
45
|
+
resumeBtn.onclick = () => bridge.resume();
|
|
46
|
+
|
|
47
|
+
const clearBtn = document.createElement("button");
|
|
48
|
+
clearBtn.textContent = "🗑";
|
|
49
|
+
clearBtn.title = "Clear history";
|
|
50
|
+
clearBtn.onclick = () => bridge.clear();
|
|
51
|
+
|
|
52
|
+
controlsEl.append(undoBtn, redoBtn, resumeBtn, clearBtn);
|
|
53
|
+
|
|
54
|
+
const slider = document.createElement("input");
|
|
55
|
+
slider.type = "range";
|
|
56
|
+
slider.min = "0";
|
|
57
|
+
slider.max = "0";
|
|
58
|
+
slider.value = "0";
|
|
59
|
+
slider.addEventListener("input", () => {
|
|
60
|
+
const index = parseInt(slider.value, 10);
|
|
61
|
+
bridge.jumpTo(index);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const countEl = document.createElement("span");
|
|
65
|
+
countEl.className = "kf-scrubber-count";
|
|
66
|
+
countEl.textContent = "0";
|
|
67
|
+
|
|
68
|
+
scrubberEl.append(controlsEl, slider, countEl);
|
|
69
|
+
container.appendChild(scrubberEl);
|
|
70
|
+
|
|
71
|
+
// --- Render entries ---
|
|
72
|
+
function renderEntry(entry: HistoryEntry, index: number, isCurrent: boolean): HTMLElement {
|
|
73
|
+
const el = document.createElement("div");
|
|
74
|
+
el.className = `kf-timeline-entry${isCurrent ? " kf-current" : ""}`;
|
|
75
|
+
el.onclick = () => bridge.jumpTo(index);
|
|
76
|
+
|
|
77
|
+
// Color dot
|
|
78
|
+
const dot = document.createElement("div");
|
|
79
|
+
dot.className = `kf-entry-dot kf-action-${entry.action}`;
|
|
80
|
+
el.appendChild(dot);
|
|
81
|
+
|
|
82
|
+
// Content
|
|
83
|
+
const content = document.createElement("div");
|
|
84
|
+
content.className = "kf-entry-content";
|
|
85
|
+
|
|
86
|
+
// Action + path
|
|
87
|
+
const actionLine = document.createElement("div");
|
|
88
|
+
const actionSpan = document.createElement("span");
|
|
89
|
+
actionSpan.className = "kf-entry-action";
|
|
90
|
+
actionSpan.textContent = entry.action;
|
|
91
|
+
actionLine.appendChild(actionSpan);
|
|
92
|
+
|
|
93
|
+
if (entry.path) {
|
|
94
|
+
const pathSpan = document.createElement("span");
|
|
95
|
+
pathSpan.className = "kf-entry-path";
|
|
96
|
+
pathSpan.textContent = entry.path;
|
|
97
|
+
actionLine.appendChild(pathSpan);
|
|
98
|
+
}
|
|
99
|
+
content.appendChild(actionLine);
|
|
100
|
+
|
|
101
|
+
// Value change
|
|
102
|
+
if (entry.prevValue !== undefined || entry.nextValue !== undefined) {
|
|
103
|
+
const valuesEl = document.createElement("div");
|
|
104
|
+
valuesEl.className = "kf-entry-values";
|
|
105
|
+
|
|
106
|
+
if (entry.prevValue !== undefined && entry.action !== "INIT") {
|
|
107
|
+
const prevEl = document.createElement("span");
|
|
108
|
+
prevEl.className = "kf-entry-prev";
|
|
109
|
+
prevEl.textContent = truncate(JSON.stringify(entry.prevValue), 20);
|
|
110
|
+
valuesEl.appendChild(prevEl);
|
|
111
|
+
|
|
112
|
+
const arrow = document.createElement("span");
|
|
113
|
+
arrow.textContent = "→";
|
|
114
|
+
arrow.style.color = "#8899aa";
|
|
115
|
+
valuesEl.appendChild(arrow);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const nextEl = document.createElement("span");
|
|
119
|
+
nextEl.className = "kf-entry-next";
|
|
120
|
+
nextEl.textContent = truncate(JSON.stringify(entry.nextValue), 30);
|
|
121
|
+
valuesEl.appendChild(nextEl);
|
|
122
|
+
|
|
123
|
+
content.appendChild(valuesEl);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Timestamp
|
|
127
|
+
const timeEl = document.createElement("div");
|
|
128
|
+
timeEl.className = "kf-entry-time";
|
|
129
|
+
const date = new Date(entry.timestamp);
|
|
130
|
+
timeEl.textContent = `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}.${date.getMilliseconds().toString().padStart(3, "0")}`;
|
|
131
|
+
content.appendChild(timeEl);
|
|
132
|
+
|
|
133
|
+
el.appendChild(content);
|
|
134
|
+
return el;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Reactive updates ---
|
|
138
|
+
const disposeEffect = createEffect(() => {
|
|
139
|
+
const entries = bridge.history.value;
|
|
140
|
+
const cursorVal = bridge.cursor.value;
|
|
141
|
+
const currentIndex = cursorVal === -1 ? entries.length - 1 : cursorVal;
|
|
142
|
+
|
|
143
|
+
// Clear and re-render (simple approach — could be optimized with diffing)
|
|
144
|
+
listEl.innerHTML = "";
|
|
145
|
+
|
|
146
|
+
// Render in reverse (newest first)
|
|
147
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
148
|
+
const entryEl = renderEntry(entries[i], i, i === currentIndex);
|
|
149
|
+
listEl.appendChild(entryEl);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Update scrubber
|
|
153
|
+
slider.max = String(Math.max(0, entries.length - 1));
|
|
154
|
+
slider.value = String(currentIndex);
|
|
155
|
+
countEl.textContent = `${currentIndex + 1}/${entries.length}`;
|
|
156
|
+
|
|
157
|
+
// Auto-scroll to current entry
|
|
158
|
+
if (cursorVal === -1 && listEl.firstChild) {
|
|
159
|
+
(listEl.firstChild as HTMLElement).scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
cleanups.push(disposeEffect);
|
|
163
|
+
|
|
164
|
+
return () => {
|
|
165
|
+
for (const cleanup of cleanups) {
|
|
166
|
+
cleanup();
|
|
167
|
+
}
|
|
168
|
+
listEl.remove();
|
|
169
|
+
scrubberEl.remove();
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// State Inspector
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
export function createInspector(
|
|
178
|
+
container: HTMLElement,
|
|
179
|
+
bridge: DevToolsBridge
|
|
180
|
+
): () => void {
|
|
181
|
+
const inspectorEl = document.createElement("div");
|
|
182
|
+
inspectorEl.className = "kf-inspector";
|
|
183
|
+
container.appendChild(inspectorEl);
|
|
184
|
+
|
|
185
|
+
function renderTree(obj: unknown, depth = 0): HTMLElement {
|
|
186
|
+
const fragment = document.createElement("div");
|
|
187
|
+
|
|
188
|
+
if (obj === null || obj === undefined) {
|
|
189
|
+
const valueEl = document.createElement("span");
|
|
190
|
+
valueEl.className = "kf-tree-value";
|
|
191
|
+
valueEl.textContent = String(obj);
|
|
192
|
+
fragment.appendChild(valueEl);
|
|
193
|
+
return fragment;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (typeof obj !== "object") {
|
|
197
|
+
const valueEl = document.createElement("span");
|
|
198
|
+
valueEl.className = "kf-tree-value";
|
|
199
|
+
valueEl.textContent = JSON.stringify(obj);
|
|
200
|
+
fragment.appendChild(valueEl);
|
|
201
|
+
return fragment;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const entries = Object.entries(obj as Record<string, unknown>);
|
|
205
|
+
for (const [key, value] of entries) {
|
|
206
|
+
const nodeEl = document.createElement("div");
|
|
207
|
+
nodeEl.className = "kf-tree-node";
|
|
208
|
+
|
|
209
|
+
const keyEl = document.createElement("span");
|
|
210
|
+
keyEl.className = "kf-tree-key";
|
|
211
|
+
keyEl.textContent = key;
|
|
212
|
+
nodeEl.appendChild(keyEl);
|
|
213
|
+
|
|
214
|
+
const colonEl = document.createElement("span");
|
|
215
|
+
colonEl.className = "kf-tree-bracket";
|
|
216
|
+
colonEl.textContent = ": ";
|
|
217
|
+
nodeEl.appendChild(colonEl);
|
|
218
|
+
|
|
219
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
220
|
+
const bracketOpen = document.createElement("span");
|
|
221
|
+
bracketOpen.className = "kf-tree-bracket";
|
|
222
|
+
bracketOpen.textContent = "{";
|
|
223
|
+
nodeEl.appendChild(bracketOpen);
|
|
224
|
+
|
|
225
|
+
if (depth < 3) {
|
|
226
|
+
nodeEl.appendChild(renderTree(value, depth + 1));
|
|
227
|
+
} else {
|
|
228
|
+
const ellipsis = document.createElement("span");
|
|
229
|
+
ellipsis.className = "kf-tree-value";
|
|
230
|
+
ellipsis.textContent = "...";
|
|
231
|
+
nodeEl.appendChild(ellipsis);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const bracketClose = document.createElement("span");
|
|
235
|
+
bracketClose.className = "kf-tree-bracket";
|
|
236
|
+
bracketClose.textContent = "}";
|
|
237
|
+
nodeEl.appendChild(bracketClose);
|
|
238
|
+
} else {
|
|
239
|
+
const valueEl = document.createElement("span");
|
|
240
|
+
valueEl.className = "kf-tree-value";
|
|
241
|
+
valueEl.textContent = JSON.stringify(value);
|
|
242
|
+
nodeEl.appendChild(valueEl);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
fragment.appendChild(nodeEl);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return fragment;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const disposeEffect = createEffect(() => {
|
|
252
|
+
const entries = bridge.history.value;
|
|
253
|
+
const cursorVal = bridge.cursor.value;
|
|
254
|
+
const currentIndex = cursorVal === -1 ? entries.length - 1 : cursorVal;
|
|
255
|
+
|
|
256
|
+
inspectorEl.innerHTML = "";
|
|
257
|
+
|
|
258
|
+
const snapshot = bridge.getSnapshotAt(currentIndex);
|
|
259
|
+
if (snapshot) {
|
|
260
|
+
inspectorEl.appendChild(renderTree(snapshot));
|
|
261
|
+
} else if (entries.length === 0) {
|
|
262
|
+
const emptyEl = document.createElement("div");
|
|
263
|
+
emptyEl.style.cssText =
|
|
264
|
+
"padding: 20px; text-align: center; color: var(--kf-text-muted);";
|
|
265
|
+
emptyEl.textContent = "No form data recorded yet";
|
|
266
|
+
inspectorEl.appendChild(emptyEl);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return () => {
|
|
271
|
+
disposeEffect();
|
|
272
|
+
inspectorEl.remove();
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Helpers
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
function truncate(str: string, max: number): string {
|
|
281
|
+
if (str.length <= max) return str;
|
|
282
|
+
return str.slice(0, max - 1) + "…";
|
|
283
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from "tsup";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ["src/index.ts"],
|
|
5
|
+
format: ["esm", "cjs"],
|
|
6
|
+
dts: true,
|
|
7
|
+
clean: true,
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
minify: false,
|
|
10
|
+
treeshake: true,
|
|
11
|
+
external: ["@kayforms/core"],
|
|
12
|
+
outExtension({ format }) {
|
|
13
|
+
return {
|
|
14
|
+
js: format === "cjs" ? ".cjs" : ".js",
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kayforms/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React adapter for Kayforms reactive form library",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": ["dist"],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup",
|
|
24
|
+
"dev": "tsup --watch",
|
|
25
|
+
"clean": "rimraf dist",
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@kayforms/core": "workspace:*"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": ">=18.0.0",
|
|
33
|
+
"react-dom": ">=18.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/react": "^18.0.0",
|
|
37
|
+
"@types/react-dom": "^18.0.0",
|
|
38
|
+
"react": "^18.0.0",
|
|
39
|
+
"react-dom": "^18.0.0",
|
|
40
|
+
"tsup": "^8.0.0",
|
|
41
|
+
"typescript": "^5.5.0",
|
|
42
|
+
"rimraf": "^5.0.0"
|
|
43
|
+
},
|
|
44
|
+
"sideEffects": false,
|
|
45
|
+
"license": "MIT"
|
|
46
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kayforms/react — React Adapter
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Bridges Kayforms signals to React re-renders using useSyncExternalStore.
|
|
5
|
+
// Each useField() subscribes only to its own FieldNode — parent components
|
|
6
|
+
// never re-render on individual field changes.
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
useSyncExternalStore,
|
|
11
|
+
useCallback,
|
|
12
|
+
useRef,
|
|
13
|
+
useEffect,
|
|
14
|
+
createContext,
|
|
15
|
+
useContext,
|
|
16
|
+
createElement,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
} from "react";
|
|
19
|
+
import {
|
|
20
|
+
createForm,
|
|
21
|
+
type FormConfig,
|
|
22
|
+
type FormStore,
|
|
23
|
+
type FormErrors,
|
|
24
|
+
type FieldNode,
|
|
25
|
+
type Signal,
|
|
26
|
+
type Computed,
|
|
27
|
+
} from "@kayforms/core";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Signal → React bridge
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Subscribe to a Kayforms signal inside a React component.
|
|
35
|
+
* Uses useSyncExternalStore for tear-free concurrent rendering.
|
|
36
|
+
*/
|
|
37
|
+
export function useSignalValue<T>(signal: Signal<T> | Computed<T>): T {
|
|
38
|
+
const subscribe = useCallback(
|
|
39
|
+
(onStoreChange: () => void) => {
|
|
40
|
+
return signal.subscribe(onStoreChange);
|
|
41
|
+
},
|
|
42
|
+
[signal]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const getSnapshot = useCallback(() => signal.value, [signal]);
|
|
46
|
+
|
|
47
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// FormContext
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const FormContext = createContext<FormStore | null>(null);
|
|
55
|
+
|
|
56
|
+
function useFormContext(): FormStore {
|
|
57
|
+
const ctx = useContext(FormContext);
|
|
58
|
+
if (!ctx) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"[kayforms/react] useField must be used inside a <FormProvider>. " +
|
|
61
|
+
"Wrap your form with <FormProvider form={...}>."
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return ctx;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// FormProvider
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
export interface FormProviderProps {
|
|
72
|
+
form: FormStore;
|
|
73
|
+
children: ReactNode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Provides form context to nested useField() hooks.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```tsx
|
|
81
|
+
* const form = useForm({ initialValues: { name: '' } });
|
|
82
|
+
* return (
|
|
83
|
+
* <FormProvider form={form.store}>
|
|
84
|
+
* <NameField />
|
|
85
|
+
* </FormProvider>
|
|
86
|
+
* );
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function FormProvider({ form, children }: FormProviderProps) {
|
|
90
|
+
return createElement(FormContext.Provider, { value: form }, children);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// useForm
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export interface UseFormReturn<T extends Record<string, unknown>> {
|
|
98
|
+
/** The reactive form store */
|
|
99
|
+
store: FormStore<T>;
|
|
100
|
+
/** Current values (reactive) */
|
|
101
|
+
values: T;
|
|
102
|
+
/** Current errors (reactive) */
|
|
103
|
+
errors: FormErrors<T>;
|
|
104
|
+
/** Whether the form is dirty */
|
|
105
|
+
dirty: boolean;
|
|
106
|
+
/** Whether the form is valid */
|
|
107
|
+
valid: boolean;
|
|
108
|
+
/** Whether the form is submitting */
|
|
109
|
+
submitting: boolean;
|
|
110
|
+
/** Handle form submission (pass to <form onSubmit>) */
|
|
111
|
+
handleSubmit: (e?: { preventDefault?: () => void }) => void;
|
|
112
|
+
/** Reset the form */
|
|
113
|
+
reset: (values?: Partial<T>) => void;
|
|
114
|
+
/** Get a field node by path */
|
|
115
|
+
getField: <V = unknown>(path: string) => FieldNode<V>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create and manage a Kayforms form in a React component.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```tsx
|
|
123
|
+
* function LoginForm() {
|
|
124
|
+
* const { handleSubmit, getField, valid, submitting } = useForm({
|
|
125
|
+
* initialValues: { email: '', password: '' },
|
|
126
|
+
* fieldValidators: {
|
|
127
|
+
* email: [validators.required(), validators.email()],
|
|
128
|
+
* password: [validators.required(), validators.minLength(8)],
|
|
129
|
+
* },
|
|
130
|
+
* onSubmit: async (values) => {
|
|
131
|
+
* await login(values);
|
|
132
|
+
* },
|
|
133
|
+
* });
|
|
134
|
+
*
|
|
135
|
+
* return (
|
|
136
|
+
* <form onSubmit={handleSubmit}>
|
|
137
|
+
* <Field field={getField('email')} />
|
|
138
|
+
* <Field field={getField('password')} type="password" />
|
|
139
|
+
* <button disabled={!valid || submitting}>Login</button>
|
|
140
|
+
* </form>
|
|
141
|
+
* );
|
|
142
|
+
* }
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
export function useForm<T extends Record<string, unknown>>(
|
|
146
|
+
config: FormConfig<T>
|
|
147
|
+
): UseFormReturn<T> {
|
|
148
|
+
// Create form store once (stable reference)
|
|
149
|
+
const storeRef = useRef<FormStore<T> | null>(null);
|
|
150
|
+
if (!storeRef.current) {
|
|
151
|
+
storeRef.current = createForm(config);
|
|
152
|
+
}
|
|
153
|
+
const store = storeRef.current;
|
|
154
|
+
|
|
155
|
+
// Cleanup on unmount
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
return () => {
|
|
158
|
+
store.dispose();
|
|
159
|
+
};
|
|
160
|
+
}, [store]);
|
|
161
|
+
|
|
162
|
+
// Subscribe to reactive values
|
|
163
|
+
const values = useSignalValue(store.values);
|
|
164
|
+
const errors = useSignalValue(store.errors);
|
|
165
|
+
const dirty = useSignalValue(store.dirty);
|
|
166
|
+
const valid = useSignalValue(store.valid);
|
|
167
|
+
const submitting = useSignalValue(store.submitting);
|
|
168
|
+
|
|
169
|
+
const handleSubmit = useCallback(
|
|
170
|
+
(e?: { preventDefault?: () => void }) => {
|
|
171
|
+
e?.preventDefault?.();
|
|
172
|
+
store.submit();
|
|
173
|
+
},
|
|
174
|
+
[store]
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const reset = useCallback(
|
|
178
|
+
(newValues?: Partial<T>) => {
|
|
179
|
+
store.reset(newValues);
|
|
180
|
+
},
|
|
181
|
+
[store]
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
store,
|
|
186
|
+
values,
|
|
187
|
+
errors,
|
|
188
|
+
dirty,
|
|
189
|
+
valid,
|
|
190
|
+
submitting,
|
|
191
|
+
handleSubmit,
|
|
192
|
+
reset,
|
|
193
|
+
getField: store.getField,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// useField
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
export interface UseFieldReturn<V = unknown> {
|
|
202
|
+
/** Current field value */
|
|
203
|
+
value: V;
|
|
204
|
+
/** Current error message (undefined = valid) */
|
|
205
|
+
error: string | undefined;
|
|
206
|
+
/** Whether the field has been touched */
|
|
207
|
+
touched: boolean;
|
|
208
|
+
/** Whether the field value differs from initial */
|
|
209
|
+
dirty: boolean;
|
|
210
|
+
/** Whether async validation is in progress */
|
|
211
|
+
validating: boolean;
|
|
212
|
+
/** Update the field value */
|
|
213
|
+
onChange: (value: V) => void;
|
|
214
|
+
/** Mark as touched (call on blur) */
|
|
215
|
+
onBlur: () => void;
|
|
216
|
+
/** Props to spread on an input element */
|
|
217
|
+
inputProps: {
|
|
218
|
+
value: V;
|
|
219
|
+
onChange: (e: { target: { value: unknown } }) => void;
|
|
220
|
+
onBlur: () => void;
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Subscribe to a single field's reactive state.
|
|
226
|
+
* Only re-renders when THIS field changes — not other fields.
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```tsx
|
|
230
|
+
* function EmailField() {
|
|
231
|
+
* const { inputProps, error, touched } = useField<string>('email');
|
|
232
|
+
* return (
|
|
233
|
+
* <div>
|
|
234
|
+
* <input {...inputProps} type="email" />
|
|
235
|
+
* {touched && error && <span className="error">{error}</span>}
|
|
236
|
+
* </div>
|
|
237
|
+
* );
|
|
238
|
+
* }
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
export function useField<V = unknown>(path: string): UseFieldReturn<V> {
|
|
242
|
+
const store = useFormContext();
|
|
243
|
+
const field = store.getField<V>(path);
|
|
244
|
+
|
|
245
|
+
const value = useSignalValue(field.value);
|
|
246
|
+
const error = useSignalValue(field.error);
|
|
247
|
+
const touched = useSignalValue(field.touched);
|
|
248
|
+
const dirty = useSignalValue(field.dirty);
|
|
249
|
+
const validating = useSignalValue(field.validating);
|
|
250
|
+
|
|
251
|
+
const onChange = useCallback(
|
|
252
|
+
(v: V) => field.onChange(v),
|
|
253
|
+
[field]
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const onBlur = useCallback(() => field.onBlur(), [field]);
|
|
257
|
+
|
|
258
|
+
const handleInputChange = useCallback(
|
|
259
|
+
(e: { target: { value: unknown } }) => {
|
|
260
|
+
field.onChange(e.target.value as V);
|
|
261
|
+
},
|
|
262
|
+
[field]
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
value,
|
|
267
|
+
error,
|
|
268
|
+
touched,
|
|
269
|
+
dirty,
|
|
270
|
+
validating,
|
|
271
|
+
onChange,
|
|
272
|
+
onBlur,
|
|
273
|
+
inputProps: {
|
|
274
|
+
value,
|
|
275
|
+
onChange: handleInputChange,
|
|
276
|
+
onBlur,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from "tsup";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ["src/index.ts"],
|
|
5
|
+
format: ["esm", "cjs"],
|
|
6
|
+
dts: true,
|
|
7
|
+
clean: true,
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
minify: false,
|
|
10
|
+
treeshake: true,
|
|
11
|
+
external: ["react", "react-dom", "@kayforms/core"],
|
|
12
|
+
outExtension({ format }) {
|
|
13
|
+
return {
|
|
14
|
+
js: format === "cjs" ? ".cjs" : ".js",
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kayforms/solid",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Solid JS adapter for Kayforms reactive form library",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": ["dist"],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup",
|
|
24
|
+
"dev": "tsup --watch",
|
|
25
|
+
"clean": "rimraf dist",
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@kayforms/core": "workspace:*"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"solid-js": "^1.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"solid-js": "^1.8.0",
|
|
36
|
+
"tsup": "^8.0.0",
|
|
37
|
+
"typescript": "^5.5.0",
|
|
38
|
+
"rimraf": "^5.0.0"
|
|
39
|
+
},
|
|
40
|
+
"sideEffects": false,
|
|
41
|
+
"license": "MIT"
|
|
42
|
+
}
|