@shivaedev/form 0.14.0 → 0.15.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 - 2026-11-04
4
+
5
+ ### Added
6
+
7
+ - `Field.number({ optional, min, max, integer })` supplies a finite numeric string codec with inclusive bounds and blank-as-null optional values. `numberInput` binds the encoded text to an input without losing invalid input.
8
+ - `Field.list(item, { min, max })` bounds arrays with native Schema checks. The React `useList` hook adds, edits and removes items and exposes feedback for each current item.
9
+
3
10
  ## 0.14.0 - 2026-11-03
4
11
 
5
12
  ### Added
package/README.md CHANGED
@@ -293,6 +293,70 @@ Read the message at the element's **current** index: in React, `useAtomValue(ans
293
293
 
294
294
  Without a selector for an array, handler errors fall back to index paths. They remain at that index while its input value matches, even if a different element with the same value moves there. Arrays nested inside an element also use index paths. Use immutable value updates and stable, unique keys for top-level arrays whose errors must follow elements.
295
295
 
296
+ ### Numeric inputs
297
+
298
+ ```tsx
299
+ import * as Field from "@shivaedev/form/field.ts";
300
+ import { numberInput } from "@shivaedev/form/adapters/numberInput.ts";
301
+ import { useField } from "@shivaedev/form/adapters/react.ts";
302
+
303
+ const numbers = form(Schema.Struct({ amount: Field.number({ optional: true, min: 0, max: 100, integer: true }) }), {
304
+ initialValues: { amount: "" },
305
+ onSubmit: Effect.succeed,
306
+ });
307
+
308
+ function Amount() {
309
+ const amount = useField(numbers, "amount");
310
+ return (
311
+ <>
312
+ <input {...numberInput(amount)} />
313
+ <span role="alert">{amount.error}</span>
314
+ </>
315
+ );
316
+ }
317
+ ```
318
+
319
+ `Field.number` holds a string and decodes to a finite number. `optional: true` decodes empty or whitespace-only input to `null`; encoding `null` gives `""`. Without it, blank input is required. `min` and `max` are inclusive; `integer: true` rejects fractions. Omit `integer` to accept fractions and omit either bound for an open range. Invalid, non-finite and out-of-range values prevent submission.
320
+
321
+ `numberInput(field)` supplies `name`, `value`, `onInput`, `onBlur`, `type: "text"` and `inputMode: "decimal"` for a string-valued field. A text input preserves invalid text so it can display Schema feedback; a native number input can erase that text before the form receives it. Wording and layout remain application choices.
322
+
323
+ ### Bounded lists
324
+
325
+ ```tsx
326
+ import * as Field from "@shivaedev/form/field.ts";
327
+ import { useList } from "@shivaedev/form/adapters/useList.ts";
328
+
329
+ const quickAmounts = form(Schema.Struct({ amounts: Field.list(Field.number({ min: 1 }), { max: 5 }) }), {
330
+ initialValues: { amounts: ["1", "2"] },
331
+ onSubmit: Effect.succeed,
332
+ });
333
+
334
+ function QuickAmounts() {
335
+ const amounts = useList(quickAmounts, "amounts");
336
+ return (
337
+ <>
338
+ {amounts.items.map((item, index) => (
339
+ <div key={index}>
340
+ <input {...numberInput({ name: String(index), value: item.value, onChange: item.onChange, onBlur: amounts.onBlur })} />
341
+ <span role="alert">{item.error}</span>
342
+ <button type="button" onClick={item.onRemove}>
343
+ Remove
344
+ </button>
345
+ </div>
346
+ ))}
347
+ <button type="button" onClick={() => amounts.add("")}>
348
+ Add amount
349
+ </button>
350
+ <span role="alert">{amounts.error}</span>
351
+ </>
352
+ );
353
+ }
354
+ ```
355
+
356
+ `Field.list(item, { max, min? })` returns a native array schema with a required maximum count and optional minimum count. An omitted minimum permits an empty list. Bounds validate the current array, including direct edits: adding beyond `max` or removing below `min` displays list feedback and prevents submission. The binding keeps the edit available for correction.
357
+
358
+ `useList(form, name)` edits a top-level array field through the existing form session. `add(encodedItem)` appends an item; each entry of `items` exposes its encoded `value`, `onChange(encodedItem)`, `onRemove()` and `error`. `onBlur()` touches the list. Item errors use `errorAt([name, index])`, and `error` retains the list summary. Schema errors track current positions. For object items, supply stable keys through `arrayKeys` and use the same keys in React; handler rejections then follow the existing keyed-array rules described above. Nested objects remain whole-item edits.
359
+
296
360
  ### Choices and nullable inputs
