react-luna-form 0.0.84 → 0.0.86

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.
Files changed (44) hide show
  1. package/dist/client/cjs/index.js +1 -1
  2. package/dist/client/esm/index.js +1 -1
  3. package/dist/config/cjs/index.js +1 -1
  4. package/dist/config/esm/index.js +1 -1
  5. package/dist/schema/cjs/index.js +1 -1
  6. package/dist/schema/esm/index.js +1 -1
  7. package/dist/server/cjs/index.js +1 -1
  8. package/dist/server/esm/index.js +1 -1
  9. package/dist/types/luna-core/type.d.ts +31 -1
  10. package/dist/types/luna-core/util/build.d.ts +3 -1
  11. package/dist/types/luna-core/util/extract.d.ts +1 -0
  12. package/dist/types/luna-core/util/is-input.d.ts +13 -0
  13. package/dist/types/luna-core/util/schema.d.ts +5 -5
  14. package/dist/types/luna-react/src/client/component/field/field.d.ts +19 -0
  15. package/dist/types/luna-react/src/client/context/failed-submit-context.d.ts +3 -0
  16. package/dist/types/luna-react/src/client/context/reveal-context.d.ts +1 -0
  17. package/dist/types/luna-react/src/client/hook/use-disclosure.d.ts +1 -0
  18. package/dist/types/luna-react/src/client/hook/use-form-action.d.ts +2 -1
  19. package/dist/types/luna-react/src/client/hook/use-host-entry.d.ts +17 -0
  20. package/dist/types/luna-react/src/client/hook/use-input-core.d.ts +4 -6
  21. package/dist/types/luna-react/src/client/hook/use-input.d.ts +1 -1
  22. package/dist/types/luna-react/src/client/hook/use-schema.d.ts +4 -2
  23. package/dist/types/luna-react/src/client/hook/use-value.d.ts +2 -2
  24. package/dist/types/luna-react/src/client/lib/compose-list-value.d.ts +29 -2
  25. package/dist/types/luna-react/src/client/lib/error-store.d.ts +3 -0
  26. package/dist/types/luna-react/src/client/lib/host-value-store.d.ts +30 -0
  27. package/dist/types/luna-react/src/client/lib/list-store.d.ts +21 -0
  28. package/dist/types/luna-react/src/client/lib/source-store.d.ts +3 -0
  29. package/dist/types/luna-react/src/client/lib/state-store.d.ts +3 -0
  30. package/dist/types/luna-react/src/client/lib/store-helper.d.ts +8 -0
  31. package/dist/types/luna-react/src/client/lib/value-store.d.ts +3 -0
  32. package/dist/types/luna-react/src/component/collapsible.d.ts +2 -1
  33. package/dist/types/luna-react/src/component/form.d.ts +2 -0
  34. package/dist/types/luna-react/src/component/input/input-base.d.ts +1 -0
  35. package/dist/types/luna-react/src/type.d.ts +1 -0
  36. package/docs/events/change.md +47 -1
  37. package/docs/fields/custom-inputs.md +4 -0
  38. package/docs/fields/input.md +9 -2
  39. package/docs/fields/list.md +2 -0
  40. package/docs/fields/specialized-selectors.md +4 -0
  41. package/docs/forms/submit.md +7 -2
  42. package/docs/structure/sections.md +3 -1
  43. package/docs/validation/overview.md +10 -1
  44. package/package.json +5 -5
@@ -1,5 +1,7 @@
1
+ import { type Nullable } from '../../../../luna-core/index';
1
2
  import type { MountedList } from './list-store';
2
- import type { Nullable } from '../../../../luna-core/index';
3
+ /** A list's rows as a consumer is given them, in order. */
4
+ export type ListRows = Array<Record<string, unknown>>;
3
5
  export declare function itemKey(listName: string, stableId: number, leafName: string): string;
4
6
  /**
5
7
  * The three places a leaf can be found, declared in the order they are tried.
@@ -38,4 +40,29 @@ export type ValueSources = {
38
40
  * @param listName - The list's name, already carrying its ancestors' stable
39
41
  * ids (`rules.0.rule`), which is what makes the keys unambiguous.
40
42
  */
