mayoiga 0.1.8 → 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.
@@ -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
- export declare function Slicer<State extends StateRestriction, R extends ReadonlyArray<unknown>>(props: ControlledComponentProps<State, SliceProps<State, R>>): React.JSX.Element;
7
- export declare function Field<State extends StateRestriction, Name extends keyof State>(props: ControlledComponentProps<State, FieldProps<State, Name>>): React.JSX.Element;
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 React from 'react';
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 React.createElement(Delegate, { ...restProps });
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 React.createElement(Delegate, { ...restProps });
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, ReactElement } from 'react';
3
- import type { infer as zodInfer, ZodIssue, ZodType } from 'zod';
4
- export type StateRestriction = Record<string, any>;
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,26 +133,75 @@ 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
- * Restricted type interface of the form store
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>) => ReactElement<any, any> | null;
99
- Slicer: <Selected extends ReadonlyArray<unknown>>(props: SliceProps<State, Selected>) => ReactElement<any, any> | null;
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
- type FormHook<State extends StateRestriction, Schema extends ZodType<any, any, any>> = {
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
+ *
105
205
  * @param handler handling logic
106
206
  * @returns created handler is always return Promise, because of the schema validation run it asynchronously.
107
207
  */
@@ -113,11 +213,50 @@ type FormHook<State extends StateRestriction, Schema extends ZodType<any, any, a
113
213
  success: false;
114
214
  error: ReadonlyArray<ZodIssue>;
115
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
+ */
116
224
  api: Subscriber<FullFormState<State>, FormControllerBehavior<State>>;
117
225
  } & Controller<State>;
118
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
119
245
  * @typeParam InitialState should be shallow
120
246
  */
121
- export declare function createFormHook<StateBeforeValidation extends StateRestriction, Schema extends ZodType<any, any, any>>(initialState: StateBeforeValidation, schema: Schema): FormHook<StateBeforeValidation, Schema>;
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
+ */
122
261
  export declare function useFormSlice<State extends StateRestriction, Sliced>(formController: Controller<State>, selector: (s: FullFormState<State>) => Sliced): readonly [Sliced, ActionsCanBePublic<State>];
123
262
  export {};
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { createStore } from 'nozuchi';
2
- import { createElement, Fragment, useEffect, useMemo } from 'react';
3
- import { isChangeEvent } from './utils';
4
- const initialFormState = Object.freeze({ isDirty: false, isValid: false });
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 { ...prev, isDirty: true, isValid: true, value: newValue, errors: initialErrors };
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 { ...prev, isDirty: true, isValid: false, errors: newErrors, value: newValue };
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: { ...prev.value, ...initialVal },
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 { ...prev, isDirty: true, isValid: false, errors: newErrors };
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 = { ...prev.errors, ...pushedErrors };
58
- return { ...prev, isValid: Object.keys(allErrors).length === 0, errors: allErrors };
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 { ...prev, errors: reducer(prev.errors) };
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 = { ...prev.value, [name]: value };
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 = { ...prev.value, ...setter(prev.value) };
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,25 +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
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
100
- return eventHandled({ success: true, data: result.data, error: undefined });
156
+ return eventHandled({
157
+ success: true,
158
+ data: result.data,
159
+ error: undefined,
160
+ });
101
161
  }
102
- const handled = eventHandled({ success: false, error: result.error.issues });
103
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
104
- if (typeof handled['then'] === 'function') {
105
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
162
+ const handled = eventHandled({
163
+ success: false,
164
+ error: result.error.issues,
165
+ });
166
+ if (isThennable(handled)) {
106
167
  return handled.then((x) => {
107
168
  store.actions.handleIssues(result.error.issues);
108
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
109
169
  return x;
110
170
  });
111
171
  }
112
- else {
113
- store.actions.handleIssues(result.error.issues);
114
- return handled;
115
- }
172
+ store.actions.handleIssues(result.error.issues);
173
+ return handled;
116
174
  });
117
175
  };
118
176
  },
