@shivaedev/form 0.14.0 → 0.16.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 +13 -0
- package/README.md +76 -9
- package/dist/adapters/numberInput.d.ts +15 -0
- package/dist/adapters/numberInput.d.ts.map +1 -0
- package/dist/adapters/numberInput.js +11 -0
- package/dist/adapters/numberInput.js.map +1 -0
- package/dist/adapters/useList.d.ts +23 -0
- package/dist/adapters/useList.d.ts.map +1 -0
- package/dist/adapters/useList.js +27 -0
- package/dist/adapters/useList.js.map +1 -0
- package/dist/field.d.ts +17 -0
- package/dist/field.d.ts.map +1 -0
- package/dist/field.js +20 -0
- package/dist/field.js.map +1 -0
- package/package.json +7 -7
- package/src/adapters/numberInput.ts +17 -0
- package/src/adapters/useList.ts +39 -0
- package/src/field.ts +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0 - 2026-11-04
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- TypeScript source exports now use `@shivaedev/source` instead of `source`. Replace `source` with `@shivaedev/source` in Node `--conditions`, TypeScript `customConditions`, and Vite/Vitest `resolve.conditions`. Bare `source` selects built JavaScript; no compatibility alias is provided.
|
|
8
|
+
|
|
9
|
+
## 0.15.0 - 2026-11-04
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `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.
|
|
14
|
+
- `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.
|
|
15
|
+
|
|
3
16
|
## 0.14.0 - 2026-11-03
|
|
4
17
|
|
|
5
18
|
### 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
|
|
315
|
-
|
|
|
316
|
-
| `@shivaedev/form/form.ts`
|
|
317
|
-
| `@shivaedev/form/shape.ts`
|
|
318
|
-
| `@shivaedev/form/fieldRejection.ts`
|
|
319
|
-
| `@shivaedev/form/adapters/react.ts`
|
|
320
|
-
| `@shivaedev/form/draftSession.ts`
|
|
321
|
-
| `@shivaedev/form/
|
|
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.
|
|
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"}
|
package/dist/field.d.ts
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Schema-derived Effect atom forms with optional React hooks",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect"
|
|
@@ -20,14 +20,14 @@
|
|
|
20
20
|
"imports": {
|
|
21
21
|
"#test/*": "./src/test-support/*",
|
|
22
22
|
"#*.ts": {
|
|
23
|
-
"source": "./src/*.ts",
|
|
23
|
+
"@shivaedev/source": "./src/*.ts",
|
|
24
24
|
"types": "./dist/*.d.ts",
|
|
25
25
|
"default": "./dist/*.js"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"exports": {
|
|
29
29
|
"./*.ts": {
|
|
30
|
-
"source": "./src/*.ts",
|
|
30
|
+
"@shivaedev/source": "./src/*.ts",
|
|
31
31
|
"types": "./dist/*.d.ts",
|
|
32
32
|
"default": "./dist/*.js"
|
|
33
33
|
},
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
"README.md"
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@shivaedev/errors": "0.
|
|
46
|
+
"@shivaedev/errors": "0.9.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.
|
|
52
|
-
"@shivaedev/test": "0.
|
|
53
|
-
"@shivaedev/test-story": "0.
|
|
51
|
+
"@shivaedev/quality": "0.60.0",
|
|
52
|
+
"@shivaedev/test": "0.15.0",
|
|
53
|
+
"@shivaedev/test-story": "0.38.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
|
+
}
|