297
361
 
298
362
  ```ts
@@ -311,14 +375,17 @@ For a form made with these fields, `order.choices("visibility")` returns the enc
311
375
 
312
376
  Import the module that defines a name; there is no root entry.
313
377
 
314
- | Module | Application-facing exports |
315
- | ----------------------------------- | ----------------------------------------------------------------------------------------- |
316
- | `@shivaedev/form/form.ts` | `form(schema, config)` |
317
- | `@shivaedev/form/shape.ts` | `Form`, `Submitter`, `FieldFailure`, `Invalid` and the field types they use |
318
- | `@shivaedev/form/fieldRejection.ts` | `rejecting`, `fieldRejectionOf`, `FieldRejection`, `RejectField`, `FieldRejectionMapping` |
319
- | `@shivaedev/form/adapters/react.ts` | `useField`, `useSubmit`, `useDirty`, `usePersistedDraft`, `DraftSessionContext` |
320
- | `@shivaedev/form/draftSession.ts` | `draftSession(id)` |
321
- | `@shivaedev/form/emptyAsNull.ts` | `emptyAsNull` |
378
+ | Module | Application-facing exports |
379
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------- |
380
+ | `@shivaedev/form/form.ts` | `form(schema, config)` |
381
+ | `@shivaedev/form/shape.ts` | `Form`, `Submitter`, `FieldFailure`, `Invalid` and the field types they use |
382
+ | `@shivaedev/form/fieldRejection.ts` | `rejecting`, `fieldRejectionOf`, `FieldRejection`, `RejectField`, `FieldRejectionMapping` |
383
+ | `@shivaedev/form/adapters/react.ts` | `useField`, `useSubmit`, `useDirty`, `usePersistedDraft`, `DraftSessionContext` |
384
+ | `@shivaedev/form/draftSession.ts` | `draftSession(id)` |
385
+ | `@shivaedev/form/field.ts` | `number`, `list`, `NumberOptions`, `ListOptions` |
386
+ | `@shivaedev/form/adapters/numberInput.ts` | `numberInput` |
387
+ | `@shivaedev/form/adapters/useList.ts` | `useList` |
388
+ | `@shivaedev/form/emptyAsNull.ts` | `emptyAsNull` |
322
389
 
323
390
  `form` takes a `Schema.Struct` and this configuration:
324
391
 
@@ -371,7 +438,7 @@ pnpm add @effect/atom-react react
371
438
  ```
372
439
 
373
440
  The React mounting example also uses the application's `react-dom`. React and `@effect/atom-react` are optional peers; the core modules do not import them. The package needs Node 24 or newer.
374
- - Field names are top-level schema keys. There is no path-addressed field-array API.
441
+ - Field names are top-level schema keys. `useList` edits top-level arrays; nested items remain whole-item edits.
375
442
  - Replace encoded values rather than mutating objects in place; use `change` for ordinary UI edits.
376
443
  - Keep the form and registry for their intended session, and clean up the registry when that session ends.
377
444
  - Decide in the application when to receive normalized rows and how to render general failures. Attach a draft owner when persisted input must end with the session.