41
- export declare function composeListValue(listName: string, { items, leafNames }: MountedList, sources: ValueSources): Array<Record<string, unknown>>;
43
+ export declare function composeListValue(listName: string, { items, leafNames }: MountedList, sources: ValueSources): ListRows;
44
+ /**
45
+ * Every positional name a change to a list's rows can have left out of date,
46
+ * each with what it holds now.
47
+ *
48
+ * A consumer that keeps each report in one record -- the way the docs suggest
49
+ * keeping values -- holds a row's leaves there twice: inside the list's array,
50
+ * and under their own positional names, `items.0.value`, from the reports the
51
+ * leaves made as they were typed in. Removing a row moves every row after it
52
+ * up one position; assigning rows rewrites what every position holds. The
53
+ * array is reported again either way, but the names would go on holding what
54
+ * the rows used to hold, and `resolveEntry` tries a flat name before the path
55
+ * into the array: the next row to read one of them gets a row that is gone.
56
+ *
57
+ * So every name from `from` to the end of the longer of the two lists comes
58
+ * back, with what its position holds now, or `undefined` where no row is left
59
+ * -- whether or not that differs from before, because what the consumer holds
60
+ * is not necessarily what the list last said.
61
+ *
62
+ * @param base - The list's positional name, as the consumer addresses it.
63
+ * @param from - The first position the change can have touched.
64
+ */
65
+ export declare function positionalEntries(base: string, from: number, previous: ListRows, next: ListRows): Array<{
66
+ name: string;
67
+ value: unknown;
68
+ }>;
@@ -1,6 +1,9 @@
1
1
  export declare const clearInputErrorAtom: import("jotai").WritableAtom<null, [names: string[]], void> & {
2
2
  init: null;
3
3
  };
4
+ export declare const releaseInputErrorAtom: import("jotai").WritableAtom<null, [names: string[]], void> & {
5
+ init: null;
6
+ };
4
7
  export declare const reportErrorAtom: import("jotai").WritableAtom<null, [newValue: Record<string, string[]>], void> & {
5
8
  init: null;
6
9
  };
@@ -0,0 +1,30 @@
1
+ import type { Nullable } from '../../../../luna-core/index';
2
+ export declare const hostValueAtom: import("jotai").PrimitiveAtom<Nullable<Record<string, unknown>>> & {
3
+ init: Nullable<Record<string, unknown>>;
4
+ };
5
+ /**
6
+ * One field's entry in that record.
7
+ *
8
+ * Keyed by the name the record actually uses -- the POSITIONAL one. A leaf
9
+ * inside a list is named after its row's stable id, and the two diverge the
10
+ * moment a non-tail row is removed, so the caller translates before it
11
+ * subscribes. That translation lives in a React context, which an atom cannot
12
+ * read; doing it at the call site is what keeps this correct where a memo
13
+ * comparator could not be. See `ListPathContext`.
14
+ *
15
+ * Not released the way the per-name families in `store-helper` are, and it does
16
+ * not need to be for the same reason it is keyed this way: a position is reused
17
+ * where a stable id only ever climbs, so the keys here are bounded by the
18
+ * widest the form has ever been rather than by every row it has ever held.
19
+ * Measured going 1 -> 20 -> 14 -> 20 rows: the released families fall back to
20
+ * 23 and this one stays at 29, its high-water mark. Worth knowing before
21
+ * someone reads the absence of a release as an oversight.
22
+ */
23
+ /** What the host says about one field, with the sentinel already read. */
24
+ export type HostEntry = {
25
+ /** The host names this field. A field it does not name reads `undefined`. */
26
+ found: boolean;
27
+ value: unknown;
28
+ };
29
+ export declare function readHostEntry(entry: unknown): HostEntry;
30
+ export declare const hostEntryAtom: import("jotai-family").AtomFamily<string, import("jotai").Atom<unknown>>;
@@ -12,6 +12,27 @@ export declare const reportPendingListRowsAtom: import("jotai-family").AtomFamil
12
12
  export type MountedList = {
13
13
  items: readonly number[];
14
14
  leafNames: readonly string[];
15
+ /**
16
+ * The list's own `hidden`, as its definition declares it.
17
+ *
18
+ * Here rather than looked up, because there is nowhere to look it up: the
19
+ * schema registry `getField` answers from is filled by `onRegister` on the
20
+ * input path, and a container never goes through it. That gap is what let a
21
+ * list keep its values through a hide -- the branch that clears a target
22
+ * reverting to its static hidden asked only that registry, found nothing,
23
+ * and skipped the list, while an explicit `hidden: true` cleared it. Same
24
+ * declaration, two outcomes. See `tests/e2e/hidden-clear.spec.ts`.
25
+ *
26
+ * A list that is not on screen has no entry, and that is the right answer
27
+ * rather than a gap: the only target worth clearing is one that was showing.
28
+ */
29
+ hidden?: boolean;
30
+ /**
31
+ * The list's own `advanced.keepValue`, published for the same reason as
32
+ * `hidden` and flattened on the way in: this registry answers about a list
33
+ * that is on screen, not about the JSON that declared it.
34
+ */
35
+ keepValue?: boolean;
15
36
  };
