mayoiga 0.1.7 → 0.2.0
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/dist/component.d.ts +19 -3
- package/dist/component.js +20 -3
- package/dist/index.d.ts +153 -10
- package/dist/index.js +112 -23
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +5 -0
- package/package.json +34 -35
- package/.eslintrc.js +0 -111
- package/.github/workflows/checker.yml +0 -42
- package/.gitignore +0 -217
- package/.prettierrc +0 -5
- package/dist/component.test.d.ts +0 -1
- package/dist/component.test.js +0 -112
- package/dist/index.test.d.ts +0 -1
- package/dist/index.test.js +0 -113
- package/docs/.nojekyll +0 -1
- package/docs/assets/highlight.css +0 -92
- package/docs/assets/icons.css +0 -1043
- package/docs/assets/icons.png +0 -0
- package/docs/assets/icons@2x.png +0 -0
- package/docs/assets/main.js +0 -52
- package/docs/assets/search.js +0 -1
- package/docs/assets/style.css +0 -1414
- package/docs/assets/widgets.png +0 -0
- package/docs/assets/widgets@2x.png +0 -0
- package/docs/index.html +0 -41
- package/docs/modules.html +0 -4
- package/example/src/demo-app.tsx +0 -123
- package/example/src/index.tsx +0 -15
- package/setup/vite-global-setup.js +0 -1
- package/setup/vite-set-global.js +0 -0
- package/src/component.test.tsx +0 -204
- package/src/component.tsx +0 -23
- package/src/index.test.tsx +0 -176
- package/src/index.ts +0 -352
- package/src/utils.ts +0 -8
- package/tsconfig.json +0 -38
- package/vite.config.js +0 -10
- package/yarn.lock +0 -2649
package/dist/component.d.ts
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
1
|
import type { SliceProps, FieldProps, StateRestriction, Controller } from './index';
|
|
3
2
|
type ControlledComponentProps<State extends StateRestriction, Props> = Props & {
|
|
4
3
|
controller: Controller<State>;
|
|
5
4
|
};
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Controller-driven variant of `<Slicer>` for reusable components.
|
|
7
|
+
*
|
|
8
|
+
* Functionally identical to `controller.components.Slicer`, but accepts the
|
|
9
|
+
* `controller` as a prop rather than being bound to a single form instance.
|
|
10
|
+
* Use this when you want to author a shared component (a date range picker,
|
|
11
|
+
* a multi-step section, etc.) that works against any form whose state satisfies
|
|
12
|
+
* its required shape.
|
|
13
|
+
*/
|
|
14
|
+
export declare function Slicer<State extends StateRestriction, R extends ReadonlyArray<unknown>>(props: ControlledComponentProps<State, SliceProps<State, R>>): import("react/jsx-runtime").JSX.Element;
|
|
15
|
+
/**
|
|
16
|
+
* Controller-driven variant of `<Field>` for reusable components.
|
|
17
|
+
*
|
|
18
|
+
* Functionally identical to `controller.components.Field`, but accepts the
|
|
19
|
+
* `controller` as a prop so it can be rendered against any compatible form.
|
|
20
|
+
* The `name` prop is still constrained to keys of the controller's state,
|
|
21
|
+
* preserving end-to-end type safety.
|
|
22
|
+
*/
|
|
23
|
+
export declare function Field<State extends StateRestriction, Name extends keyof State>(props: ControlledComponentProps<State, FieldProps<State, Name>>): import("react/jsx-runtime").JSX.Element;
|
|
8
24
|
export {};
|
package/dist/component.js
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Controller-driven variant of `<Slicer>` for reusable components.
|
|
4
|
+
*
|
|
5
|
+
* Functionally identical to `controller.components.Slicer`, but accepts the
|
|
6
|
+
* `controller` as a prop rather than being bound to a single form instance.
|
|
7
|
+
* Use this when you want to author a shared component (a date range picker,
|
|
8
|
+
* a multi-step section, etc.) that works against any form whose state satisfies
|
|
9
|
+
* its required shape.
|
|
10
|
+
*/
|
|
2
11
|
export function Slicer(props) {
|
|
3
12
|
const { controller, ...restProps } = props;
|
|
4
13
|
const { Slicer: Delegate } = controller.components;
|
|
5
|
-
return
|
|
14
|
+
return _jsx(Delegate, { ...restProps });
|
|
6
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Controller-driven variant of `<Field>` for reusable components.
|
|
18
|
+
*
|
|
19
|
+
* Functionally identical to `controller.components.Field`, but accepts the
|
|
20
|
+
* `controller` as a prop so it can be rendered against any compatible form.
|
|
21
|
+
* The `name` prop is still constrained to keys of the controller's state,
|
|
22
|
+
* preserving end-to-end type safety.
|
|
23
|
+
*/
|
|
7
24
|
export function Field(props) {
|
|
8
25
|
const { controller, ...restProps } = props;
|
|
9
26
|
const { Field: Delegate } = controller.components;
|
|
10
|
-
return
|
|
27
|
+
return _jsx(Delegate, { ...restProps });
|
|
11
28
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { Subscriber } from 'nozuchi';
|
|
2
|
-
import type { BaseSyntheticEvent, ChangeEvent, ReactNode
|
|
3
|
-
import type { infer as zodInfer
|
|
4
|
-
|
|
2
|
+
import type { BaseSyntheticEvent, ChangeEvent, ReactNode } from 'react';
|
|
3
|
+
import type { ZodIssue, ZodType, infer as zodInfer } from 'zod';
|
|
4
|
+
/**
|
|
5
|
+
* Base constraint for any form state shape handled by Mayoiga.
|
|
6
|
+
*
|
|
7
|
+
* State is expected to be a flat (shallow) record. Nested objects are not traversed
|
|
8
|
+
* by the built-in change/validation machinery.
|
|
9
|
+
*/
|
|
10
|
+
export type StateRestriction = Record<string, unknown>;
|
|
5
11
|
type FormStatus = {
|
|
6
12
|
isDirty: boolean;
|
|
7
13
|
isValid: boolean;
|
|
@@ -9,11 +15,39 @@ type FormStatus = {
|
|
|
9
15
|
type FormErrors<State extends StateRestriction> = {
|
|
10
16
|
[k in keyof State]: string | null;
|
|
11
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Complete snapshot of a form: the current values, per-field error messages,
|
|
20
|
+
* and top-level status flags.
|
|
21
|
+
*
|
|
22
|
+
* @typeParam State - the pre-validation shape of the form
|
|
23
|
+
*/
|
|
12
24
|
export type FullFormState<State extends StateRestriction> = FormStatus & {
|
|
13
25
|
value: State;
|
|
14
26
|
errors: FormErrors<State>;
|
|
15
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* Signature of the handler used to update a single field value.
|
|
30
|
+
*
|
|
31
|
+
* `Name` is constrained to keys of the state so the value type is inferred
|
|
32
|
+
* from the field being written — invalid field/value pairs fail to type-check.
|
|
33
|
+
*
|
|
34
|
+
* @typeParam State - pre-validation form state
|
|
35
|
+
* @typeParam R - return type of the handler (defaults to `void`; internal
|
|
36
|
+
* reducer variants return the next form state)
|
|
37
|
+
*/
|
|
16
38
|
export type HandleChangeAction<State extends StateRestriction, R = void> = <Name extends keyof State>(name: Name, value: State[Name]) => R;
|
|
39
|
+
/**
|
|
40
|
+
* Props for the `<Field>` render-prop component.
|
|
41
|
+
*
|
|
42
|
+
* The `children` callback is called with a pre-wired `tool` object (so most inputs
|
|
43
|
+
* can simply spread it: `<input {...tool} />`), the current value, and the current
|
|
44
|
+
* error message for the field. `onChange` is overloaded to accept either a
|
|
45
|
+
* DOM `ChangeEvent` or an explicit `(name, value)` pair — the latter is useful
|
|
46
|
+
* for non-DOM inputs or typed values that do not serialize to strings.
|
|
47
|
+
*
|
|
48
|
+
* @typeParam State - pre-validation form state
|
|
49
|
+
* @typeParam Name - the key of the field being rendered
|
|
50
|
+
*/
|
|
17
51
|
export type FieldProps<State extends StateRestriction, Name extends keyof State> = {
|
|
18
52
|
name: Name;
|
|
19
53
|
children: (tool: {
|
|
@@ -28,6 +62,16 @@ export type FieldProps<State extends StateRestriction, Name extends keyof State>
|
|
|
28
62
|
*/
|
|
29
63
|
deps?: ReadonlyArray<unknown>;
|
|
30
64
|
};
|
|
65
|
+
/**
|
|
66
|
+
* Props for the `<Slicer>` render-prop component.
|
|
67
|
+
*
|
|
68
|
+
* Unlike `<Field>`, `<Slicer>` subscribes to an arbitrary derivation of the form
|
|
69
|
+
* state (via `selector`) and only re-renders when the selected tuple changes.
|
|
70
|
+
* Use this when you need to project multiple fields or computed values at once.
|
|
71
|
+
*
|
|
72
|
+
* @typeParam State - pre-validation form state
|
|
73
|
+
* @typeParam Selected - tuple of values returned by the selector
|
|
74
|
+
*/
|
|
31
75
|
export type SliceProps<State extends StateRestriction, Selected extends ReadonlyArray<unknown>> = {
|
|
32
76
|
selector: (s: FullFormState<State>) => Selected;
|
|
33
77
|
children: (tool: {
|
|
@@ -71,7 +115,14 @@ type FormControllerBehavior<StateBeforeValidation extends StateRestriction> = {
|
|
|
71
115
|
reduceFormState: (reducer: (prevValue: StateBeforeValidation) => StateBeforeValidation) => (prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>;
|
|
72
116
|
handleBulkChange: (setter: (prev: StateBeforeValidation) => Partial<StateBeforeValidation>) => (prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>;
|
|
73
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* The subset of store actions that are safe to call from application code.
|
|
120
|
+
*
|
|
121
|
+
* Internal reducers are hidden behind this narrower type so consumers only see
|
|
122
|
+
* effects (returning `void`) rather than the underlying reducer functions.
|
|
123
|
+
*/
|
|
74
124
|
type ActionsCanBePublic<State extends StateRestriction> = {
|
|
125
|
+
/** Reset the form back to its original initial state and clear all errors. */
|
|
75
126
|
reset: VoidFunction;
|
|
76
127
|
/**
|
|
77
128
|
* Initialize form state without validation.
|
|
@@ -82,38 +133,130 @@ type ActionsCanBePublic<State extends StateRestriction> = {
|
|
|
82
133
|
initializeForm: (initial: Partial<State>, opts?: {
|
|
83
134
|
cleanup?: true;
|
|
84
135
|
}) => void;
|
|
136
|
+
/**
|
|
137
|
+
* Merge external errors (e.g. server-side validation results) into the form.
|
|
138
|
+
*
|
|
139
|
+
* The validator receives the current value and returns a partial error map;
|
|
140
|
+
* unspecified fields keep their existing error. `isValid` is recomputed from
|
|
141
|
+
* the merged result.
|
|
142
|
+
*/
|
|
85
143
|
pushFormErrors: (validator: (state: State) => Partial<FormErrors<State>>) => void;
|
|
144
|
+
/** Update a single field. Triggers schema validation on the next state. */
|
|
86
145
|
handleChange: HandleChangeAction<State>;
|
|
146
|
+
/**
|
|
147
|
+
* Update multiple fields in one commit.
|
|
148
|
+
*
|
|
149
|
+
* The setter receives the current value and returns a partial patch that is
|
|
150
|
+
* merged shallowly before validation runs once over the result.
|
|
151
|
+
*/
|
|
87
152
|
handleBulkChange: (setter: (prev: State) => Partial<State>) => void;
|
|
88
153
|
};
|
|
89
154
|
/**
|
|
90
|
-
*
|
|
155
|
+
* Narrowed public view of a form store.
|
|
156
|
+
*
|
|
157
|
+
* Passing a `Controller` (rather than the full form hook) to reusable components
|
|
158
|
+
* is the recommended way to keep them decoupled from the concrete form instance.
|
|
159
|
+
* See `useFormSlice` and the `<Field>` / `<Slicer>` components in `./component`
|
|
160
|
+
* for consumers of this shape.
|
|
161
|
+
*
|
|
91
162
|
* TODO: more restriction
|
|
92
163
|
*/
|
|
93
164
|
export type Controller<State extends StateRestriction> = {
|
|
165
|
+
/**
|
|
166
|
+
* Effect hook that seeds the form with `initialValue` on mount.
|
|
167
|
+
* Intended to be called once per form instance at the top of a component.
|
|
168
|
+
*/
|
|
94
169
|
useInitialize: (initialValue: Partial<State>) => void;
|
|
170
|
+
/**
|
|
171
|
+
* Subscribe to a derived slice of form state. The component re-renders only
|
|
172
|
+
* when the selected value changes according to `isEqual` (defaults to
|
|
173
|
+
* reference equality, as provided by the underlying store).
|
|
174
|
+
*/
|
|
95
175
|
useSelector: <R>(selector: (state: FullFormState<State>) => R, isEqual?: (prev: R, current: R) => boolean) => R;
|
|
96
176
|
actions: ActionsCanBePublic<State>;
|
|
97
177
|
components: {
|
|
98
|
-
Field: <Name extends keyof State>(props: FieldProps<State, Name>) =>
|
|
99
|
-
Slicer: <Selected extends ReadonlyArray<unknown>>(props: SliceProps<State, Selected>) =>
|
|
178
|
+
Field: <Name extends keyof State>(props: FieldProps<State, Name>) => ReactNode;
|
|
179
|
+
Slicer: <Selected extends ReadonlyArray<unknown>>(props: SliceProps<State, Selected>) => ReactNode;
|
|
100
180
|
};
|
|
101
181
|
};
|
|
102
|
-
|
|
182
|
+
/**
|
|
183
|
+
* The full return value of {@link createFormHook}.
|
|
184
|
+
*
|
|
185
|
+
* Embeds a {@link Controller} for sharing with child components, plus the
|
|
186
|
+
* top-level `handleSubmit` helper and the raw store `api` for advanced use
|
|
187
|
+
* cases (e.g. reading state outside React, composing with other stores).
|
|
188
|
+
*
|
|
189
|
+
* For convenience the controller members are also spread onto the root, so
|
|
190
|
+
* `form.useSelector(...)` and `form.controller.useSelector(...)` behave
|
|
191
|
+
* identically.
|
|
192
|
+
*/
|
|
193
|
+
type FormHook<State extends StateRestriction, Schema extends ZodType<unknown>> = {
|
|
103
194
|
controller: Controller<State>;
|
|
104
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Build a submit handler that runs schema validation before invoking
|
|
197
|
+
* the user-supplied logic.
|
|
198
|
+
*
|
|
199
|
+
* The outer callback receives the DOM event so the caller can call
|
|
200
|
+
* `e.preventDefault()` or inspect it before validation; the inner callback
|
|
201
|
+
* then receives a discriminated result with either the parsed data (on
|
|
202
|
+
* success) or the collected Zod issues (on failure). On failure the issues
|
|
203
|
+
* are also pushed into form errors so fields light up automatically.
|
|
204
|
+
*
|
|
205
|
+
* @param handler handling logic
|
|
206
|
+
* @returns created handler is always return Promise, because of the schema validation run it asynchronously.
|
|
207
|
+
*/
|
|
208
|
+
handleSubmit: <R>(handler: (e: BaseSyntheticEvent) => (val: {
|
|
105
209
|
success: true;
|
|
106
210
|
data: zodInfer<Schema>;
|
|
107
211
|
error: undefined;
|
|
108
212
|
} | {
|
|
109
213
|
success: false;
|
|
110
214
|
error: ReadonlyArray<ZodIssue>;
|
|
111
|
-
}) =>
|
|
215
|
+
}) => R) => (e: BaseSyntheticEvent) => Promise<R>;
|
|
216
|
+
/**
|
|
217
|
+
* Low-level handle to the underlying store (from `nozuchi`).
|
|
218
|
+
*
|
|
219
|
+
* Exposed for advanced integrations — reading/writing state outside React,
|
|
220
|
+
* subscribing imperatively, or composing this form with other stores.
|
|
221
|
+
* Prefer the hook-based API (`useSelector`, `actions`, `components`) for
|
|
222
|
+
* ordinary component code.
|
|
223
|
+
*/
|
|
112
224
|
api: Subscriber<FullFormState<State>, FormControllerBehavior<State>>;
|
|
113
225
|
} & Controller<State>;
|
|
114
226
|
/**
|
|
227
|
+
* Create a self-contained form instance backed by a Zod schema.
|
|
228
|
+
*
|
|
229
|
+
* Mayoiga keeps two distinct state types in mind:
|
|
230
|
+
*
|
|
231
|
+
* 1. the **pre-validation** shape (what the UI edits, typically loose — nullable
|
|
232
|
+
* fields, string inputs for numbers, etc.), inferred from `initialState`.
|
|
233
|
+
* 2. the **post-validation** shape, inferred from `schema` and surfaced through
|
|
234
|
+
* `handleSubmit`. Zod transforms (e.g. `z.string().transform(Number)`) apply
|
|
235
|
+
* here, so `data` in the submit callback can have a narrower/coerced type
|
|
236
|
+
* than the editable state.
|
|
237
|
+
*
|
|
238
|
+
* The returned object exposes hooks (`useSelector`, `useInitialize`), actions
|
|
239
|
+
* (`handleChange`, `reset`, …), render-prop components (`Field`, `Slicer`),
|
|
240
|
+
* a `handleSubmit` builder, and the raw store `api` for advanced use.
|
|
241
|
+
*
|
|
242
|
+
* @param initialState - default values for every field; shape drives the
|
|
243
|
+
* pre-validation type inference
|
|
244
|
+
* @param schema - Zod schema used for field-level and submit-time validation
|
|
115
245
|
* @typeParam InitialState should be shallow
|
|
116
246
|
*/
|
|
117
|
-
export declare function createFormHook<StateBeforeValidation extends StateRestriction, Schema extends ZodType<
|
|
247
|
+
export declare function createFormHook<StateBeforeValidation extends StateRestriction, Schema extends ZodType<unknown>>(initialState: StateBeforeValidation, schema: Schema): FormHook<StateBeforeValidation, Schema>;
|
|
248
|
+
/**
|
|
249
|
+
* Convenience hook that reads a derived slice of state and returns it alongside
|
|
250
|
+
* the controller's actions — mirroring the `[state, dispatch]` shape familiar
|
|
251
|
+
* from `useReducer`.
|
|
252
|
+
*
|
|
253
|
+
* Prefer this over calling `useSelector` and pulling `actions` separately when
|
|
254
|
+
* a component consumes both in the same render.
|
|
255
|
+
*
|
|
256
|
+
* @param formController - the controller obtained from {@link createFormHook}
|
|
257
|
+
* (or passed down as a prop)
|
|
258
|
+
* @param selector - projection function from full form state to the needed slice
|
|
259
|
+
* @returns a tuple of `[slicedValue, actions]`
|
|
260
|
+
*/
|
|
118
261
|
export declare function useFormSlice<State extends StateRestriction, Sliced>(formController: Controller<State>, selector: (s: FullFormState<State>) => Sliced): readonly [Sliced, ActionsCanBePublic<State>];
|
|
119
262
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createStore } from 'nozuchi';
|
|
2
|
-
import {
|
|
3
|
-
import { isChangeEvent } from './utils';
|
|
4
|
-
const initialFormState = Object.freeze({
|
|
2
|
+
import { Fragment, createElement, useEffect, useMemo } from 'react';
|
|
3
|
+
import { isChangeEvent, isThennable } from './utils';
|
|
4
|
+
const initialFormState = Object.freeze({
|
|
5
|
+
isDirty: false,
|
|
6
|
+
isValid: false,
|
|
7
|
+
});
|
|
5
8
|
function createFormStore(initialState, schema) {
|
|
6
9
|
const initialErrors = Object.freeze(Object.keys(initialState).reduce((buf, k) => {
|
|
7
10
|
buf[k] = null;
|
|
@@ -15,7 +18,13 @@ function createFormStore(initialState, schema) {
|
|
|
15
18
|
const withValidation = (prev, newValue) => {
|
|
16
19
|
const result = schema.safeParse(newValue);
|
|
17
20
|
if (result.success) {
|
|
18
|
-
return {
|
|
21
|
+
return {
|
|
22
|
+
...prev,
|
|
23
|
+
isDirty: true,
|
|
24
|
+
isValid: true,
|
|
25
|
+
value: newValue,
|
|
26
|
+
errors: initialErrors,
|
|
27
|
+
};
|
|
19
28
|
}
|
|
20
29
|
const newErrors = result.error.issues.reduce((buf, iss) => {
|
|
21
30
|
// TODO: should support nested value?
|
|
@@ -25,8 +34,14 @@ function createFormStore(initialState, schema) {
|
|
|
25
34
|
}
|
|
26
35
|
buf[shallowPath] = iss.message;
|
|
27
36
|
return buf;
|
|
28
|
-
}, {});
|
|
29
|
-
return {
|
|
37
|
+
}, { ...initialErrors });
|
|
38
|
+
return {
|
|
39
|
+
...prev,
|
|
40
|
+
isDirty: true,
|
|
41
|
+
isValid: false,
|
|
42
|
+
errors: newErrors,
|
|
43
|
+
value: newValue,
|
|
44
|
+
};
|
|
30
45
|
};
|
|
31
46
|
return createStore(fullInitialState, {
|
|
32
47
|
peek: (eyeball) => (prev) => {
|
|
@@ -36,7 +51,10 @@ function createFormStore(initialState, schema) {
|
|
|
36
51
|
reset: () => () => fullInitialState,
|
|
37
52
|
initializeForm: (initialVal, opts) => (prev) => ({
|
|
38
53
|
...prev,
|
|
39
|
-
value: {
|
|
54
|
+
value: {
|
|
55
|
+
...prev.value,
|
|
56
|
+
...initialVal,
|
|
57
|
+
},
|
|
40
58
|
errors: { ...initialErrors },
|
|
41
59
|
isDirty: opts?.cleanup === true ? false : prev.isDirty,
|
|
42
60
|
}),
|
|
@@ -49,22 +67,40 @@ function createFormStore(initialState, schema) {
|
|
|
49
67
|
}
|
|
50
68
|
buf[shallowPath] = iss.message;
|
|
51
69
|
return buf;
|
|
52
|
-
}, {});
|
|
53
|
-
return {
|
|
70
|
+
}, { ...initialErrors });
|
|
71
|
+
return {
|
|
72
|
+
...prev,
|
|
73
|
+
isDirty: true,
|
|
74
|
+
isValid: false,
|
|
75
|
+
errors: newErrors,
|
|
76
|
+
};
|
|
54
77
|
},
|
|
55
78
|
pushFormErrors: (validator) => (prev) => {
|
|
56
79
|
const pushedErrors = validator(prev.value);
|
|
57
|
-
const allErrors = {
|
|
58
|
-
|
|
80
|
+
const allErrors = {
|
|
81
|
+
...prev.errors,
|
|
82
|
+
...pushedErrors,
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
...prev,
|
|
86
|
+
isValid: Object.values(allErrors).every((v) => v == null),
|
|
87
|
+
errors: allErrors,
|
|
88
|
+
};
|
|
59
89
|
},
|
|
60
90
|
reduceFormErrors: (reducer) => (prev) => {
|
|
61
|
-
return {
|
|
91
|
+
return {
|
|
92
|
+
...prev,
|
|
93
|
+
errors: reducer(prev.errors),
|
|
94
|
+
};
|
|
62
95
|
},
|
|
63
96
|
handleChange: (name, value) => (prev) => {
|
|
64
97
|
if (name in prev.value === false) {
|
|
65
98
|
return prev;
|
|
66
99
|
}
|
|
67
|
-
const newValue = {
|
|
100
|
+
const newValue = {
|
|
101
|
+
...prev.value,
|
|
102
|
+
[name]: value,
|
|
103
|
+
};
|
|
68
104
|
return withValidation(prev, newValue);
|
|
69
105
|
},
|
|
70
106
|
reduceFormState: (reducer) => (prev) => {
|
|
@@ -72,18 +108,40 @@ function createFormStore(initialState, schema) {
|
|
|
72
108
|
return withValidation(prev, reducedState);
|
|
73
109
|
},
|
|
74
110
|
handleBulkChange: (setter) => (prev) => {
|
|
75
|
-
const mergedNewState = {
|
|
111
|
+
const mergedNewState = {
|
|
112
|
+
...prev.value,
|
|
113
|
+
...setter(prev.value),
|
|
114
|
+
};
|
|
76
115
|
return withValidation(prev, mergedNewState);
|
|
77
116
|
},
|
|
78
117
|
});
|
|
79
118
|
}
|
|
80
119
|
/**
|
|
120
|
+
* Create a self-contained form instance backed by a Zod schema.
|
|
121
|
+
*
|
|
122
|
+
* Mayoiga keeps two distinct state types in mind:
|
|
123
|
+
*
|
|
124
|
+
* 1. the **pre-validation** shape (what the UI edits, typically loose — nullable
|
|
125
|
+
* fields, string inputs for numbers, etc.), inferred from `initialState`.
|
|
126
|
+
* 2. the **post-validation** shape, inferred from `schema` and surfaced through
|
|
127
|
+
* `handleSubmit`. Zod transforms (e.g. `z.string().transform(Number)`) apply
|
|
128
|
+
* here, so `data` in the submit callback can have a narrower/coerced type
|
|
129
|
+
* than the editable state.
|
|
130
|
+
*
|
|
131
|
+
* The returned object exposes hooks (`useSelector`, `useInitialize`), actions
|
|
132
|
+
* (`handleChange`, `reset`, …), render-prop components (`Field`, `Slicer`),
|
|
133
|
+
* a `handleSubmit` builder, and the raw store `api` for advanced use.
|
|
134
|
+
*
|
|
135
|
+
* @param initialState - default values for every field; shape drives the
|
|
136
|
+
* pre-validation type inference
|
|
137
|
+
* @param schema - Zod schema used for field-level and submit-time validation
|
|
81
138
|
* @typeParam InitialState should be shallow
|
|
82
139
|
*/
|
|
83
140
|
export function createFormHook(initialState, schema) {
|
|
84
141
|
const store = createFormStore(initialState, schema);
|
|
85
142
|
const formHook = {
|
|
86
143
|
useInitialize: (initialValue) => {
|
|
144
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: on init effect
|
|
87
145
|
useEffect(() => {
|
|
88
146
|
store.actions.initializeForm(initialValue);
|
|
89
147
|
}, []);
|
|
@@ -94,13 +152,25 @@ export function createFormHook(initialState, schema) {
|
|
|
94
152
|
const eventHandled = handler(e);
|
|
95
153
|
const value = store.getState().value;
|
|
96
154
|
return schema.safeParseAsync(value).then((result) => {
|
|
97
|
-
// FIXME: hook formを捨ててzodのバージョンをあげればこの辺に型をつけられる
|
|
98
155
|
if (result.success) {
|
|
99
|
-
|
|
100
|
-
|
|
156
|
+
return eventHandled({
|
|
157
|
+
success: true,
|
|
158
|
+
data: result.data,
|
|
159
|
+
error: undefined,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const handled = eventHandled({
|
|
163
|
+
success: false,
|
|
164
|
+
error: result.error.issues,
|
|
165
|
+
});
|
|
166
|
+
if (isThennable(handled)) {
|
|
167
|
+
return handled.then((x) => {
|
|
168
|
+
store.actions.handleIssues(result.error.issues);
|
|
169
|
+
return x;
|
|
170
|
+
});
|
|
101
171
|
}
|
|
102
|
-
void eventHandled({ success: false, error: result.error.issues });
|
|
103
172
|
store.actions.handleIssues(result.error.issues);
|
|
173
|
+
return handled;
|
|
104
174
|
});
|
|
105
175
|
};
|
|
106
176
|
},
|
|
@@ -109,9 +179,13 @@ export function createFormHook(initialState, schema) {
|
|
|
109
179
|
const { selector, children, deps } = props;
|
|
110
180
|
const slicedValues = formHook.useSelector(selector);
|
|
111
181
|
return useMemo(() => {
|
|
112
|
-
const renderContent = children({
|
|
182
|
+
const renderContent = children({
|
|
183
|
+
handleChange: formHook.actions.handleChange,
|
|
184
|
+
}, ...slicedValues);
|
|
113
185
|
return createElement(Fragment, {}, renderContent);
|
|
114
|
-
},
|
|
186
|
+
},
|
|
187
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: deps array is conditionally spread; Biome cannot statically verify it.
|
|
188
|
+
deps ? [...slicedValues, ...deps] : slicedValues);
|
|
115
189
|
},
|
|
116
190
|
Field(props) {
|
|
117
191
|
const { name, children, deps } = props;
|
|
@@ -123,19 +197,21 @@ export function createFormHook(initialState, schema) {
|
|
|
123
197
|
onChange: (name_or_event, value_or_none) => {
|
|
124
198
|
if (typeof name_or_event === 'string') {
|
|
125
199
|
// assumes name is valid if it's string
|
|
126
|
-
//
|
|
200
|
+
// biome-ignore lint/suspicious/noExplicitAny: has to be safe
|
|
127
201
|
return store.actions.handleChange(name_or_event, value_or_none);
|
|
128
202
|
}
|
|
129
203
|
if (isChangeEvent(name_or_event)) {
|
|
130
204
|
const target = name_or_event.currentTarget || name_or_event.target;
|
|
131
|
-
//
|
|
205
|
+
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
|
132
206
|
return store.actions.handleChange(target.name, target.value);
|
|
133
207
|
}
|
|
134
208
|
throw new Error('`handleChange` handles unexpected formed object.');
|
|
135
209
|
},
|
|
136
210
|
}, value, errMsg);
|
|
137
211
|
return createElement(Fragment, {}, renderContent);
|
|
138
|
-
},
|
|
212
|
+
},
|
|
213
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: deps array is conditionally spread; Biome cannot statically verify it.
|
|
214
|
+
deps ? [value, errMsg, ...deps] : [value, errMsg]);
|
|
139
215
|
},
|
|
140
216
|
},
|
|
141
217
|
actions: store.actions,
|
|
@@ -146,6 +222,19 @@ export function createFormHook(initialState, schema) {
|
|
|
146
222
|
...formHook,
|
|
147
223
|
};
|
|
148
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Convenience hook that reads a derived slice of state and returns it alongside
|
|
227
|
+
* the controller's actions — mirroring the `[state, dispatch]` shape familiar
|
|
228
|
+
* from `useReducer`.
|
|
229
|
+
*
|
|
230
|
+
* Prefer this over calling `useSelector` and pulling `actions` separately when
|
|
231
|
+
* a component consumes both in the same render.
|
|
232
|
+
*
|
|
233
|
+
* @param formController - the controller obtained from {@link createFormHook}
|
|
234
|
+
* (or passed down as a prop)
|
|
235
|
+
* @param selector - projection function from full form state to the needed slice
|
|
236
|
+
* @returns a tuple of `[slicedValue, actions]`
|
|
237
|
+
*/
|
|
149
238
|
export function useFormSlice(formController, selector) {
|
|
150
239
|
const sliced = formController.useSelector((s) => selector(s));
|
|
151
240
|
return [sliced, formController.actions];
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { ChangeEvent } from 'react';
|
|
2
|
-
export declare function isChangeEvent<T>(x: any): x is ChangeEvent<T>;
|
|
2
|
+
export declare function isChangeEvent<T extends HTMLInputElement>(x: any): x is ChangeEvent<T>;
|
|
3
|
+
export declare function isThennable<T>(x: any): x is Promise<T>;
|
package/dist/utils.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
// biome-ignore lint/suspicious/noExplicitAny: "unsafe" type guard
|
|
1
2
|
export function isChangeEvent(x) {
|
|
2
3
|
if (typeof x !== 'object') {
|
|
3
4
|
return false;
|
|
4
5
|
}
|
|
5
6
|
return 'target' in x;
|
|
6
7
|
}
|
|
8
|
+
// biome-ignore lint/suspicious/noExplicitAny: "unsafe" type guard
|
|
9
|
+
export function isThennable(x) {
|
|
10
|
+
return x != null && typeof x.then === 'function';
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mayoiga",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Type safe hook based form library for React",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -10,18 +10,6 @@
|
|
|
10
10
|
"/component.d.ts",
|
|
11
11
|
"!*.test.js"
|
|
12
12
|
],
|
|
13
|
-
"scripts": {
|
|
14
|
-
"clean": "rm -rf dist",
|
|
15
|
-
"test": "vitest run",
|
|
16
|
-
"prebuild": "npm run clean",
|
|
17
|
-
"build": "tsc",
|
|
18
|
-
"check:type": "tsc --noEmit",
|
|
19
|
-
"check:style": "prettier --check 'src/**/*.{js,ts,jsx,tsx}'",
|
|
20
|
-
"check:lint": "eslint 'src/**/*.{ts,tsx}'",
|
|
21
|
-
"check-all": "npm run check:type && npm run check:style && npm run check:lint && npm run build --noEmit && npm run test",
|
|
22
|
-
"fix-all": "npm run check:style -- --write && npm run check:lint -- --fix",
|
|
23
|
-
"prepublish": "npm run check-all && npm run build"
|
|
24
|
-
},
|
|
25
13
|
"repository": {
|
|
26
14
|
"type": "git",
|
|
27
15
|
"url": "git+ssh://git@github.com/hachibeeDI/mayoiga.git"
|
|
@@ -40,30 +28,41 @@
|
|
|
40
28
|
},
|
|
41
29
|
"homepage": "https://github.com/hachibeeDI/mayoiga#readme",
|
|
42
30
|
"peerDependencies": {
|
|
43
|
-
"zod": "^3.
|
|
31
|
+
"zod": "^3.25.0 || ^4.0.0"
|
|
44
32
|
},
|
|
45
33
|
"dependencies": {
|
|
46
|
-
"nozuchi": "^0.0
|
|
34
|
+
"nozuchi": "^0.1.0"
|
|
47
35
|
},
|
|
48
36
|
"devDependencies": {
|
|
49
|
-
"@
|
|
50
|
-
"@testing-library/
|
|
51
|
-
"@
|
|
52
|
-
"@
|
|
53
|
-
"@types/
|
|
54
|
-
"@
|
|
55
|
-
"@
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"
|
|
37
|
+
"@biomejs/biome": "^2.5.15",
|
|
38
|
+
"@testing-library/dom": "^10.4.2",
|
|
39
|
+
"@testing-library/react": "^16.3.3",
|
|
40
|
+
"@testing-library/user-event": "^14.6.8",
|
|
41
|
+
"@types/node": "^26.6.5",
|
|
42
|
+
"@types/react": "^19.3.0",
|
|
43
|
+
"@types/use-sync-external-store": "^1.7.0",
|
|
44
|
+
"@vitest/coverage-v8": "^5.0.5",
|
|
45
|
+
"jsdom": "^30.1.3",
|
|
46
|
+
"react": "^19.3.1",
|
|
47
|
+
"react-dom": "^19.3.1",
|
|
48
|
+
"typedoc": "^0.28.20",
|
|
49
|
+
"typescript": "^6.0.3",
|
|
50
|
+
"use-sync-external-store": "^1.7.0",
|
|
51
|
+
"vitest": "^5.0.5",
|
|
52
|
+
"zod": "^4.6.7"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"clean": "rm -rf dist",
|
|
56
|
+
"docs": "typedoc src/index.ts src/component.tsx --out docs",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:coverage": "vitest run --coverage",
|
|
59
|
+
"prebuild": "pnpm run clean",
|
|
60
|
+
"build": "tsc -p tsconfig.build.json",
|
|
61
|
+
"check:type": "tsc --noEmit",
|
|
62
|
+
"check:lint": "biome lint src/",
|
|
63
|
+
"check:style": "biome format src/",
|
|
64
|
+
"check-all": "pnpm run check:type && pnpm run check:style && pnpm run check:lint && pnpm run test",
|
|
65
|
+
"fix:lint": "biome lint src/ --fix",
|
|
66
|
+
"fix-all": "pnpm run check:style --write && pnpm run check:lint --fix"
|
|
68
67
|
}
|
|
69
|
-
}
|
|
68
|
+
}
|