@@ -0,0 +1,15 @@
1
+ import type { InputEvent } from "react";
2
+ export declare function numberInput(field: {
3
+ readonly name: string;
4
+ readonly value: string;
5
+ readonly onChange: (value: string) => void;
6
+ readonly onBlur: () => void;
7
+ }): {
8
+ inputMode: "decimal";
9
+ name: string;
10
+ onBlur: () => void;
11
+ onInput: (event: InputEvent<HTMLInputElement>) => void;
12
+ type: "text";
13
+ value: string;
14
+ };
15
+ //# sourceMappingURL=numberInput.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"numberInput.d.ts","sourceRoot":"","sources":["../../src/adapters/numberInput.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAExC,wBAAgB,WAAW,CAAC,KAAK,EAAE;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,QAAQ,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC;CAC5B;IAEC,SAAS,EAAE,SAAS;IACpB,IAAI;IACJ,MAAM,QALgB,IAAI;IAM1B,OAAO,UAAU,UAAU,CAAC,gBAAgB,CAAC;IAC7C,IAAI,EAAE,MAAM;IACZ,KAAK;EAEN"}
@@ -0,0 +1,11 @@
1
+ export function numberInput(field) {
2
+ return {
3
+ inputMode: "decimal",
4
+ name: field.name,
5
+ onBlur: field.onBlur,
6
+ onInput: (event) => field.onChange(event.currentTarget.value),
7
+ type: "text",
8
+ value: field.value,
9
+ };
10
+ }
11
+ //# sourceMappingURL=numberInput.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"numberInput.js","sourceRoot":"","sources":["../../src/adapters/numberInput.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,WAAW,CAAC,KAK3B;IACA,OAAO;QACN,SAAS,EAAE,SAAkB;QAC7B,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,OAAO,EAAE,CAAC,KAAmC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,aAAa,CAAC,KAAK,CAAC;QAC3F,IAAI,EAAE,MAAe;QACrB,KAAK,EAAE,KAAK,CAAC,KAAK;KAClB,CAAC;AACH,CAAC"}
@@ -0,0 +1,23 @@
1
+ import * as Atom from "effect/reactivity/Atom";
2
+ import type * as AtomRef from "effect/reactivity/AtomRef";
3
+ interface ListForm<TKey extends string, TItem> {
4
+ readonly blur: (name: TKey) => void;
5
+ readonly change: (name: TKey, value: readonly TItem[]) => void;
6
+ readonly dirty: Atom.Atom<boolean>;
7
+ readonly error: (name: TKey) => Atom.Atom<string | undefined>;
8
+ readonly errorAt: (path: readonly [TKey, ...PropertyKey[]]) => Atom.Atom<string | undefined>;
9
+ readonly field: (name: TKey) => AtomRef.ReadonlyRef<readonly TItem[]>;
10
+ }
11
+ export declare function useList<TKey extends string, TItem>(form: ListForm<NoInfer<TKey>, TItem>, name: TKey): {
12
+ add: (value: TItem) => void;
13
+ error: string | undefined;
14
+ items: {
15
+ error: string | undefined;
16
+ onChange: (next: TItem) => void;
17
+ onRemove: () => void;
18
+ value: TItem;
19
+ }[];
20
+ onBlur: () => void;
21
+ };
22
+ export {};
23
+ //# sourceMappingURL=useList.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useList.d.ts","sourceRoot":"","sources":["../../src/adapters/useList.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,IAAI,MAAM,wBAAwB,CAAC;AAC/C,OAAO,KAAK,KAAK,OAAO,MAAM,2BAA2B,CAAC;AAG1D,UAAU,QAAQ,CAAC,IAAI,SAAS,MAAM,EAAE,KAAK;IAC5C,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,KAAK,EAAE,KAAK,IAAI,CAAC;IAC/D,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC9D,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,SAAS,CAAC,IAAI,EAAE,GAAG,WAAW,EAAE,CAAC,KAAK,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC7F,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,WAAW,CAAC,SAAS,KAAK,EAAE,CAAC,CAAC;CACtE;AAED,wBAAgB,OAAO,CAAC,IAAI,SAAS,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI;iBAcrF,KAAK;;;;yBAIA,KAAK;;;;;EAMxB"}
@@ -0,0 +1,27 @@
1
+ import { useAtomMount, useAtomRef, useAtomValue } from "@effect/atom-react";
2
+ import * as Atom from "effect/reactivity/Atom";
3
+ import { useMemo } from "react";
4
+ export function useList(form, name) {
5
+ useAtomMount(form.dirty);
6
+ const values = useAtomRef(form.field(name));
7
+ const error = useAtomValue(form.error(name));
8
+ const errors = useAtomValue(useMemo(() => Atom.readable((get) => values.map((_, index) => get(form.errorAt([name, index])))), [form, name, values]));
9
+ function change(next) {
10
+ form.change(name, next);
11
+ }
12
+ function current() {
13
+ return form.field(name).value;
14
+ }
15
+ return {
16
+ add: (value) => change([...current(), value]),
17
+ error,
18
+ items: values.map((value, index) => ({
19
+ error: errors[index],
20
+ onChange: (next) => change(current().map((item, at) => (at === index ? next : item))),
21
+ onRemove: () => change(current().filter((_, at) => at !== index)),
22
+ value,
23
+ })),
24
+ onBlur: () => form.blur(name),
25
+ };
26
+ }
27
+ //# sourceMappingURL=useList.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useList.js","sourceRoot":"","sources":["../../src/adapters/useList.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,KAAK,IAAI,MAAM,wBAAwB,CAAC;AAE/C,OAAO,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC;AAWhC,MAAM,UAAU,OAAO,CAA6B,IAAoC,EAAE,IAAU;IACnG,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACzB,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;IAC5C,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;IAC7C,MAAM,MAAM,GAAG,YAAY,CAC1B,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,CACvH,CAAC;IACF,SAAS,MAAM,CAAC,IAAsB;QACrC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACzB,CAAC;IACD,SAAS,OAAO;QACf,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC;IAC/B,CAAC;IACD,OAAO;QACN,GAAG,EAAE,CAAC,KAAY,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,KAAK,CAAC,CAAC;QACpD,KAAK;QACL,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;YACpC,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC;YACpB,QAAQ,EAAE,CAAC,IAAW,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;YAC5F,QAAQ,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,KAAK,KAAK,CAAC,CAAC;YACjE,KAAK;SACL,CAAC,CAAC;QACH,MAAM,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;KAC7B,CAAC;AACH,CAAC"}
@@ -0,0 +1,17 @@
1
+ import { Schema } from "effect";
2
+ export interface NumberOptions {
3
+ readonly integer?: boolean;
4
+ readonly max?: number;
5
+ readonly min?: number;
6
+ readonly optional?: boolean;
7
+ }
8
+ export declare function number(options?: NumberOptions & {
9
+ readonly optional?: false;
10
+ }): Schema.Codec<number, string>;
11
+ export declare function number(options: NumberOptions): Schema.Codec<number | null, string>;
12
+ export interface ListOptions {
13
+ readonly max: number;
14
+ readonly min?: number;
15
+ }
16
+ export declare function list<TItem extends Schema.Constraint>(item: TItem, options: ListOptions): Schema.$Array<TItem>;
17
+ //# sourceMappingURL=field.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../src/field.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGhC,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC5B;AACD,wBAAgB,MAAM,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG;IAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAA;CAAE,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAC9G,wBAAgB,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC;AAepF,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,wBAAgB,IAAI,CAAC,KAAK,SAAS,MAAM,CAAC,UAAU,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,WAAW,wBAGtF"}
package/dist/field.js ADDED
@@ -0,0 +1,20 @@
1
+ import { Schema } from "effect";
2
+ import { emptyAsNull } from "./emptyAsNull.js";
3
+ export function number(options = {}) {
4
+ let populated = Schema.NonEmptyString.pipe(Schema.decodeTo(Schema.FiniteFromString));
5
+ if (options.min !== undefined) {
6
+ populated = populated.check(Schema.isGreaterThanOrEqualTo(options.min));
7
+ }
8
+ if (options.max !== undefined) {
9
+ populated = populated.check(Schema.isLessThanOrEqualTo(options.max));
10
+ }
11
+ if (options.integer) {
12
+ populated = populated.check(Schema.isInt());
13
+ }
14
+ return Schema.Trim.pipe(Schema.decodeTo(options.optional ? emptyAsNull(populated) : populated));
15
+ }
16
+ export function list(item, options) {
17
+ const values = Schema.Array(item).check(Schema.isMaxLength(options.max));
18
+ return options.min === undefined ? values : values.check(Schema.isMinLength(options.min));
19
+ }
20
+ //# sourceMappingURL=field.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field.js","sourceRoot":"","sources":["../src/field.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAU/C,MAAM,UAAU,MAAM,CAAC,OAAO,GAAkB,EAAE;IACjD,IAAI,SAAS,GAAG,MAAM,CAAC,cAAc,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC;IACrF,IAAI,OAAO,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;QAC/B,SAAS,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,sBAAsB,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;IACzE,CAAC;IACD,IAAI,OAAO,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;QAC/B,SAAS,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,mBAAmB,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;IACtE,CAAC;IACD,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;QACrB,SAAS,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;IAC7C,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;AACjG,CAAC;AAOD,MAAM,UAAU,IAAI,CAAkC,IAAW,EAAE,OAAoB;IACtF,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;IACzE,OAAO,OAAO,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;AAC3F,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/form",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Schema-derived Effect atom forms with optional React hooks",
5
5
  "keywords": [
6
6
  "effect"
@@ -43,14 +43,14 @@
43
43
  "README.md"
44
44
  ],