16
37
  export declare const mountedListsAtom: import("jotai").PrimitiveAtom<Record<string, MountedList>> & {
17
38
  init: Record<string, MountedList>;
@@ -3,3 +3,6 @@ export declare const reportSourceAtom: import("jotai-family").AtomFamily<string,
3
3
  export declare const clearInputSourceAtom: import("jotai").WritableAtom<null, [contributorNames: string[]], void> & {
4
4
  init: null;
5
5
  };
6
+ export declare const releaseInputSourceAtom: import("jotai").WritableAtom<null, [names: string[]], void> & {
7
+ init: null;
8
+ };
@@ -2,4 +2,7 @@ import type { FieldState } from '../../../../luna-core/index';
2
2
  export declare const fieldStateAtom: import("jotai").PrimitiveAtom<Record<string, FieldState>> & {
3
3
  init: Record<string, FieldState>;
4
4
  };
5
+ export declare const releaseFieldStateAtom: import("jotai").WritableAtom<null, [names: string[]], void> & {
6
+ init: null;
7
+ };
5
8
  export declare const reportFieldStateAtom: import("jotai-family").AtomFamily<string, import("jotai").WritableAtom<FieldState | undefined, [newValue: FieldState | undefined], void>>;
@@ -5,6 +5,11 @@ type NestedAtomFamilyOptions<TInner> = {
5
5
  };
6
6
  export declare function omitKey<T extends Record<string, unknown>>(obj: T, key: string): T;
7
7
  export declare function createNestedRecordAtomFamily<TInner, TRecord extends Record<string, Record<string, TInner>> = Record<string, Record<string, TInner>>>(baseAtom: PrimitiveAtom<TRecord>, options?: NestedAtomFamilyOptions<TInner>): import("jotai-family").AtomFamily<string, import("jotai").WritableAtom<TInner | undefined, [target: string, value: TInner | undefined], void>>;
8
+ export declare function createNestedReleaseAtom(family: {
9
+ remove: (name: string) => void;
10
+ }): import("jotai").WritableAtom<null, [names: string[]], void> & {
11
+ init: null;
12
+ };
8
13
  export declare function createNestedClearAtom<TInner>(baseAtom: PrimitiveAtom<Record<string, Record<string, TInner>>>): import("jotai").WritableAtom<null, [contributorNames: string[]], void> & {
9
14
  init: null;
10
15
  };
@@ -21,6 +26,9 @@ export declare function createAtomStore<T>(initialValue?: Record<string, T>): {
21
26
  bulkReport: import("jotai").WritableAtom<null, [newValue: Record<string, T>], void> & {
22
27
  init: null;
23
28
  };
29
+ release: import("jotai").WritableAtom<null, [names: string[]], void> & {
30
+ init: null;
31
+ };
24
32
  report: import("jotai-family").AtomFamily<string, import("jotai").WritableAtom<NonNullable<T> | undefined, [newValue: T | undefined], void>>;
25
33
  };
26
34
  export {};
@@ -7,6 +7,9 @@ export declare const clearAllValueAtom: import("jotai").WritableAtom<null, [], v
7
7
  export declare const clearInputValueAtom: import("jotai").WritableAtom<null, [names: string[]], void> & {
8
8
  init: null;
9
9
  };
10
+ export declare const releaseInputValueAtom: import("jotai").WritableAtom<null, [names: string[]], void> & {
11
+ init: null;
12
+ };
10
13
  export declare const reportValueAtom: import("jotai-family").AtomFamily<string, import("jotai").WritableAtom<{} | undefined, [newValue: unknown], void>>;
11
14
  export declare const appliedAutoFillAtom: import("jotai").PrimitiveAtom<Record<string, unknown>> & {
12
15
  init: Record<string, unknown>;
@@ -1,4 +1,5 @@
1
- export declare function Collapsible({ children, visible, }: Readonly<{
1
+ export declare function Collapsible({ children, onReveal, visible, }: Readonly<{
2
2
  children: React.ReactNode;
3
+ onReveal?: () => void;
3
4
  visible?: boolean;
4
5
  }>): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { type Definition, type Sections } from '../../../luna-core/index';
2
2
  import type { Config, Control, Slot } from '../type';
3
+ import type { FormEvent } from 'react';
3
4
  export declare function Form(props: Readonly<{
4
5
  advanced?: {
5
6
  step?: boolean;
@@ -11,6 +12,7 @@ export declare function Form(props: Readonly<{
11
12
  definition?: Definition;
12
13
  isPending?: boolean;
13
14
  noValidate?: boolean;
15
+ onSubmit?: (event: FormEvent<HTMLFormElement>) => void;
14
16
  readOnly?: boolean;
15
17
  sections: Sections;
16
18
  translations?: Record<string, string>;
@@ -7,5 +7,6 @@ export declare function InputBase(props: Readonly<{
7
7
  field: Field;
8
8
  horizontal?: boolean;
9
9
  lang?: string;
10
+ readOnly?: boolean;
10
11
  translations?: Record<string, string>;
11
12
  }>): import("react").ReactNode;
@@ -12,6 +12,7 @@ export type Children = (props: {
12
12
  dataAttributes?: DataAttributes;
13
13
  field: Field;
14
14
  horizontal?: boolean;
15
+ readOnly?: boolean;
15
16
  }) => React.ReactNode;
16
17
  export type InputConfig<T extends React.ElementType> = {
17
18
  types: string | string[];
@@ -159,7 +159,7 @@ A row's key can name a field that is itself a `list`, in which case its value is
159
159
  Every rule above holds one level down as well: an inner list assigns rather than adds, honours its own `advanced.length`, and keeps the identity of the rows that survive. Two more only come up here:
160
160
 
161
161
  - **A row that names an inner list without giving it rows empties it.** `"checks": []`, a `checks` key holding something that is not rows, and no `checks` key at all all mean the same thing — none — rather than leaving behind what the row in that position had.
162
- - **The consumer is told once for the whole tree**, under the outer list's name, with the inner rows already in it. The inner lists then report their own paths (`groups.0.checks`) as they take their rows, the same way they do when a row is added or removed by hand.
162
+ - **The consumer is told the whole tree at once**, under the outer list's name, with the inner rows already in it, and then every positional name the assignment rewrote (`groups.0.label`, `groups.1.checks.0.v`) with what that position holds now, or nothing where no row is left. A consumer that keeps each report in one record would otherwise go on holding the values of the rows that went away under those names. The inner lists then report their own paths (`groups.0.checks`) as they take their rows, the same way they do when a row is added or removed by hand.
163
163
 
164
164
  This is what the relative syntax cannot do. `"groups/checks"` reaches the inner list of the row the sender is sitting in, and `"groups.0.checks"` reaches the first one; neither can say "every group", so a control that appends a group has to assign the outer list and carry the inner rows along with it.
165
165
 
@@ -232,6 +232,52 @@ A target that holds nothing is not announced — there is nothing to clear —
232
232
  and a target that keeps its own values under compound names (a `group`, a
233
233
  `list`) has each of those cleared and reported in its own right.
234
234
 
235
+ It does not matter how the form phrases the hiding. A rule whose state is
236
+ `hidden: true`, and a rule that reveals with `hidden: false` and stops matching
237
+ so the field falls back to its own `hidden`, are the same act and clear the
238
+ same values -- containers included.
239
+
240
+ What the cleared target decided goes with it. A target with `state` rules of its
241
+ own asserted them on the strength of its value, and that value is gone, so its
242
+ rules are undone as if it held nothing: a field it revealed is hidden again --
243
+ and cleared, if it too falls back to `hidden` -- and so on down the chain.
244
+ Showing the first target again brings it back empty, and nothing it had revealed
245
+ comes back with it. A target that keeps its value through the hide keeps what
246
+ its rules decided as well; see below.
247
+
248
+ #### Keeping a value through a hide
249
+
250
+ A field that declares `advanced.keepValue: true` is put away rather than
251
+ dropped: hiding it leaves what it held, showing it again gives it back, and the
252
+ consumer is told nothing because nothing was taken.
253
+
254
+ ```json
255
+ {
256
+ "label": "Raw text",
257
+ "name": "raw",
258
+ "type": "textarea",
259
+ "hidden": true,
260
+ "advanced": { "keepValue": true }
261
+ }
262
+ ```
263
+
264
+ It sits in `advanced` beside [`transient`](#transient-controls), which is the
265
+ other key that decides what ends up stored, rather than beside `hidden`: the
266
+ two read as a pair, but `hidden` is what the form does to the field and this is
267
+ what the field is.
268
+
269
+ It is declared on the FIELD and not on the rule, for the same reason the clear
270
+ does not care how the hiding was phrased: a field either survives being put
271
+ away or it does not, and which of several rules happened to hide it this time
272
+ says nothing about that. A
273
+ [`list`](../fields/list.md#advanced-configuration-advanced-object) may declare
274
+ it too, and then it covers every value its rows hold.
275
+
276
+ Reach for it where two fields are two views of the same thing and the form
277
+ swaps between them -- there, hiding one is a change of scenery, not a change of
278
+ mind. Leave it off everywhere else: a value the user cannot see and cannot
279
+ correct is one that still gets submitted.
280
+
235
281
  #### What `when` compares against for a `chips` field
236
282
 
237
283
  A `chips` field [holds an array](../fields/select.md#a-chips-value-is-always-an-array),
@@ -60,6 +60,10 @@ its own handlers must call the ones it received, or the form never learns the
60
60
  value changed: validation will not run, and neither will any `change` event the
61
61
  field declares.
62
62
 
63
+ A `readonly` field arrives `disabled` and without a `name`. Its value is still
64
+ submitted, but by the form, in hidden inputs of its own: nothing your component
65
+ renders for it is sent, so it is sent once, whatever your component renders.
66
+
63
67
  ## Forwarding `id` is what gives the field its name
64
68
 
65
69
  The label is rendered by this library, outside your component, as
@@ -28,8 +28,8 @@ Luna Form's input fields inherit the standard logical properties available to al
28
28
  - Or an object payload: `{ title: string, message: string, collapsed?: boolean }`.
29
29
  - **`placeholder`** _(string, optional)_: The temporary placeholder text shown when the input is empty.
30
30
  - **`required`** _(boolean, optional)_: If `true`, standard browser/HTML5 validation makes the field mandatory.
31
- - **`disabled`** _(boolean, optional)_: If `true`, the field is non-interactive and blocked from events.
32
- - **`readonly`** _(boolean, optional)_: If `true`, the field's value is locked and cannot be modified by the user.
31
+ - **`disabled`** _(boolean, optional)_: If `true`, the field is non-interactive and blocked from events, and its value is not submitted.
32
+ - **`readonly`** _(boolean, optional)_: If `true`, the field's value is locked and cannot be modified by the user, but it is still submitted. The control renders disabled, the one lock every input component understands, and the form sends the value itself. A field that is both `readonly` and `disabled` is not submitted.
33
33
  - **`hidden`** _(boolean, optional)_: If `true`, the field is hidden from the user interface.
34
34
  - **`order`** _(number, optional)_: A numeric order determining the field's position relative to adjacent fields.
35
35
 
@@ -47,6 +47,7 @@ The `advanced` property dictates finer HTML details, interactive structures, and
47
47
  - **`reverse`** _(boolean)_: `checkbox` only, and it exists to turn a default **off**. A checkbox renders its box first and its label after it; `reverse: false` swaps them, `true` changes nothing, and on any other field type the key is not read. See [Layout](../structure/layout.md#advancedreverse-checkbox-only).
48
48
  - **`aria`** _(object)_: Map of specific ARIA attributes for screen readers (e.g., `{ "aria-label": "Custom label" }`).
49
49
  - **`data`** _(object)_: Map of specific HTML data attributes (e.g., `{ "data-test-id": "my-field" }`).
50
+ - **`keepValue`** _(boolean)_: If `true`, hiding the field leaves its value behind instead of taking it — the field is put away rather than dropped. See [Keeping a value through a hide](../events/change.md#keeping-a-value-through-a-hide).
50
51
  - **`transient`** _(boolean)_: If `true`, the field fires its change events and keeps nothing of its own — it is neither stored nor reported to the consumer. For a control whose whole job is to write to _another_ field. See [Transient controls](../events/change.md#transient-controls).
51
52
 
52
53
  ### Text & Numeric Options (`input/text`, `input/email`, `input/tel`, `input/password`, `input/number`)
@@ -54,12 +55,17 @@ The `advanced` property dictates finer HTML details, interactive structures, and
54
55
  These basic field types support extra manipulation properties inside the `advanced` block:
55
56
 
56
57
  - **`length`** _({ min?: number, max?: number })_: Applies HTML structural limits (`minlength` / `maxlength`, or `min` / `max` depending on the input type).
58
+ - **`step`** _(number)_ (`input/number` only): A number is whole unless it declares a step, which is the browser's own default of 1. The step is rendered on the input, so its arrows move by it, and validation accepts only values on it, counted from `length.min` when there is one, as the browser does: `0.01` for a price, `0.5` for halves, `0.001` for three decimals. A step has to be a number above 0. The browser ignores 0 and below, and so does the form; text such as `"any"` is no step either, and it is never rendered. Either way the number stays whole.
57
59
  - **`transform`** _(string | string[])_: Safely intercepts user inputs and manipulates content dynamically. Options include:
58
60
  - `"lowercase"`
59
61
  - `"uppercase"`
60
62
  - `"remove-space"`
61
63
  - `"remove-accent"`
62
64
 
65
+ ### Empty and required numbers
66
+
67
+ A required `input/number` accepts `0` and negative numbers: required means a value is present, not that it is at least 1. An optional one left empty is not submitted at all, rather than submitted as `0`, and its `length` bounds only apply to a value that is there. `select/year` and `select/month` read an empty selection the same way.
68
+
63
69
  ### Temporal Options (`input/date`, `input/time`)
64
70
 
65
71
  Date and time inputs omit transformations and instead expose a `format` property directly under `advanced` for displaying values properly:
@@ -76,6 +82,7 @@ The `validation` object resolves form errors overriding generic defaults, mappin
76
82
  - **`required`** _(string)_: Specifies the error message exposed when the element is marked exactly as `required: true` and the field is empty.
77
83
  - **`email`** _(string)_: Error message specifically asserting an invalid email format.
78
84
  - **`length`** _({ min?: string, max?: string })_: Specific string messages shown when input lengths are breached.
85
+ - **`step`** _(string)_: The message shown when an `input/number` is off its step: a decimal on a number that declares no step, or a value off the `advanced.step` it declares. The form's dictionary translates it, as it does every other message.
79
86
  - **`custom`** _(CustomValidation | CustomValidation[])_: Powerful conditional-based logic blocks. An array specifying:
80
87
  - `field`: Optional target reference string.
81
88
  - `operator`: Logical evaluation operations (e.g., `eq`, `neq`, `gt`, `lt`).
@@ -26,6 +26,8 @@ The `advanced` property customizes structural interaction rules and layout rende
26
26
  - **`length`** _({ min?: number, max?: number })_: Configures restrictions on how many items can be generated. Enforces boundary conditions where minimum instances ensure permanent default items and maximum instances automatically disable insertion toggles. The matching error messages are declared under the list's `validation.length` — see the [Validation reference](../validation/overview.md#list-length).
27
27
  Both bounds apply to the rows a list opens with, not only to the ones added by hand: an initial `value` shorter than `min` leaves the remainder standing and empty, and one longer than `max` stops at `max`. A [`value` event](../events/change.md#targeting-a-list) assigning rows is clamped the same way.
28
28
  - **`collapsed`** _(boolean)_: When enabled, list items default to a visually collapsed representation upon mounting or adding.
29
+ Collapsing only hides a row: its fields keep their values, still validate and are still submitted, and a submit that fails on one of them opens the row so the error can be seen.
30
+ - **`keepValue`** _(boolean)_: If `true`, hiding the list leaves the values behind instead of taking them. A list holds nothing under its own name, so declaring it here is a statement about the rows: it covers every value they hold. See [Keeping a value through a hide](../events/change.md#keeping-a-value-through-a-hide).
29
31
 
30
32
  ### Collapsed Previews (`preview` object)
31
33
 
@@ -17,6 +17,8 @@ Renders a dropdown with the 12 months of the year.
17
17
  }
18
18
  ```
19
19
 
20
+ An optional month nobody picked is not submitted at all, and a required one asks for a value, the way an [`input/number`](input.md#empty-and-required-numbers) does.
21
+
20
22
  ### 2. Year Selector (`select/year`)
21
23
 
22
24
  Renders a dropdown with a range of years.
@@ -38,6 +40,8 @@ Renders a dropdown with a range of years.
38
40
  }
39
41
  ```
40
42
 
43
+ An optional year nobody picked is not submitted at all, and a required one asks for a value, the way an [`input/number`](input.md#empty-and-required-numbers) does.
44
+
41
45
  ### 3. Day Selector (`select/day`)
42
46
 
43
47
  Renders a dropdown with days 1 through 31.
@@ -103,8 +103,13 @@ If the values have to survive, hold them outside the form: mirror
103
103
  people, which is that a key you stop sending is not the same as a key you send
104
104
  empty.
105
105
 
106
- A **failed** validation is the opposite: the submitted values are handed back to
107
- the form, so the user's typing is still on screen next to the errors.
106
+ A **failed** submit is the opposite: nothing is cleared or reset, so what the
107
+ user entered stays on screen next to the errors — typed text, ticked boxes and
108
+ picked options alike. An error inside a collapsed
109
+ [section](../structure/sections.md#advancedcollapsible) or list row is brought
110
+ on screen too: the section or row holding it opens. The form does not take over
111
+ `value` either. What you pass is still what the fields read, so you can change
112
+ it, or put it back, while the errors are showing.
108
113
 
109
114
  ## Turning submit validation off
110
115
 
@@ -95,7 +95,7 @@ Renders the section closed, with its title as the toggle:
95
95
  }
96
96
  ```
97
97
 
98
- Three things to know before using it:
98
+ Four things to know before using it:
99
99
 
100
100
  - **It always starts closed.** There is no key that opens it, and reopening the
101
101
  form does not remember that the user had expanded it.
@@ -103,6 +103,8 @@ Three things to know before using it:
103
103
  them. They keep their values, they still validate, and they are still
104
104
  submitted. That is the opposite of what a [`state` action](../events/change.md#what-clearing-a-hidden-target-means)
105
105
  does when it hides a field, which unmounts it and clears the value.
106
+ - **A failed submit opens it.** When a field inside fails validation, the
107
+ section opens so the error can be seen, and it stays open.
106
108
  - **The description moves inside.** It renders with the fields rather than under
107
109
  the title, so it is not visible while the section is closed.
108
110
 
@@ -18,6 +18,7 @@ This page documents every key of the `validation` object plus the shared operato
18
18
  - **`required`** _(string)_: Message shown when a field marked `required: true` is empty.
19
19
  - **`email`** _(string)_: Message for an invalid email format (`input/email`).
20
20
  - **`length`** _({ min?: string, max?: string })_: Messages when the value's length breaches `advanced.length`.
21
+ - **`step`** _(string)_: Message when an `input/number` is off its step: a decimal on a whole number, or a value off `advanced.step`. See [Input](../fields/input.md).
21
22
  - **`custom`** _(CustomValidation | CustomValidation[])_: Cross-field comparison. See [Custom (cross-field)](#custom-cross-field).
22
23
  - **`requiredWhen`** _(WhenRule | WhenRule[])_: Conditionally required. See [requiredWhen](#requiredwhen).
23
24
  - **`pattern`** _(PatternRule)_: Regex/format check. See [pattern](#pattern).
@@ -110,6 +111,14 @@ Validates a string value against a regular expression.
110
111
 
111
112
  `pattern` never fires on an empty value — pair it with `required`/`requiredWhen` when the field is mandatory.
112
113
 
114
+ A `regex` or `flags` that does not compile is a mistake in the definition, not in the value. The rule then holds back every value it checks with its `message`, as it does a value that does not match, and the form keeps working. The same goes for a `pattern` among [`rules`](#rules).
115
+
116
+ A `regex` that compiles runs as written, on what the user types: in the browser when the form is submitted, and on the server when it validates with `buildFormSchema`. Nothing inspects it first, so it is part of the code the form ships with. Accept one only from someone you would let write that code.
117
+
118
+ The shape to avoid is a pattern that can split the same text between its repetitions in more than one way, such as `^(a+)+$`, `^(\w+\s?)+$` or `^(a|a)+$`. On a value that almost matches, it backtracks exponentially, and about thirty characters can hold the page, or the server, for seconds. A repetition that opens with a separator the one before cannot close with is not ambiguous and runs in linear time, as in `^[a-z]+(-[a-z]+)*$` or `^\d+(,\d+)*$`.
119
+
120
+ If definitions reach you from other people, check their patterns with a ReDoS analyzer before you accept them.
121
+
113
122
  ---
114
123
 
115
124
  ## `rules`
@@ -258,7 +267,7 @@ if (!result.success) {
258
267
 
259
268
  | Rule | Rendered `<Form>` submit | `buildFormSchema` (headless) |
260
269
  | --------------------------------------------- | ------------------------ | ---------------------------- |
261
- | `required`, `email`, `length` (field) | ✅ | ✅ |
270
+ | `required`, `email`, `length`, `step` (field) | ✅ | ✅ |
262
271
  | `custom` | ✅ | ✅ |
263
272
  | `requiredWhen` (top-level) | ✅ | ✅ |
264
273
  | `pattern` | ✅ | ✅ |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-luna-form",
3
- "version": "0.0.84",
3
+ "version": "0.0.86",
4
4
  "description": "A React library for building forms based on JSON",
5
5
  "main": "./dist/client/cjs/index.js",
6
6
  "module": "./dist/client/esm/index.js",
@@ -30,12 +30,12 @@
30
30
  "zod": "^4.0.0"
31
31
  },
32
32
  "devDependencies": {
33
- "@types/react": "19.2.18",
34
- "@types/react-dom": "19.2.5",
33
+ "@luna-form/core": "0.0.86",
34
+ "@types/react": "19.3.0",
35
+ "@types/react-dom": "19.3.0",
35
36
  "eslint-plugin-react": "7.37.5",
36
37
  "eslint-plugin-react-hooks": "7.1.1",
37
- "eslint-plugin-react-refresh": "0.5.5",
38
- "@luna-form/core": "0.0.84"
38
+ "eslint-plugin-react-refresh": "0.5.7"
39
39
  },
40
40
  "license": "Apache-2.0",
41
41
  "repository": {