@@ -121,9 +179,13 @@ export function createFormHook(initialState, schema) {
121
179
  const { selector, children, deps } = props;
122
180
  const slicedValues = formHook.useSelector(selector);
123
181
  return useMemo(() => {
124
- const renderContent = children({ handleChange: formHook.actions.handleChange }, ...slicedValues);
182
+ const renderContent = children({
183
+ handleChange: formHook.actions.handleChange,
184
+ }, ...slicedValues);
125
185
  return createElement(Fragment, {}, renderContent);
126
- }, deps ? [...slicedValues, ...deps] : slicedValues);
186
+ },
187
+ // biome-ignore lint/correctness/useExhaustiveDependencies: deps array is conditionally spread; Biome cannot statically verify it.
188
+ deps ? [...slicedValues, ...deps] : slicedValues);
127
189
  },
128
190
  Field(props) {
129
191
  const { name, children, deps } = props;
@@ -135,19 +197,21 @@ export function createFormHook(initialState, schema) {
135
197
  onChange: (name_or_event, value_or_none) => {
136
198
  if (typeof name_or_event === 'string') {
137
199
  // assumes name is valid if it's string
138
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
200
+ // biome-ignore lint/suspicious/noExplicitAny: has to be safe
139
201
  return store.actions.handleChange(name_or_event, value_or_none);
140
202
  }
141
203
  if (isChangeEvent(name_or_event)) {
142
204
  const target = name_or_event.currentTarget || name_or_event.target;
143
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
205
+ // biome-ignore lint/suspicious/noExplicitAny: FIXME
144
206
  return store.actions.handleChange(target.name, target.value);
145
207
  }
146
208
  throw new Error('`handleChange` handles unexpected formed object.');
147
209
  },
148
210
  }, value, errMsg);
149
211
  return createElement(Fragment, {}, renderContent);
150
- }, deps ? [value, errMsg, ...deps] : [value, errMsg]);
212
+ },
213
+ // biome-ignore lint/correctness/useExhaustiveDependencies: deps array is conditionally spread; Biome cannot statically verify it.
214
+ deps ? [value, errMsg, ...deps] : [value, errMsg]);
151
215
  },
152
216
  },
153
217
  actions: store.actions,
@@ -158,6 +222,19 @@ export function createFormHook(initialState, schema) {
158
222
  ...formHook,
159
223
  };
160
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
+ */
161
238
  export function useFormSlice(formController, selector) {
162
239
  const sliced = formController.useSelector((s) => selector(s));
163
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.1.8",
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,35 +28,41 @@
40
28
  },
41
29
  "homepage": "https://github.com/hachibeeDI/mayoiga#readme",
42
30
  "peerDependencies": {
43
- "zod": "^3.20.2"
31
+ "zod": "^3.25.0 || ^4.0.0"
44
32
  },
45
33
  "dependencies": {
46
- "nozuchi": "^0.0.6"
47
- },
48
- "resolutions": {
49
- "react": "^18.2.0",
50
- "react-dom": "^18.2.0",
51
- "@types/react": "^18.2.57"
34
+ "nozuchi": "^0.1.0"
52
35
  },
53
36
  "devDependencies": {
54
- "@testing-library/react": "^14.2.1",
55
- "@testing-library/user-event": "^14.5.2",
56
- "@types/node": "^20.11.19",
57
- "@types/react": "^18.2.57",
58
- "@types/use-sync-external-store": "^0.0.6",
59
- "@typescript-eslint/eslint-plugin": "^7.0.2",
60
- "@typescript-eslint/parser": "^7.0.2",
61
- "eslint": "^8.56.0",
62
- "eslint-plugin-import": "^2.29.1",
63
- "eslint-plugin-testing-library": "^6.2.0",
64
- "jsdom": "^24.0.0",
65
- "prettier": "^3.2.5",
66
- "react": "^18.2.0",
67
- "react-dom": "^18.2.0",
68
- "typedoc": "^0.25.8",
69
- "typescript": "^5.3.3",
70
- "use-sync-external-store": "^1.2.0",
71
- "vitest": "^1.3.0",
72
- "zod": "^3.22.4"
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"
73
67
  }
74
- }
68
+ }