45
45
  "dependencies": {
46
- "@shivaedev/errors": "0.7.0"
46
+ "@shivaedev/errors": "0.8.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@effect/atom-react": "4.0.6",
50
50
  "@effect/vitest": "4.0.6",
51
- "@shivaedev/quality": "0.57.1",
51
+ "@shivaedev/quality": "0.58.0",
52
52
  "@shivaedev/test": "0.14.0",
53
- "@shivaedev/test-story": "0.36.0",
53
+ "@shivaedev/test-story": "0.37.0",
54
54
  "@types/node": "24.19.2",
55
55
  "@types/react": "19.3.0",
56
56
  "effect": "4.0.6",
@@ -0,0 +1,17 @@
1
+ import type { InputEvent } from "react";
2
+
3
+ export function numberInput(field: {
4
+ readonly name: string;
5
+ readonly value: string;
6
+ readonly onChange: (value: string) => void;
7
+ readonly onBlur: () => void;
8
+ }) {
9
+ return {
10
+ inputMode: "decimal" as const,
11
+ name: field.name,
12
+ onBlur: field.onBlur,
13
+ onInput: (event: InputEvent<HTMLInputElement>) => field.onChange(event.currentTarget.value),
14
+ type: "text" as const,
15
+ value: field.value,
16
+ };
17
+ }
@@ -0,0 +1,39 @@
1
+ import { useAtomMount, useAtomRef, useAtomValue } from "@effect/atom-react";
2
+ import * as Atom from "effect/reactivity/Atom";
3
+ import type * as AtomRef from "effect/reactivity/AtomRef";
4
+ import { useMemo } from "react";
5
+
6
+ interface ListForm<TKey extends string, TItem> {
7
+ readonly blur: (name: TKey) => void;
8
+ readonly change: (name: TKey, value: readonly TItem[]) => void;
9
+ readonly dirty: Atom.Atom<boolean>;
10
+ readonly error: (name: TKey) => Atom.Atom<string | undefined>;
11
+ readonly errorAt: (path: readonly [TKey, ...PropertyKey[]]) => Atom.Atom<string | undefined>;
12
+ readonly field: (name: TKey) => AtomRef.ReadonlyRef<readonly TItem[]>;
13
+ }
14
+
15
+ export function useList<TKey extends string, TItem>(form: ListForm<NoInfer<TKey>, TItem>, name: TKey) {
16
+ useAtomMount(form.dirty);
17
+ const values = useAtomRef(form.field(name));
18
+ const error = useAtomValue(form.error(name));
19
+ const errors = useAtomValue(
20
+ useMemo(() => Atom.readable((get) => values.map((_, index) => get(form.errorAt([name, index])))), [form, name, values]),
21
+ );
22
+ function change(next: readonly TItem[]) {
23
+ form.change(name, next);
24
+ }
25
+ function current() {
26
+ return form.field(name).value;
27
+ }
28
+ return {
29
+ add: (value: TItem) => change([...current(), value]),
30
+ error,
31
+ items: values.map((value, index) => ({
32
+ error: errors[index],
33
+ onChange: (next: TItem) => change(current().map((item, at) => (at === index ? next : item))),
34
+ onRemove: () => change(current().filter((_, at) => at !== index)),
35
+ value,
36
+ })),
37
+ onBlur: () => form.blur(name),
38
+ };
39
+ }
package/src/field.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { Schema } from "effect";
2
+ import { emptyAsNull } from "./emptyAsNull.ts";
3
+
4
+ export interface NumberOptions {
5
+ readonly integer?: boolean;
6
+ readonly max?: number;
7
+ readonly min?: number;
8
+ readonly optional?: boolean;
9
+ }
10
+ export function number(options?: NumberOptions & { readonly optional?: false }): Schema.Codec<number, string>;
11
+ export function number(options: NumberOptions): Schema.Codec<number | null, string>;
12
+ export function number(options: NumberOptions = {}): Schema.Codec<number | null, string> {
13
+ let populated = Schema.NonEmptyString.pipe(Schema.decodeTo(Schema.FiniteFromString));
14
+ if (options.min !== undefined) {
15
+ populated = populated.check(Schema.isGreaterThanOrEqualTo(options.min));
16
+ }
17
+ if (options.max !== undefined) {
18
+ populated = populated.check(Schema.isLessThanOrEqualTo(options.max));
19
+ }
20
+ if (options.integer) {
21
+ populated = populated.check(Schema.isInt());
22
+ }
23
+ return Schema.Trim.pipe(Schema.decodeTo(options.optional ? emptyAsNull(populated) : populated));
24
+ }
25
+
26
+ export interface ListOptions {
27
+ readonly max: number;
28
+ readonly min?: number;
29
+ }
30
+
31
+ export function list<TItem extends Schema.Constraint>(item: TItem, options: ListOptions) {
32
+ const values = Schema.Array(item).check(Schema.isMaxLength(options.max));
33
+ return options.min === undefined ? values : values.check(Schema.isMinLength(options.min));
34
+ }