@goodie-forms/core 1.0.0-alpha

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.
@@ -0,0 +1,290 @@
1
+ import { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import { enableArrayMethods, enableMapSet, produce } from "immer";
3
+ import { createNanoEvents } from "nanoevents";
4
+ import { Field } from "../form/Field";
5
+ import { DeepPartial } from "../types/DeepPartial";
6
+ import { removeBy } from "../utils/removeBy";
7
+ import { FormField } from "./FormField";
8
+
9
+ enableMapSet();
10
+ enableArrayMethods();
11
+
12
+ export namespace Form {
13
+ export type Status = "idle" | "validating" | "submitting";
14
+
15
+ export type FormConfigs<TShape extends object> = ConstructorParameters<
16
+ typeof FormController<TShape>
17
+ >[0];
18
+
19
+ export interface PreventableEvent {
20
+ preventDefault(): void;
21
+ }
22
+
23
+ export type SubmitSuccessHandler<
24
+ TShape extends object,
25
+ TEvent extends PreventableEvent,
26
+ > = (
27
+ data: TShape,
28
+ event: TEvent,
29
+ abortSignal: AbortSignal,
30
+ ) => void | Promise<void>;
31
+
32
+ export type SubmitErrorHandler<TEvent extends PreventableEvent> = (
33
+ issues: StandardSchemaV1.Issue[],
34
+ event: TEvent,
35
+ abortSignal: AbortSignal,
36
+ ) => void | Promise<void>;
37
+ }
38
+
39
+ export class FormController<TShape extends object = object> {
40
+ _status: Form.Status = "idle";
41
+ _fields = new Map<Field.Paths<TShape>, FormField<TShape, any>>();
42
+ _initialData: DeepPartial<TShape>;
43
+ _data: DeepPartial<TShape>;
44
+ _issues: StandardSchemaV1.Issue[] = [];
45
+
46
+ equalityComparators?: Record<any, (a: any, b: any) => boolean>;
47
+ validationSchema?: StandardSchemaV1<TShape, TShape>;
48
+
49
+ public readonly events = createNanoEvents<{
50
+ statusChanged(newStatus: Form.Status, oldStatus: Form.Status): void;
51
+ fieldBound(fieldPath: Field.Paths<TShape>): void;
52
+ fieldUnbound(fieldPath: Field.Paths<TShape>): void;
53
+ fieldTouchUpdated(path: Field.Paths<TShape>): void;
54
+ fieldDirtyUpdated(path: Field.Paths<TShape>): void;
55
+ elementBound(fieldPath: Field.Paths<TShape>, el: HTMLElement): void;
56
+ elementUnbound(fieldPath: Field.Paths<TShape>): void;
57
+ validationTriggered(fieldPath: Field.Paths<TShape>): void;
58
+ validationIssuesUpdated(fieldPath: Field.Paths<TShape>): void;
59
+ valueChanged(
60
+ path: Field.Paths<TShape>,
61
+ newValue: Field.GetValue<TShape, Field.Paths<TShape>> | undefined,
62
+ oldValue: Field.GetValue<TShape, Field.Paths<TShape>> | undefined,
63
+ ): void;
64
+ }>();
65
+
66
+ constructor(config: {
67
+ initialData?: DeepPartial<TShape>;
68
+ validationSchema?: StandardSchemaV1<TShape, TShape>;
69
+ equalityComparators?: Record<any, (a: any, b: any) => boolean>;
70
+ }) {
71
+ this.validationSchema = config.validationSchema;
72
+ this.equalityComparators = config.equalityComparators;
73
+ this._initialData = config.initialData ?? ({} as DeepPartial<TShape>);
74
+ this._data = produce(this._initialData, () => {});
75
+ }
76
+
77
+ get isDirty() {
78
+ for (const field of this._fields.values()) {
79
+ if (field.isDirty) return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ get isValid() {
85
+ return this._issues.length === 0;
86
+ }
87
+
88
+ get isSubmitting() {
89
+ return this._status === "submitting";
90
+ }
91
+
92
+ protected setStatus(newStatus: Form.Status) {
93
+ if (newStatus === this._status) return;
94
+ const oldStatus = this._status;
95
+ this._status = newStatus;
96
+ this.events.emit("statusChanged", newStatus, oldStatus);
97
+ }
98
+
99
+ _unsafeSetFieldValue<TPath extends Field.Paths<TShape>>(
100
+ path: TPath,
101
+ value: Field.GetValue<TShape, TPath>,
102
+ config?: { updateInitialValue?: boolean },
103
+ ) {
104
+ if (config?.updateInitialValue) {
105
+ this._initialData = produce(this._initialData, (draft) => {
106
+ Field.setValue<TShape, TPath>(draft as TShape, path, value);
107
+ });
108
+ }
109
+ this._data = produce(this._data, (draft) => {
110
+ Field.setValue<TShape, TPath>(draft as TShape, path, value);
111
+ });
112
+ }
113
+
114
+ bindField<TPath extends Field.Paths<TShape>>(
115
+ path: TPath,
116
+ config?: {
117
+ defaultValue?: Field.GetValue<TShape, TPath>;
118
+ domElement?: HTMLElement;
119
+ },
120
+ ) {
121
+ const field = new FormField(this, path);
122
+
123
+ console.log("Binding", path, config?.defaultValue, field.id);
124
+ this._fields.set(path, field);
125
+ this.events.emit("fieldBound", path);
126
+
127
+ if (config?.defaultValue != null) {
128
+ this._unsafeSetFieldValue(path, config.defaultValue, {
129
+ updateInitialValue: true,
130
+ });
131
+ }
132
+
133
+ if (config?.domElement != null) {
134
+ field.bindElement(config.domElement);
135
+ }
136
+
137
+ return field;
138
+ }
139
+
140
+ unbindField(path: Field.Paths<TShape>) {
141
+ this._fields.delete(path);
142
+ this.events.emit("fieldUnbound", path);
143
+ }
144
+
145
+ // TODO: Add an option to keep dirty/touched fields as they are
146
+ reset(newInitialData?: DeepPartial<TShape>) {
147
+ this.setStatus("idle");
148
+ this._data = this._initialData;
149
+ this._issues = [];
150
+
151
+ for (const field of this._fields.values()) {
152
+ field.reset();
153
+ }
154
+
155
+ if (newInitialData != null) {
156
+ this._initialData = newInitialData;
157
+ this._data = produce(this._initialData, () => {});
158
+ }
159
+ }
160
+
161
+ getField<TPath extends Field.Paths<TShape>>(
162
+ path: TPath,
163
+ config: { bindIfMissing: true },
164
+ ): FormField<TShape, TPath>;
165
+ getField<TPath extends Field.Paths<TShape>>(
166
+ path: TPath,
167
+ ): FormField<TShape, TPath> | undefined;
168
+ getField<TPath extends Field.Paths<TShape>>(
169
+ path: TPath,
170
+ config?: { bindIfMissing?: boolean },
171
+ ) {
172
+ let field = this._fields.get(path);
173
+
174
+ if (field == null && config?.bindIfMissing) {
175
+ field = this.bindField(path);
176
+ }
177
+
178
+ return field;
179
+ }
180
+
181
+ clearFieldIssues<TPath extends Field.Paths<TShape>>(path: TPath) {
182
+ this._issues = this._issues.filter((issue) => {
183
+ if (issue.path == null) return true;
184
+ const issuePath = issue.path.join(".");
185
+ return issuePath !== path;
186
+ });
187
+ }
188
+
189
+ private async applyValidation<TPath extends Field.Paths<TShape>>(
190
+ _result: StandardSchemaV1.Result<TShape>,
191
+ path: TPath,
192
+ ) {
193
+ const diff = Field.diff(
194
+ this._issues,
195
+ _result.issues ?? [],
196
+ Field.deepEqual,
197
+ (issue) => {
198
+ if (issue.path == null) return false;
199
+ const issuePath = issue.path.join(".");
200
+ return issuePath === path;
201
+ },
202
+ );
203
+
204
+ removeBy(this._issues, (issue) => diff.removed.includes(issue));
205
+
206
+ diff.added.forEach((issue) => this._issues.push(issue));
207
+
208
+ if (diff.added.length !== 0 || diff.removed.length !== 0) {
209
+ this.events.emit("validationIssuesUpdated", path);
210
+ }
211
+ }
212
+
213
+ async validateField<TPath extends Field.Paths<TShape>>(path: TPath) {
214
+ if (this._status !== "idle") return;
215
+
216
+ if (this.validationSchema == null) return;
217
+
218
+ this.setStatus("validating");
219
+
220
+ this.getField(path, { bindIfMissing: true });
221
+
222
+ const result = await this.validationSchema["~standard"].validate(
223
+ this._data,
224
+ );
225
+
226
+ this.events.emit("validationTriggered", path);
227
+ this.applyValidation(result, path);
228
+
229
+ this.setStatus("idle");
230
+ }
231
+
232
+ async validateForm() {
233
+ if (this._status !== "idle") return;
234
+
235
+ if (this.validationSchema == null) return;
236
+
237
+ this.setStatus("validating");
238
+
239
+ const result = await this.validationSchema["~standard"].validate(
240
+ this._data,
241
+ );
242
+
243
+ for (const path of this._fields.keys()) {
244
+ this.events.emit("validationTriggered", path);
245
+ this.applyValidation(result, path);
246
+ }
247
+
248
+ // Append non-registered issues too
249
+ const diff = Field.diff(this._issues, result.issues ?? [], Field.deepEqual);
250
+ diff.added.forEach((issue) => this._issues.push(issue));
251
+
252
+ this.setStatus("idle");
253
+ }
254
+
255
+ createSubmitHandler<TEvent extends Form.PreventableEvent>(
256
+ onSuccess?: Form.SubmitSuccessHandler<TShape, TEvent>,
257
+ onError?: Form.SubmitErrorHandler<TEvent>,
258
+ ) {
259
+ return async (event: TEvent) => {
260
+ if (event != null) {
261
+ event.preventDefault();
262
+ }
263
+
264
+ if (this._status !== "idle") return;
265
+
266
+ const abortController = new AbortController();
267
+
268
+ await this.validateForm();
269
+
270
+ if (this._issues.length === 0) {
271
+ this.setStatus("submitting");
272
+ await onSuccess?.(this._data as TShape, event, abortController.signal);
273
+ this.setStatus("idle");
274
+ return;
275
+ }
276
+
277
+ for (const issue of this._issues) {
278
+ if (issue.path == null) continue;
279
+ const fieldPath = issue.path.join(".") as Field.Paths<TShape>;
280
+ const field = this.getField(fieldPath);
281
+ if (field == null) continue;
282
+ if (field.boundElement == null) continue;
283
+ field.focus();
284
+ break;
285
+ }
286
+ await onError?.(this._issues, event, abortController.signal);
287
+ this.setStatus("idle");
288
+ };
289
+ }
290
+ }
@@ -0,0 +1,199 @@
1
+ import { immerable, produce } from "immer";
2
+ import { getId } from "../utils/getId";
3
+ import { Field } from "./Field";
4
+ import { FormController } from "./FormController";
5
+
6
+ export class FormField<
7
+ TShape extends object,
8
+ TPath extends Field.Paths<TShape>,
9
+ > {
10
+ public readonly id = getId();
11
+
12
+ protected target?: HTMLElement;
13
+
14
+ protected _isTouched = false;
15
+ protected _isDirty = false;
16
+
17
+ constructor(
18
+ public readonly controller: FormController<TShape>,
19
+ public readonly path: TPath,
20
+ ) {}
21
+
22
+ get value() {
23
+ return Field.getValue<TShape, TPath>(
24
+ this.controller._data as TShape,
25
+ this.path,
26
+ );
27
+ }
28
+
29
+ get boundElement() {
30
+ return this.target;
31
+ }
32
+
33
+ get issues() {
34
+ return this.controller._issues.filter(
35
+ (issue) => issue.path?.join(".") === this.path,
36
+ );
37
+ }
38
+
39
+ get isTouched() {
40
+ return this._isTouched;
41
+ }
42
+
43
+ get isDirty() {
44
+ return this._isDirty;
45
+ }
46
+
47
+ get isValid() {
48
+ return this.issues.length === 0;
49
+ }
50
+
51
+ protected _setTouched(isTouched: boolean) {
52
+ const changed = this._isTouched !== isTouched;
53
+ this._isTouched = isTouched;
54
+ if (changed) this.controller.events.emit("fieldTouchUpdated", this.path);
55
+ }
56
+
57
+ protected _setDirty(isDirty: boolean) {
58
+ const changed = this._isDirty !== isDirty;
59
+ this._isDirty = isDirty;
60
+ if (changed) this.controller.events.emit("fieldDirtyUpdated", this.path);
61
+ }
62
+
63
+ bindElement(el: HTMLElement | undefined) {
64
+ if (el != null) this.controller.events.emit("elementBound", this.path, el);
65
+ else this.controller.events.emit("elementUnbound", this.path);
66
+ this.target = el;
67
+ }
68
+
69
+ protected static ensureImmerability(value: any) {
70
+ if (typeof value !== "object" || value === null) return;
71
+
72
+ // Skip plain objects
73
+ const proto = Object.getPrototypeOf(value);
74
+ if (proto === Object.prototype || proto === null) return;
75
+
76
+ const ctor = proto.constructor;
77
+ if (typeof ctor !== "function") return;
78
+
79
+ // Skip known built-ins
80
+ if (
81
+ value instanceof Date ||
82
+ value instanceof RegExp ||
83
+ value instanceof Map ||
84
+ value instanceof Set ||
85
+ value instanceof WeakMap ||
86
+ value instanceof WeakSet ||
87
+ ArrayBuffer.isView(value)
88
+ ) {
89
+ return;
90
+ }
91
+
92
+ if (ctor[immerable] === true) return;
93
+
94
+ // Define non-enumerable immerable flag
95
+ ctor[immerable] = true;
96
+ }
97
+
98
+ setValue(
99
+ value: Field.GetValue<TShape, TPath>,
100
+ opts?: Parameters<typeof this.modifyValue>[1],
101
+ ) {
102
+ return this.modifyValue(() => value, opts);
103
+ }
104
+
105
+ modifyValue(
106
+ modifier: (
107
+ currentValue: Field.GetValue<TShape, TPath>,
108
+ field: this,
109
+ ) => Field.GetValue<TShape, TPath> | void,
110
+ opts?: {
111
+ shouldTouch?: boolean;
112
+ shouldMarkDirty?: boolean;
113
+ },
114
+ ) {
115
+ if (opts?.shouldTouch == null || opts?.shouldTouch) {
116
+ this.touch();
117
+ }
118
+
119
+ const initialValue = Field.getValue<TShape, TPath>(
120
+ this.controller._initialData as TShape,
121
+ this.path,
122
+ );
123
+
124
+ const oldValue = Field.getValue<TShape, TPath>(
125
+ this.controller._data as TShape,
126
+ this.path,
127
+ );
128
+
129
+ FormField.ensureImmerability(initialValue);
130
+ FormField.ensureImmerability(oldValue);
131
+
132
+ this.controller._data = produce(this.controller._data, (draft) => {
133
+ Field.modifyValue<TShape, TPath>(draft as TShape, this.path, (oldValue) =>
134
+ modifier(oldValue, this),
135
+ );
136
+ });
137
+
138
+ const newValue = Field.getValue<TShape, TPath>(
139
+ this.controller._data as TShape,
140
+ this.path,
141
+ );
142
+
143
+ FormField.ensureImmerability(newValue);
144
+
145
+ const compareCustom = (a: any, b: any) => {
146
+ if (typeof a !== "object") return;
147
+ if (typeof b !== "object") return;
148
+ const ctorA = a.constructor;
149
+ const ctorB = b.constructor;
150
+ if (ctorA !== ctorB) return;
151
+ return this.controller.equalityComparators?.[ctorA]?.(a, b);
152
+ };
153
+
154
+ const valueChanged = !Field.deepEqual(oldValue, newValue, compareCustom);
155
+
156
+ if (valueChanged) {
157
+ this.controller.events.emit(
158
+ "valueChanged",
159
+ this.path,
160
+ newValue,
161
+ oldValue,
162
+ );
163
+ }
164
+
165
+ if (opts?.shouldMarkDirty == null || opts?.shouldMarkDirty) {
166
+ const gotDirty = !Field.deepEqual(initialValue, newValue, compareCustom);
167
+ this._setDirty(gotDirty);
168
+ }
169
+ }
170
+
171
+ reset() {
172
+ this._setTouched(false);
173
+ this._setDirty(false);
174
+ }
175
+
176
+ touch() {
177
+ this._setTouched(true);
178
+ }
179
+
180
+ markDirty() {
181
+ this.touch();
182
+ this._setDirty(true);
183
+ }
184
+
185
+ triggerValidation() {
186
+ this.controller.validateField(this.path);
187
+ }
188
+
189
+ focus(opts?: { shouldTouch?: boolean }) {
190
+ if (opts?.shouldTouch == null || opts.shouldTouch) {
191
+ this.target?.addEventListener("focus", () => this.touch(), {
192
+ once: true,
193
+ });
194
+ }
195
+
196
+ this.target?.scrollIntoView();
197
+ this.target?.focus();
198
+ }
199
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./form/Field";
2
+ export * from "./form/FormField";
3
+ export * from "./form/FormController";
@@ -0,0 +1,3 @@
1
+ export type DeepPartial<T extends object> = {
2
+ [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
3
+ };
@@ -0,0 +1,5 @@
1
+ let id = 0;
2
+
3
+ export function getId() {
4
+ return id++;
5
+ }
@@ -0,0 +1,11 @@
1
+ export function removeBy<T>(arr: T[], predicate: (item: T) => boolean) {
2
+ let indices: number[] = [];
3
+
4
+ for (let i = arr.length - 1; i >= 0; i--) {
5
+ if (predicate(arr[i])) {
6
+ indices.push(i);
7
+ }
8
+ }
9
+
10
+ indices.forEach((i) => arr.splice(i, 1));
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist"
5
+ },
6
+ "include": ["src"]
7
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "vite";
2
+
3
+ import dts from "vite-plugin-dts";
4
+
5
+ export default defineConfig({
6
+ plugins: [dts()],
7
+ build: {
8
+ lib: {
9
+ entry: "src/index.ts",
10
+ formats: ["es"],
11
+ fileName: "index",
12
+ },
13
+ rollupOptions: {
14
+ external: [],
15
+ },
16
+ sourcemap: true,
17
+ },
18
+ });