@orkestrel/scaffold 0.0.67 → 0.0.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +4 -4
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1567 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +507 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +445 -6
- package/dist/src/core/index.cjs +38 -16
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +37 -17
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,1791 @@
|
|
|
1
|
+
# Form
|
|
2
|
+
|
|
3
|
+
> The environment-agnostic form document: a `FormSchema` stating what is asked, a `Form` holding
|
|
4
|
+
> the answers given against it, declarative `FieldRule` data stating what those answers must
|
|
5
|
+
> satisfy, and one submit that settles the form exactly once.
|
|
6
|
+
|
|
7
|
+
**A terminal prompt and a browser form are the same abstraction.** Both ask a person a set of
|
|
8
|
+
questions, hold partial answers, check them against rules, and finish once. What differs is the
|
|
9
|
+
host, and each host contributes the one part it owns. Parking is the server environment's
|
|
10
|
+
contribution: `answer` is a form whose result nobody has resolved yet, so a server can hand the
|
|
11
|
+
document out, wait, and receive the answers back through the same promise a local caller awaits.
|
|
12
|
+
Rendering is the browser's contribution, and it lives in the browser, not here. Nothing here
|
|
13
|
+
renders, reads a keyboard, or opens a socket. This package ships the document both hosts share.
|
|
14
|
+
|
|
15
|
+
The core is pure and total. Every guard returns `false` off-shape rather than throwing, every parser
|
|
16
|
+
returns `undefined` on refusal, and every value the form hands back is a frozen owned copy.
|
|
17
|
+
Form-owned refusals raise `FormError`, and each one names a caller mistake. A custom validator's own
|
|
18
|
+
throw escapes the mutation call unchanged.
|
|
19
|
+
|
|
20
|
+
## Surface
|
|
21
|
+
|
|
22
|
+
Everything in this guide is exported from `@orkestrel/form` ([`src/core`](../src/core)). Nothing is
|
|
23
|
+
internal: every declaration in the module is reachable from the barrel, so a consumer holds exactly
|
|
24
|
+
the mechanisms the package uses on itself.
|
|
25
|
+
|
|
26
|
+
### Open a form, answer it, and settle it
|
|
27
|
+
|
|
28
|
+
Builds a two-field sign-up form, fills both answers, submits, and awaits the settled result.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { createForm } from '@orkestrel/form'
|
|
32
|
+
|
|
33
|
+
const form = createForm({
|
|
34
|
+
label: 'Sign up',
|
|
35
|
+
fields: [
|
|
36
|
+
{ control: 'text', name: 'email', label: 'Email', rule: { required: true, email: true } },
|
|
37
|
+
{ control: 'confirm', name: 'terms', label: 'I accept the terms', rule: { required: true } },
|
|
38
|
+
],
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
form.fill({ email: 'ada@example.com', terms: true })
|
|
42
|
+
const result = form.submit() // { success: true, value: { email: 'ada@example.com', terms: true } }
|
|
43
|
+
const answers = await form.answer // { email: 'ada@example.com', terms: true }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Schema and fields
|
|
47
|
+
|
|
48
|
+
The document itself — what a form asks, in the order it asks it. All data, no behavior. Each
|
|
49
|
+
control's own interface adds its members to `FieldBase`, and the control values themselves are
|
|
50
|
+
worked through in [Controls](#controls).
|
|
51
|
+
|
|
52
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an
|
|
53
|
+
optional member and `plus` introducing its call-signature members, and a type alias's own type
|
|
54
|
+
literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`,
|
|
55
|
+
with the members it adds after.
|
|
56
|
+
|
|
57
|
+
| API | Kind | Shape | Summary |
|
|
58
|
+
| --------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
59
|
+
| `FormSchema` | interface | `{ name?, label?, help?, groups?, fields }` | Describes everything a form asks. |
|
|
60
|
+
| `FormGroup` | interface | `{ name, label, help? }` | Represents a named section of a form. |
|
|
61
|
+
| `FormField` | type | `TextField \| EditorField \| PasswordField \| NumberField \| DateField \| TimeField \| DatetimeField \| ColorField \| ConfirmField \| SelectField \| CheckboxField \| FileField` | Represents any field a schema can declare. |
|
|
62
|
+
| `FieldBase` | interface | `{ name, label?, help?, group?, hidden?, disabled?, locked?, rule?, meta? }` | Declares what every field carries, whatever its control. |
|
|
63
|
+
| `FieldControl` | type | `'text' \| 'editor' \| 'password' \| 'number' \| 'date' \| 'time' \| 'datetime' \| 'color' \| 'confirm' \| 'select' \| 'checkbox' \| 'file'` | Names the control a field presents to the person answering it. |
|
|
64
|
+
| `FieldChoice` | interface | `{ value, label, help?, disabled? }` | Represents one option a `select` or `checkbox` field offers. |
|
|
65
|
+
| `TextField` | interface | `FieldBase plus { control, default?, placeholder? }` | Represents a single line of text. |
|
|
66
|
+
| `EditorField` | interface | `FieldBase plus { control, default?, placeholder? }` | Represents text over many lines. |
|
|
67
|
+
| `PasswordField` | interface | `FieldBase plus { control, mask? }` | Represents a secret, obscured as it is typed. |
|
|
68
|
+
| `NumberField` | interface | `FieldBase plus { control, default?, placeholder? }` | Represents a number. |
|
|
69
|
+
| `DateField` | interface | `FieldBase plus { control, default? }` | Represents a calendar date, held as the control's own `YYYY-MM-DD` string. |
|
|
70
|
+
| `TimeField` | interface | `FieldBase plus { control, default? }` | Represents a time of day, held as the control's own `HH:MM` string, with seconds optional. |
|
|
71
|
+
| `DatetimeField` | interface | `FieldBase plus { control, default? }` | Represents a date and a time of day together, with no zone, held as the control's own string. |
|
|
72
|
+
| `ColorField` | interface | `FieldBase plus { control, default? }` | Represents a color, held as the control's own six-digit `#rrggbb` string. |
|
|
73
|
+
| `ConfirmField` | interface | `FieldBase plus { control, default? }` | Represents a single on/off box, holding a boolean. |
|
|
74
|
+
| `SelectField` | interface | `FieldBase plus { control, choices, default?, open? }` | Represents one choice out of a list. |
|
|
75
|
+
| `CheckboxField` | interface | `FieldBase plus { control, choices, default? }` | Represents any number of choices out of a list, holding the checked values. |
|
|
76
|
+
| `FileField` | interface | `FieldBase plus { control, accept?, multiple? }` | Represents one or more files, by name. |
|
|
77
|
+
|
|
78
|
+
### Answers and rules
|
|
79
|
+
|
|
80
|
+
What a form holds, what its answers must satisfy, and how a failure reports itself.
|
|
81
|
+
|
|
82
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an
|
|
83
|
+
optional member and `plus` introducing its call-signature members, and a type alias's own type
|
|
84
|
+
literal with a union's arms escaped as `\|`.
|
|
85
|
+
|
|
86
|
+
| API | Kind | Shape | Summary |
|
|
87
|
+
| ------------------- | --------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
|
88
|
+
| `FieldValue` | type | `string \| number \| boolean \| readonly string[]` | Represents every value a field can hold. |
|
|
89
|
+
| `FormValues` | type | `Readonly<Record<string, FieldValue>>` | Represents a form's answers, keyed by field name. |
|
|
90
|
+
| `FieldRule` | interface | `{ required?, minimum?, maximum?, step?, pattern?, email?, url?, integer?, alphanumeric?, custom? }` | Represents the constraints one field's value must satisfy. |
|
|
91
|
+
| `FieldRuleName` | type | `Exclude<keyof FieldRule, 'custom'>` | Lists every rule that reports its failure by name. |
|
|
92
|
+
| `FieldValidator` | type | `(value: FieldValue \| undefined, values: FormValues) => true \| string` | Checks one value against the whole form. |
|
|
93
|
+
| `FieldError` | interface | `{ field, message, rule? }` | Represents one failed check against one field. |
|
|
94
|
+
| `EvaluationOptions` | interface | `{ messages?, disabled? }` | Describes how to check a schema against a set of answers. |
|
|
95
|
+
|
|
96
|
+
### The form
|
|
97
|
+
|
|
98
|
+
The entity, its factory, its contract, and the error it raises.
|
|
99
|
+
|
|
100
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an
|
|
101
|
+
optional member and `plus` introducing its call-signature members, and a type alias's own type
|
|
102
|
+
literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature,
|
|
103
|
+
and a guard row's the type it narrows to. A class row's `Shape` cell holds the interface it
|
|
104
|
+
implements, or its constructor signature where it implements none.
|
|
105
|
+
|
|
106
|
+
| API | Kind | Shape | Summary |
|
|
107
|
+
| --------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
108
|
+
| `Form` | class | `FormInterface` | Implements `FormInterface` exactly, over an owned schema, the answers given against it, and the errors they carry. |
|
|
109
|
+
| `FormInterface` | interface | `{ emitter, schema, values, baseline, errors, touched, disabled, status, valid, dirty, answer } plus field, fill, touch, invalidate, disable, enable, submit, clear, destroy` | Declares the contract a form exposes: the state it holds and the calls that move it. |
|
|
110
|
+
| `createForm` | function | `(schema: FormSchema, options?: FormOptions) => FormInterface` | Opens a form against a schema. |
|
|
111
|
+
| `FormOptions` | interface | `{ on?, error?, values?, messages? }` | Describes how to open a form. |
|
|
112
|
+
| `FormStatus` | type | `'editing' \| 'settled' \| 'abandoned'` | Represents where a form sits in its life. |
|
|
113
|
+
| `FormResult` | type | `Result<FormValues, readonly FieldError[]>` | Reports what a submit answers with: the values, or every error that stopped them. |
|
|
114
|
+
| `FormEventMap` | type | `{ fill, validate, disable, enable, submit, clear, abandon }` | Lists everything a form announces. |
|
|
115
|
+
| `FormError` | class | `new (code: FormErrorCode, message: string, context?: JSONRecord) => FormError` | Represents an error raised by the form domain. |
|
|
116
|
+
| `FormErrorCode` | type | `'SCHEMA' \| 'FIELD' \| 'CONTROL' \| 'SETTLED' \| 'ABANDONED'` | Names the machine-readable code a form error carries. |
|
|
117
|
+
| `isFormError` | function | `FormError` | Determines whether an unknown value is a form error. |
|
|
118
|
+
|
|
119
|
+
`FormInterface`'s readonly data members are the names in its `Shape` cell before `plus`, and they
|
|
120
|
+
stay here rather than in `## Methods`; the call-signature members after `plus` are documented under
|
|
121
|
+
[Methods](#methods).
|
|
122
|
+
|
|
123
|
+
### Constants
|
|
124
|
+
|
|
125
|
+
The control and status registries, the permitted-member table each control is checked against, the
|
|
126
|
+
default rule copy, and the shipped patterns — every one of them frozen, so a shared `RegExp` cannot
|
|
127
|
+
be recompiled under a consumer. `EMAIL_PATTERN`, `URL_PATTERN`, `ALPHANUMERIC_PATTERN`, and
|
|
128
|
+
`INTEGER_PATTERN` are the tests behind the rules they are named for, `INTEGER_PATTERN` on a text
|
|
129
|
+
control; `COLOR_PATTERN`, `DATE_PATTERN`, `TIME_PATTERN`, and `DATETIME_PATTERN` are the shapes a
|
|
130
|
+
`color`, `date`, `time`, and `datetime` value must have. Each budget's row names its ceiling.
|
|
131
|
+
[Budgets](#budgets) then works each ceiling through beside the unit it counts, and
|
|
132
|
+
[Patterns and where trust lives](#patterns-and-where-trust-lives) does the same for `PATTERN_LIMIT`.
|
|
133
|
+
|
|
134
|
+
A `Shape` cell holds the constant's declared type.
|
|
135
|
+
|
|
136
|
+
| API | Kind | Shape | Summary |
|
|
137
|
+
| ---------------------- | ----- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
138
|
+
| `FIELD_CONTROLS` | const | `readonly FieldControl[]` | Lists every field control, in the order declared by the public contract. |
|
|
139
|
+
| `FIELD_BASE_KEYS` | const | `readonly string[]` | Lists the members every field declares, whatever its control. |
|
|
140
|
+
| `FIELD_KEYS` | const | `Readonly<Record<FieldControl, readonly string[]>>` | Lists every member one field control permits, composed from `FIELD_BASE_KEYS` and the members the control's own interface adds. |
|
|
141
|
+
| `FORM_STATUSES` | const | `readonly FormStatus[]` | Lists every form lifecycle status. |
|
|
142
|
+
| `RULE_MESSAGES` | const | `Readonly<Record<FieldRuleName, string>>` | Holds the default failure copy for every named field rule. |
|
|
143
|
+
| `EMAIL_PATTERN` | const | `Readonly<RegExp>` | Matches a practical whole-address email shape. |
|
|
144
|
+
| `URL_PATTERN` | const | `Readonly<RegExp>` | Matches an absolute HTTP or HTTPS URL shape. |
|
|
145
|
+
| `ALPHANUMERIC_PATTERN` | const | `Readonly<RegExp>` | Matches one or more ASCII letters or digits. |
|
|
146
|
+
| `INTEGER_PATTERN` | const | `Readonly<RegExp>` | Matches a signed or unsigned base-ten integer string. |
|
|
147
|
+
| `COLOR_PATTERN` | const | `Readonly<RegExp>` | Matches a six-digit hexadecimal color string. |
|
|
148
|
+
| `DATE_PATTERN` | const | `Readonly<RegExp>` | Matches an ISO calendar date string in `YYYY-MM-DD` form. |
|
|
149
|
+
| `TIME_PATTERN` | const | `Readonly<RegExp>` | Matches a 24-hour time string with optional seconds. |
|
|
150
|
+
| `DATETIME_PATTERN` | const | `Readonly<RegExp>` | Matches an ISO local date and time string with optional seconds. |
|
|
151
|
+
| `PATTERN_LIMIT` | const | `number` | Caps the accepted source length for an authored regular expression, at 256. |
|
|
152
|
+
| `FIELD_LIMIT` | const | `number` | Caps the number of fields one schema may declare, at 512. |
|
|
153
|
+
| `GROUP_LIMIT` | const | `number` | Caps the number of groups one schema may declare, at 64. |
|
|
154
|
+
| `CHOICE_LIMIT` | const | `number` | Caps the number of choices one `select` or `checkbox` field may offer, at 1024. |
|
|
155
|
+
| `LIST_LIMIT` | const | `number` | Caps the number of entries one list-valued answer may hold, at 1024. |
|
|
156
|
+
| `NAME_LIMIT` | const | `number` | Caps the length, in UTF-16 code units, of a schema, group, or field name, at 128. |
|
|
157
|
+
| `STRING_LIMIT` | const | `number` | Caps the length, in UTF-16 code units, of any single retained string, at 65536. |
|
|
158
|
+
| `TEXT_LIMIT` | const | `number` | Caps the total length, in UTF-16 code units, of every string one schema retains, at 1048576. |
|
|
159
|
+
| `NODE_LIMIT` | const | `number` | Caps the total number of records, arrays, and leaves one schema retains, at 16384. |
|
|
160
|
+
|
|
161
|
+
### Guards
|
|
162
|
+
|
|
163
|
+
Total `is*` guards over unknown input. None throws, none coerces, and each returns `false` for
|
|
164
|
+
anything off-shape — including a hostile prototype, a symbol key, or a cyclic value. `isFieldValue`
|
|
165
|
+
also refuses a number that is not finite, so `NaN` and `Infinity` are not field values, and
|
|
166
|
+
`isFormSchema` reads structure alone: domain soundness is `auditSchema`'s question.
|
|
167
|
+
|
|
168
|
+
In a guard table a `Shape` cell holds the type the guard narrows to.
|
|
169
|
+
|
|
170
|
+
| API | Kind | Shape | Summary |
|
|
171
|
+
| ---------------- | -------- | -------------- | -------------------------------------------------------------------------- |
|
|
172
|
+
| `isFieldControl` | function | `FieldControl` | Determines whether an unknown value is a declared field control. |
|
|
173
|
+
| `isFormStatus` | function | `FormStatus` | Determines whether an unknown value is a form lifecycle status. |
|
|
174
|
+
| `isFieldValue` | function | `FieldValue` | Determines whether an unknown value has a form field value shape. |
|
|
175
|
+
| `isFieldChoice` | function | `FieldChoice` | Determines whether an unknown value is one exact field choice record. |
|
|
176
|
+
| `isFieldRule` | function | `FieldRule` | Determines whether an unknown value is one exact field rule record. |
|
|
177
|
+
| `isFormField` | function | `FormField` | Determines whether an unknown value is one exact discriminated form field. |
|
|
178
|
+
| `isFormGroup` | function | `FormGroup` | Determines whether an unknown value is one exact form group record. |
|
|
179
|
+
| `isFormSchema` | function | `FormSchema` | Determines whether an unknown value is one exact structural form schema. |
|
|
180
|
+
| `isFormValues` | function | `FormValues` | Determines whether an unknown value is a record of field values. |
|
|
181
|
+
| `isFieldError` | function | `FieldError` | Determines whether an unknown value is one exact field error record. |
|
|
182
|
+
|
|
183
|
+
### Helpers
|
|
184
|
+
|
|
185
|
+
The pure leaves the form composes: the prototype-safe record write, `createFieldError`'s rule
|
|
186
|
+
failure builder, the control shape test, the evaluation engine, the derivations, and the wire
|
|
187
|
+
projection.
|
|
188
|
+
|
|
189
|
+
| API | Kind | Summary |
|
|
190
|
+
| ------------------ | -------- | ------------------------------------------------------------------------------- |
|
|
191
|
+
| `defineEntry` | function | Writes one own enumerable data property onto a record. |
|
|
192
|
+
| `freezeEntry` | function | Writes one own enumerable data property that cannot be rewritten or removed. |
|
|
193
|
+
| `matchesField` | function | Checks whether a value has the shape required by one field control. |
|
|
194
|
+
| `matchesAnswer` | function | Decides whether a raw binding value projects to an answered field. |
|
|
195
|
+
| `appliesRule` | function | Checks whether a named rule applies to one field control. |
|
|
196
|
+
| `evaluateField` | function | Evaluates one field rule against its current value. |
|
|
197
|
+
| `evaluateForm` | function | Evaluates every active field in schema order. |
|
|
198
|
+
| `computeDefaults` | function | Computes the values explicitly seeded by a schema. |
|
|
199
|
+
| `matchesValue` | function | Compares two field values by scalar identity or ordered list content. |
|
|
200
|
+
| `extractChanges` | function | Extracts the names whose answers differ between two form value records. |
|
|
201
|
+
| `matchesValues` | function | Compares two form value records by keys and value content. |
|
|
202
|
+
| `formatMessage` | function | Resolves and interpolates one rule message. |
|
|
203
|
+
| `createFieldError` | function | Creates one named-rule failure against a field. |
|
|
204
|
+
| `serializeForm` | function | Projects a schema into JSON while removing custom validators and absent values. |
|
|
205
|
+
| `extractGroups` | function | Selects referenced groups in first-reference field order. |
|
|
206
|
+
| `auditSchema` | function | Audits a structurally valid schema for domain invariants. |
|
|
207
|
+
|
|
208
|
+
### Cloners
|
|
209
|
+
|
|
210
|
+
Owned frozen snapshots. The form takes one of the schema at construction, so a later edit to the
|
|
211
|
+
schema the caller passed changes nothing inside the form, and no list the form hands back is a live
|
|
212
|
+
internal reference.
|
|
213
|
+
|
|
214
|
+
| API | Kind | Summary |
|
|
215
|
+
| ----------------- | -------- | ------------------------------------------------------- |
|
|
216
|
+
| `cloneValue` | function | Clones one form value into an owned frozen snapshot. |
|
|
217
|
+
| `cloneChoices` | function | Clones a field's choices into an owned frozen snapshot. |
|
|
218
|
+
| `cloneFormField` | function | Clones one form field into an owned frozen snapshot. |
|
|
219
|
+
| `cloneFormSchema` | function | Clones a form schema into an owned frozen snapshot. |
|
|
220
|
+
|
|
221
|
+
### Parsers
|
|
222
|
+
|
|
223
|
+
The wire boundary. Each returns `undefined` on refusal rather than throwing, and each returns an
|
|
224
|
+
owned value rather than the caller's.
|
|
225
|
+
|
|
226
|
+
| API | Kind | Summary |
|
|
227
|
+
| ------------- | -------- | ----------------------------------------------------------------------- |
|
|
228
|
+
| `parseForm` | function | Parses unknown wire data into an owned, semantically sound form schema. |
|
|
229
|
+
| `parseValue` | function | Parses one answer against its field control. |
|
|
230
|
+
| `parseValues` | function | Parses a strict answer record against the fields declared by a schema. |
|
|
231
|
+
|
|
232
|
+
## Controls
|
|
233
|
+
|
|
234
|
+
Each control fixes both the options its field accepts and the `FieldValue` it holds. The `confirm`,
|
|
235
|
+
`checkbox`, and `datetime` mappings need saying out loud, because a host's vocabulary is wider than
|
|
236
|
+
this one and the collapses are deliberate:
|
|
237
|
+
|
|
238
|
+
- A lone browser checkbox is a `confirm`. It means yes or no and it holds a boolean.
|
|
239
|
+
- `checkbox` is the multi-choice group — the terminal's checkbox — and it holds the checked values
|
|
240
|
+
as a list. It is never one box.
|
|
241
|
+
- `datetime` is the browser's `datetime-local`: a wall-clock date and time carrying no zone.
|
|
242
|
+
|
|
243
|
+
Email and url are this package's `text` plus a rule, because they differ from text only in what they
|
|
244
|
+
accept: email is `text` with `{ email: true }`, and url is `text` with `{ url: true }`. Tel and
|
|
245
|
+
search are `text` with no rule of their own. A telephone number has no one shape this package could
|
|
246
|
+
assert across dialling plans, and search names an affordance rather than a constraint, so a schema
|
|
247
|
+
that wants a shape for either declares its own `pattern`.
|
|
248
|
+
|
|
249
|
+
A browser range is a `number` with `minimum`, `maximum`, and `step`. A radio group is a `select` and
|
|
250
|
+
a switch is a `confirm` — both are the same question wearing a different affordance, and which
|
|
251
|
+
affordance to draw is the renderer's decision. A datalist is a `select` with `open`, which is
|
|
252
|
+
exactly what "suggest these, accept anything" means.
|
|
253
|
+
|
|
254
|
+
| Control | Value | Notes |
|
|
255
|
+
| ---------- | ------------------- | -------------------------------------------------------------- |
|
|
256
|
+
| `text` | `string` | Carries email and url as rules, and tel and search as neither. |
|
|
257
|
+
| `editor` | `string` | Text over many lines. |
|
|
258
|
+
| `password` | `string` | No `default`: a seeded secret is a secret written down. |
|
|
259
|
+
| `number` | `number` | Also carries a range, as `minimum` plus `maximum` plus `step`. |
|
|
260
|
+
| `date` | `string` | `YYYY-MM-DD`. |
|
|
261
|
+
| `time` | `string` | `HH:MM`, seconds optional. |
|
|
262
|
+
| `datetime` | `string` | The browser's datetime-local, no zone. |
|
|
263
|
+
| `color` | `string` | `#rrggbb`, six digits. |
|
|
264
|
+
| `confirm` | `boolean` | A lone browser checkbox, and a switch. |
|
|
265
|
+
| `select` | `string` | A radio group, and a datalist when `open` is true. |
|
|
266
|
+
| `checkbox` | `readonly string[]` | The multi-choice group. |
|
|
267
|
+
| `file` | `readonly string[]` | Names only. Bytes never enter the document. |
|
|
268
|
+
|
|
269
|
+
### text
|
|
270
|
+
|
|
271
|
+
Declares a `TextField` carrying a placeholder and a required-and-email rule.
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import type { TextField } from '@orkestrel/form'
|
|
275
|
+
|
|
276
|
+
const email: TextField = {
|
|
277
|
+
control: 'text',
|
|
278
|
+
name: 'email',
|
|
279
|
+
label: 'Email',
|
|
280
|
+
placeholder: 'you@example.com',
|
|
281
|
+
rule: { required: true, email: true },
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### editor
|
|
286
|
+
|
|
287
|
+
Declares an `EditorField` bounded by a maximum-length rule.
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
import type { EditorField } from '@orkestrel/form'
|
|
291
|
+
|
|
292
|
+
const bio: EditorField = {
|
|
293
|
+
control: 'editor',
|
|
294
|
+
name: 'bio',
|
|
295
|
+
label: 'About you',
|
|
296
|
+
rule: { maximum: 500 },
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### password
|
|
301
|
+
|
|
302
|
+
`password` carries no `default`, so `computeDefaults` never seeds one. `mask` is the character the
|
|
303
|
+
control repeats in place of the text, and the form stores the real value untouched.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
import type { PasswordField } from '@orkestrel/form'
|
|
307
|
+
|
|
308
|
+
const secret: PasswordField = {
|
|
309
|
+
control: 'password',
|
|
310
|
+
name: 'secret',
|
|
311
|
+
label: 'Password',
|
|
312
|
+
mask: '*',
|
|
313
|
+
rule: { required: true, minimum: 12 },
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### number
|
|
318
|
+
|
|
319
|
+
A browser range is this field with `minimum`, `maximum`, and `step` all set.
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
import type { NumberField } from '@orkestrel/form'
|
|
323
|
+
|
|
324
|
+
const volume: NumberField = {
|
|
325
|
+
control: 'number',
|
|
326
|
+
name: 'volume',
|
|
327
|
+
label: 'Volume',
|
|
328
|
+
default: 5,
|
|
329
|
+
rule: { minimum: 0, maximum: 11, step: 1 },
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### date
|
|
334
|
+
|
|
335
|
+
Declares a `DateField` bounded by a minimum and a maximum calendar date.
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
import type { DateField } from '@orkestrel/form'
|
|
339
|
+
|
|
340
|
+
const start: DateField = {
|
|
341
|
+
control: 'date',
|
|
342
|
+
name: 'start',
|
|
343
|
+
label: 'Start date',
|
|
344
|
+
rule: { minimum: '2026-01-01', maximum: '2026-12-31' },
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### time
|
|
349
|
+
|
|
350
|
+
Declares a `TimeField` with a default and a minimum-and-maximum time-of-day rule.
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
import type { TimeField } from '@orkestrel/form'
|
|
354
|
+
|
|
355
|
+
const opens: TimeField = {
|
|
356
|
+
control: 'time',
|
|
357
|
+
name: 'opens',
|
|
358
|
+
label: 'Opening time',
|
|
359
|
+
default: '09:00',
|
|
360
|
+
rule: { minimum: '06:00', maximum: '22:00' },
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### datetime
|
|
365
|
+
|
|
366
|
+
Declares a `DatetimeField` bounded by a minimum date and time.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import type { DatetimeField } from '@orkestrel/form'
|
|
370
|
+
|
|
371
|
+
const slot: DatetimeField = {
|
|
372
|
+
control: 'datetime',
|
|
373
|
+
name: 'slot',
|
|
374
|
+
label: 'Appointment',
|
|
375
|
+
rule: { minimum: '2026-01-01T09:00' },
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### color
|
|
380
|
+
|
|
381
|
+
Declares a `ColorField` seeded with a default six-digit color.
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
import type { ColorField } from '@orkestrel/form'
|
|
385
|
+
|
|
386
|
+
const brand: ColorField = {
|
|
387
|
+
control: 'color',
|
|
388
|
+
name: 'brand',
|
|
389
|
+
label: 'Brand color',
|
|
390
|
+
default: '#3366ff',
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### confirm
|
|
395
|
+
|
|
396
|
+
Declares a `ConfirmField` a submit refuses to pass until it is required and checked.
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
import type { ConfirmField } from '@orkestrel/form'
|
|
400
|
+
|
|
401
|
+
const terms: ConfirmField = {
|
|
402
|
+
control: 'confirm',
|
|
403
|
+
name: 'terms',
|
|
404
|
+
label: 'I accept the terms',
|
|
405
|
+
rule: { required: true },
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### select
|
|
410
|
+
|
|
411
|
+
`open` admits a value the list does not offer, which is what turns a closed menu into a suggestion
|
|
412
|
+
list. A choice marked `disabled` is shown and refused at every door, including seeded values.
|
|
413
|
+
Filter stored answers through `parseValues` or `parseValue` before seeding them; an `undefined`
|
|
414
|
+
result means the value is no longer legal. A closed, all-disabled select is unanswerable and faults
|
|
415
|
+
when required, whether or not the field itself is declared disabled; an open select and an optional
|
|
416
|
+
select are both legal.
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
import type { SelectField } from '@orkestrel/form'
|
|
420
|
+
|
|
421
|
+
const plan: SelectField = {
|
|
422
|
+
control: 'select',
|
|
423
|
+
name: 'plan',
|
|
424
|
+
label: 'Plan',
|
|
425
|
+
choices: [
|
|
426
|
+
{ value: 'free', label: 'Free' },
|
|
427
|
+
{ value: 'pro', label: 'Pro', help: 'Everything in Free, plus support' },
|
|
428
|
+
{ value: 'legacy', label: 'Legacy', disabled: true },
|
|
429
|
+
],
|
|
430
|
+
default: 'free',
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### checkbox
|
|
435
|
+
|
|
436
|
+
A checkbox value is the checked values as a list. Duplicates are refused, and `minimum` and
|
|
437
|
+
`maximum` count selections rather than characters. `required` is satisfied by any present answer,
|
|
438
|
+
including the empty list; an empty submission is a valid "none selected".
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
import type { CheckboxField } from '@orkestrel/form'
|
|
442
|
+
|
|
443
|
+
const topics: CheckboxField = {
|
|
444
|
+
control: 'checkbox',
|
|
445
|
+
name: 'topics',
|
|
446
|
+
label: 'Interests',
|
|
447
|
+
choices: [
|
|
448
|
+
{ value: 'releases', label: 'Releases' },
|
|
449
|
+
{ value: 'security', label: 'Security' },
|
|
450
|
+
],
|
|
451
|
+
default: ['releases'],
|
|
452
|
+
rule: { minimum: 1 },
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### file
|
|
457
|
+
|
|
458
|
+
A file value is a list of names. `multiple` admits more than one, and without it a second name is
|
|
459
|
+
refused.
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
import type { FileField } from '@orkestrel/form'
|
|
463
|
+
|
|
464
|
+
const documents: FileField = {
|
|
465
|
+
control: 'file',
|
|
466
|
+
name: 'documents',
|
|
467
|
+
label: 'Supporting documents',
|
|
468
|
+
accept: ['application/pdf', '.png'],
|
|
469
|
+
multiple: true,
|
|
470
|
+
rule: { maximum: 3 },
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### meta
|
|
475
|
+
|
|
476
|
+
`meta` is not a control. It is the bounded JSON carrier every field has, on `FieldBase`, for
|
|
477
|
+
whatever the schema declines to model — an icon, a column width, an analytics key, a renderer hint.
|
|
478
|
+
The properties that follow define it, and together they are why it can be there at all.
|
|
479
|
+
|
|
480
|
+
**Evaluation never reads it.** No rule sees it, no error can come from it, and `evaluateForm` gives
|
|
481
|
+
the same answers whether it is present or absent. It is inert by construction, not by convention.
|
|
482
|
+
|
|
483
|
+
**It round-trips verbatim.** `serializeForm` writes it out and `parseForm` reads it back, key for
|
|
484
|
+
key and value for value, because it is already JSON. Nothing in this package rewrites, prunes, or
|
|
485
|
+
namespaces what a host put there.
|
|
486
|
+
|
|
487
|
+
**It is bounded JSON.** `isFormField` admits it only through `isBoundedJSONRecord`, so a cyclic
|
|
488
|
+
value, a value nested past that guard's depth bound, and anything that is not JSON — a function, a
|
|
489
|
+
symbol — each refuse the whole field. Depth is the guard's job; size is the audit's, and the
|
|
490
|
+
following budgets count `meta`'s strings and nodes. They count it a little more strictly than the
|
|
491
|
+
schema's own: a key inside `meta` counts against the text budget, and the schema's own keys —
|
|
492
|
+
`control`, `name`, `rule` — do not. The stricter side is the one the host controls, which is the
|
|
493
|
+
right way round.
|
|
494
|
+
|
|
495
|
+
The guard reads structure alone, so it admits one record that ownership then refuses: a `meta` whose
|
|
496
|
+
keys are accessors rather than data. That record is bounded JSON by shape, and `cloneFormField`
|
|
497
|
+
copies enumerable data properties only, so taking ownership of it throws `FormError` coded `SCHEMA`
|
|
498
|
+
naming the field. The constructor reaches that refusal through the same clone, which is why a field
|
|
499
|
+
`isFormField` accepted can still be refused when a form opens against it. `serializeForm` refuses
|
|
500
|
+
the same record the same way — `SCHEMA` naming the field — and `parseForm` answers it as every
|
|
501
|
+
refusal: `undefined`.
|
|
502
|
+
|
|
503
|
+
**This package defines no key in it.** Every key belongs to the host, so two hosts can carry
|
|
504
|
+
different vocabularies through the same document and neither collides with the package. A key this
|
|
505
|
+
package started reading would stop being the host's.
|
|
506
|
+
|
|
507
|
+
It is declared on fields only. The first consumer asked for a field carrier, and an exact guard
|
|
508
|
+
refuses `meta` on `FormSchema`, `FormGroup`, and `FieldChoice` rather than admitting a member
|
|
509
|
+
nothing reads. The form owns what it stores, so `form.field(name)` hands back a frozen
|
|
510
|
+
null-prototype copy rather than the caller's object.
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
import {
|
|
514
|
+
createForm,
|
|
515
|
+
evaluateForm,
|
|
516
|
+
isFieldChoice,
|
|
517
|
+
isFormGroup,
|
|
518
|
+
parseForm,
|
|
519
|
+
serializeForm,
|
|
520
|
+
} from '@orkestrel/form'
|
|
521
|
+
import type { FormSchema } from '@orkestrel/form'
|
|
522
|
+
|
|
523
|
+
const schema: FormSchema = {
|
|
524
|
+
fields: [{ control: 'text', name: 'email', meta: { icon: 'mail', order: 2 } }],
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
evaluateForm(schema, {}) // [] — meta is never evaluated
|
|
528
|
+
|
|
529
|
+
const wire = JSON.stringify(serializeForm(schema))
|
|
530
|
+
wire // '{"fields":[{"control":"text","name":"email","meta":{"icon":"mail","order":2}}]}'
|
|
531
|
+
JSON.stringify(parseForm(JSON.parse(wire))) === wire // true — verbatim, both directions
|
|
532
|
+
|
|
533
|
+
isFormGroup({ name: 'account', label: 'Account', meta: {} }) // false — groups carry no meta
|
|
534
|
+
isFieldChoice({ value: 'a', label: 'A', meta: {} }) // false — nor do choices
|
|
535
|
+
|
|
536
|
+
const form = createForm(schema)
|
|
537
|
+
form.field('email')?.meta // { icon: 'mail', order: 2 } — an owned frozen copy
|
|
538
|
+
Object.getPrototypeOf(form.field('email')?.meta ?? {}) // null
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### Rendering
|
|
542
|
+
|
|
543
|
+
A renderer picks an affordance from what a field asks a person for, not from the name its control
|
|
544
|
+
carries. The categories that decision is made in are the portable ones the `enterprise-bootstrap`
|
|
545
|
+
skill catalogs in its `references/inputs.md` file, which every scaffold target carries at
|
|
546
|
+
`.agents/skills/enterprise-bootstrap/references/inputs.md`. The category names in the
|
|
547
|
+
table are that catalog's own, and a category name is the join key: read the row here, then open the
|
|
548
|
+
section carrying that name there.
|
|
549
|
+
|
|
550
|
+
Every field owes the same things whatever its category: its `label`, its `help`, a message for each
|
|
551
|
+
`FieldError` it carries keyed by that error's `rule`, and the display policy over `touched` that
|
|
552
|
+
holds a message back until somebody has visited the field. `hidden`, `locked`, and `disabled`
|
|
553
|
+
change what is drawn in every category alike; see [The visibility switches](#the-visibility-switches)
|
|
554
|
+
for the obligation each one carries. The table states what a control owes on top of those.
|
|
555
|
+
|
|
556
|
+
| Control | Category | What moves it | What the renderer owes |
|
|
557
|
+
| ---------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
558
|
+
| `text` | One line of text | Nothing. `email`, `url`, and `pattern` narrow what the line accepts and leave the category where it is. | One line whatever the rule says: email, url, tel, and search are all this control. |
|
|
559
|
+
| `editor` | Text over many lines | Nothing. | The height of the box. The document states no row count. |
|
|
560
|
+
| `password` | A secret | Nothing. | Obscure the text as it is typed, repeating `mask` where the schema names one, and seed nothing: the control carries no `default`. |
|
|
561
|
+
| `number` | A number | `minimum`, `maximum`, and `step` all set — that is a number in a bounded range. | The bounded affordance only when `minimum`, `maximum`, and `step` are all set, and a plain number otherwise. |
|
|
562
|
+
| `date` | A date | Nothing. | The value is the control's own `YYYY-MM-DD` string. Reading a person's localized entry back into it is the binding's parse. |
|
|
563
|
+
| `time` | A time | Nothing. | The value is the control's own `HH:MM` string, seconds optional. |
|
|
564
|
+
| `datetime` | A date and time | Nothing. | The value carries no zone, so a renderer that shows one has invented it. |
|
|
565
|
+
| `color` | A color | Nothing. | The value is `#rrggbb`, six digits, whatever picker produced it. |
|
|
566
|
+
| `confirm` | One on/off answer | Nothing. | One box or a switch, and which of them to draw is the renderer's decision. |
|
|
567
|
+
| `select` | One of a few | `open` — that is one of many with an unlisted value admitted. A list longer than the renderer draws at once — that is one of many. | Show a choice marked `disabled` and refuse it, seeded values included. |
|
|
568
|
+
| `checkbox` | Any of a few | A list longer than the renderer draws at once — that is any of many. | Refuse a duplicate, and count selections rather than characters where `minimum` or `maximum` is set. |
|
|
569
|
+
| `file` | Files | Nothing. `multiple` changes how many names the value holds, not the category. | Names only, filtered by `accept` and bounded by `multiple`. Bytes never enter the document. |
|
|
570
|
+
|
|
571
|
+
The catalog's remaining categories have no control of their own here. A renderer draws each over a
|
|
572
|
+
control the document already carries, or leaves it outside the document, so a renderer never
|
|
573
|
+
invents a control:
|
|
574
|
+
|
|
575
|
+
- **A value picked from a searched list** — `select`, and `select` with `open` where the search must
|
|
576
|
+
admit a value the list does not offer. `choices` is data in the schema, so the search field, the
|
|
577
|
+
ranking, and the menu belong to the renderer, and a list fetched while somebody types is the host
|
|
578
|
+
building a new schema.
|
|
579
|
+
- **An ordered set of tags** — `checkbox` over a fixed vocabulary, whose value keeps the order it
|
|
580
|
+
was filled in. Tags a person types freely are outside the document: `checkbox` admits a listed
|
|
581
|
+
value only, and no control holds a list of unlisted ones.
|
|
582
|
+
- **A rating** — `select` over the fixed values, which is the radio group the catalog draws, or
|
|
583
|
+
`number` with `minimum`, `maximum`, and `step` where the scale is continuous.
|
|
584
|
+
- **A step in a sequence** — outside the document. A step indicator reports position rather than
|
|
585
|
+
holding an answer, and sequencing several forms is the host's work.
|
|
586
|
+
|
|
587
|
+
## Rules
|
|
588
|
+
|
|
589
|
+
A rule is data, not a closure. That is what lets a schema cross a wire and validate on the other
|
|
590
|
+
side exactly as it validated here — with the single exception of `custom`, which is a function and
|
|
591
|
+
therefore does not travel.
|
|
592
|
+
|
|
593
|
+
| Rule | Operand | What it measures |
|
|
594
|
+
| -------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
595
|
+
| `required` | `true` | That an answer exists at all. Presence only: `''`, `[]`, `false`, and `0` are answers and satisfy it. |
|
|
596
|
+
| `minimum` | number or string | Characters for text, editor, and password; magnitude for number; chronology for `date`, `time`, and `datetime`; selections for checkbox and file. |
|
|
597
|
+
| `maximum` | number or string | The same measure as `minimum`, at the other end. |
|
|
598
|
+
| `step` | number | The interval a numeric value must land on, counted from `minimum` or from zero. Number only. |
|
|
599
|
+
| `pattern` | string | Regular-expression source the whole value must match. String-valued controls only. |
|
|
600
|
+
| `email` | `true` | That the whole value is an email address, per `EMAIL_PATTERN`. |
|
|
601
|
+
| `url` | `true` | That the whole value is an absolute HTTP or HTTPS URL, per `URL_PATTERN`. |
|
|
602
|
+
| `integer` | `true` | That a number has no fractional part, or that a string is a base-ten integer. |
|
|
603
|
+
| `alphanumeric` | `true` | That the whole value is ASCII letters and digits, per `ALPHANUMERIC_PATTERN`. |
|
|
604
|
+
| `custom` | `FieldValidator` | Anything the rest cannot say. It runs last, on an absent value too, and it is the only rule that sees the rest of the form. |
|
|
605
|
+
|
|
606
|
+
The operand's type follows the control family. `minimum` and `maximum` take a number wherever the
|
|
607
|
+
measure is a count or a magnitude, and take a string written in the control's own format wherever
|
|
608
|
+
the measure is chronology — `'2026-01-01'` for a date, `'09:00'` for a time. `auditSchema` refuses
|
|
609
|
+
the mismatch rather than letting it fail silently at evaluation time.
|
|
610
|
+
|
|
611
|
+
Bounds compare temporal strings lexically. Spell each operand and value at the same precision:
|
|
612
|
+
because seconds are optional, `'09:00'` sorts before `'09:00:00'`.
|
|
613
|
+
|
|
614
|
+
`step` is number-only. A temporal step is not in this package; see the concept inventory.
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
import { evaluateField, formatMessage } from '@orkestrel/form'
|
|
618
|
+
import type { NumberField } from '@orkestrel/form'
|
|
619
|
+
|
|
620
|
+
const volume: NumberField = {
|
|
621
|
+
control: 'number',
|
|
622
|
+
name: 'volume',
|
|
623
|
+
rule: { minimum: 0, maximum: 11, step: 1 },
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
evaluateField(volume, 12, {})
|
|
627
|
+
// [{ field: 'volume', message: 'Must be at most 11', rule: 'maximum' }]
|
|
628
|
+
evaluateField(volume, 0.5, {})
|
|
629
|
+
// [{ field: 'volume', message: 'Must be a multiple of 1', rule: 'step' }]
|
|
630
|
+
|
|
631
|
+
formatMessage('minimum', 8) // 'Must be at least 8'
|
|
632
|
+
formatMessage('required', undefined, { required: 'We need this one' }) // 'We need this one'
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
### How an answer is counted
|
|
636
|
+
|
|
637
|
+
**`undefined` is the only absence.** A field is unanswered when `values` has no key for it, and
|
|
638
|
+
answered otherwise. `''`, a string of spaces, `[]`, `false`, and `0` are all answers, so `required`
|
|
639
|
+
is satisfied by every one of them. `required` asks whether an answer exists, never whether it says
|
|
640
|
+
anything: a length rule is `minimum`, and a "must be ticked" rule is `custom`.
|
|
641
|
+
|
|
642
|
+
**This is not HTML's model, and the difference is deliberate.** A browser fails `required` on the
|
|
643
|
+
exact empty string, so an empty text input is unanswered there and answered here. Neither model is
|
|
644
|
+
wrong — HTML has one input surface and this document has many — but a binding that wants HTML's
|
|
645
|
+
answer has to say so, and `matchesAnswer` is where it says it.
|
|
646
|
+
|
|
647
|
+
**Project at the binding, not in the rules.** `matchesAnswer` is the documented projection: it is
|
|
648
|
+
false for absence and for a string that is only whitespace, and true for every other value including
|
|
649
|
+
`[]`, `false`, and `0`. Fill through it, and the empty box arrives as absence:
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
import { createForm, matchesAnswer } from '@orkestrel/form'
|
|
653
|
+
|
|
654
|
+
matchesAnswer(undefined) // false
|
|
655
|
+
matchesAnswer('') // false
|
|
656
|
+
matchesAnswer(' ') // false — whitespace alone is not an answer
|
|
657
|
+
matchesAnswer('ada') // true
|
|
658
|
+
matchesAnswer([]) // true — an empty list is an answered "none of them"
|
|
659
|
+
matchesAnswer(false) // true
|
|
660
|
+
matchesAnswer(0) // true
|
|
661
|
+
|
|
662
|
+
const form = createForm({
|
|
663
|
+
fields: [{ control: 'text', name: 'email', rule: { required: true } }],
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
// The binding's own line, once, wherever a raw control value arrives.
|
|
667
|
+
const raw = ' '
|
|
668
|
+
form.fill('email', matchesAnswer(raw) ? raw : undefined)
|
|
669
|
+
|
|
670
|
+
form.values.email // undefined — the projection cleared it
|
|
671
|
+
form.errors.length // 1 — and `required` then reports it
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
Core evaluation does not use this projection. Keeping it at the binding is what lets one schema
|
|
675
|
+
serve a browser input that treats blank as empty and a terminal prompt that treats a bare return as
|
|
676
|
+
skipped, without the schema knowing which host it is on.
|
|
677
|
+
|
|
678
|
+
### The custom seam
|
|
679
|
+
|
|
680
|
+
`custom` receives two arguments: the value the field holds — or `undefined` when nobody has answered
|
|
681
|
+
it — and every answer the form holds. The second is what makes a cross-field rule possible without a
|
|
682
|
+
second mechanism, because a confirmation field
|
|
683
|
+
reads its sibling directly. It returns `true` to pass, or the message explaining the failure, and
|
|
684
|
+
that message travels as a `FieldError` with no `rule`, because the failure belongs to no named rule.
|
|
685
|
+
A validator's own throw escapes the mutation call unchanged; only form-owned refusals are
|
|
686
|
+
`FormError`.
|
|
687
|
+
|
|
688
|
+
```ts
|
|
689
|
+
import { evaluateField } from '@orkestrel/form'
|
|
690
|
+
import type { FieldValidator, PasswordField } from '@orkestrel/form'
|
|
691
|
+
|
|
692
|
+
const matches: FieldValidator = (value, values) =>
|
|
693
|
+
value === values.password ? true : 'Both passwords must match'
|
|
694
|
+
|
|
695
|
+
const again: PasswordField = { control: 'password', name: 'again', rule: { custom: matches } }
|
|
696
|
+
|
|
697
|
+
evaluateField(again, 'hunter3', { password: 'hunter2' })
|
|
698
|
+
// [{ field: 'again', message: 'Both passwords must match' }]
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
**`custom` runs on an absent value too**, after every named rule, which is what makes "required once
|
|
702
|
+
the sibling says yes" expressible without a second mechanism. An unanswered field can therefore
|
|
703
|
+
carry a `required` message and this validator's own together, and each one is a separate
|
|
704
|
+
`FieldError`.
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
import { evaluateField } from '@orkestrel/form'
|
|
708
|
+
import type { FieldValidator, TextField } from '@orkestrel/form'
|
|
709
|
+
|
|
710
|
+
const whenBusiness: FieldValidator = (value, values) =>
|
|
711
|
+
values.account === 'business' && value === undefined ? 'A VAT number is required' : true
|
|
712
|
+
|
|
713
|
+
const vat: TextField = { control: 'text', name: 'vat', rule: { custom: whenBusiness } }
|
|
714
|
+
|
|
715
|
+
evaluateField(vat, undefined, { account: 'business' })
|
|
716
|
+
// [{ field: 'vat', message: 'A VAT number is required' }]
|
|
717
|
+
evaluateField(vat, undefined, { account: 'personal' }) // []
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
The same seam closes a list of addresses. `TextField` has no `multiple`, because that word already
|
|
721
|
+
means a list of file names on `FileField` and one word cannot hold two value shapes. A field that
|
|
722
|
+
takes several addresses is `text` plus a `custom` that splits the value and tests each part with the
|
|
723
|
+
exported `EMAIL_PATTERN` — the same pattern the `email` rule uses, so the two agree by construction.
|
|
724
|
+
|
|
725
|
+
```ts
|
|
726
|
+
import { evaluateField, EMAIL_PATTERN } from '@orkestrel/form'
|
|
727
|
+
import type { FieldValidator, TextField } from '@orkestrel/form'
|
|
728
|
+
|
|
729
|
+
const addresses: FieldValidator = (value) =>
|
|
730
|
+
typeof value !== 'string' ||
|
|
731
|
+
value
|
|
732
|
+
.split(',')
|
|
733
|
+
.map((entry) => entry.trim())
|
|
734
|
+
.every((entry) => EMAIL_PATTERN.test(entry))
|
|
735
|
+
? true
|
|
736
|
+
: 'Every address must be valid'
|
|
737
|
+
|
|
738
|
+
const to: TextField = { control: 'text', name: 'to', rule: { custom: addresses } }
|
|
739
|
+
|
|
740
|
+
evaluateField(to, 'ada@example.com, grace@example.com', {}) // []
|
|
741
|
+
evaluateField(to, 'ada@example.com, nope', {})
|
|
742
|
+
// [{ field: 'to', message: 'Every address must be valid' }]
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
### Messages
|
|
746
|
+
|
|
747
|
+
`FormOptions.messages` replaces a rule's default copy, keyed by `FieldRuleName`. `{limit}` in the
|
|
748
|
+
replacement is substituted with the rule's operand exactly as it is in `RULE_MESSAGES`. `custom` is
|
|
749
|
+
absent from `FieldRuleName` because a custom rule supplies its own message and nothing keyed by a
|
|
750
|
+
rule name would ever be read for it.
|
|
751
|
+
|
|
752
|
+
### Patterns and where trust lives
|
|
753
|
+
|
|
754
|
+
`pattern` is authored regular-expression source, so it is the one rule that can carry an attack.
|
|
755
|
+
`PATTERN_LIMIT` bounds the source this package will compile, and the wire boundary keeps a schema
|
|
756
|
+
data only. Each is deliberate.
|
|
757
|
+
|
|
758
|
+
`PATTERN_LIMIT` is 256 characters. A longer source is never compiled: `auditSchema` reports it, so
|
|
759
|
+
`createForm` and `parseForm` both refuse the schema, and `evaluateField` fails the field on the
|
|
760
|
+
`pattern` rule rather than handing the source to `RegExp`.
|
|
761
|
+
|
|
762
|
+
A pattern within `PATTERN_LIMIT` can still backtrack catastrophically. This package applies no time
|
|
763
|
+
bound. Evaluating an untrusted pattern spends the caller's thread. The wire boundary remains data
|
|
764
|
+
only: `serializeForm` drops every `custom` validator on the way out, and `parseForm` drops every
|
|
765
|
+
`custom` member on the way in. Parse a peer's schema through `parseForm`, which refuses an over-long
|
|
766
|
+
or uncompilable pattern, and decide whether its remaining patterns are trusted before evaluation.
|
|
767
|
+
|
|
768
|
+
```ts
|
|
769
|
+
import { auditSchema, evaluateField, PATTERN_LIMIT } from '@orkestrel/form'
|
|
770
|
+
import type { TextField } from '@orkestrel/form'
|
|
771
|
+
|
|
772
|
+
const long: TextField = {
|
|
773
|
+
control: 'text',
|
|
774
|
+
name: 'code',
|
|
775
|
+
rule: { pattern: 'a'.repeat(PATTERN_LIMIT + 1) },
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
auditSchema({ fields: [long] })
|
|
779
|
+
// ['Field "code" has a pattern longer than 256']
|
|
780
|
+
evaluateField(long, 'aaa', {})
|
|
781
|
+
// [{ field: 'code', message: 'Must match the required format', rule: 'pattern' }]
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
### Budgets
|
|
785
|
+
|
|
786
|
+
`PATTERN_LIMIT` is one of the budgets. The others bound how much a schema and its answers can be, so
|
|
787
|
+
a document that arrives from a wire cannot cost unbounded memory or unbounded scanning before
|
|
788
|
+
anything decides to trust it. Every one is exported, so a host can check against the same number the
|
|
789
|
+
package checks against.
|
|
790
|
+
|
|
791
|
+
| Constant | Value | Unit | Bounds |
|
|
792
|
+
| -------------- | ------- | ----------------------- | ----------------------------------------- |
|
|
793
|
+
| `FIELD_LIMIT` | 512 | fields | One schema's `fields` |
|
|
794
|
+
| `GROUP_LIMIT` | 64 | groups | One schema's `groups` |
|
|
795
|
+
| `CHOICE_LIMIT` | 1024 | choices | One `select` or `checkbox` field's list |
|
|
796
|
+
| `NAME_LIMIT` | 128 | UTF-16 code units | Each schema, group, and field name |
|
|
797
|
+
| `STRING_LIMIT` | 65536 | UTF-16 code units | Any one retained string |
|
|
798
|
+
| `TEXT_LIMIT` | 1048576 | UTF-16 code units | Every string one schema retains, together |
|
|
799
|
+
| `NODE_LIMIT` | 16384 | records, arrays, leaves | Everything one schema retains, together |
|
|
800
|
+
| `LIST_LIMIT` | 1024 | entries | One list-valued answer |
|
|
801
|
+
|
|
802
|
+
They bind at the schema door and the value door, and which door a limit sits at is the whole story.
|
|
803
|
+
|
|
804
|
+
**The schema door reports.** `auditSchema` counts fields, groups, choices, names, strings, total
|
|
805
|
+
text, and total nodes — `meta` included, since it is retained like everything else — and returns one
|
|
806
|
+
human diagnostic per breach, beside `PATTERN_LIMIT`'s. `createForm` throws `SCHEMA` carrying them
|
|
807
|
+
and `parseForm` refuses the schema, so no over-budget schema is ever held.
|
|
808
|
+
|
|
809
|
+
**The value door refuses.** `matchesField` checks `STRING_LIMIT` on any string and `LIST_LIMIT` on
|
|
810
|
+
any list before it consults the control, so the check happens **before any regular expression sees
|
|
811
|
+
the value**. `fill` and a seeded value throw `CONTROL`; `parseValue` and `parseValues` return
|
|
812
|
+
`undefined`.
|
|
813
|
+
|
|
814
|
+
`STRING_LIMIT` is the one that stands at both: the same ceiling holds a schema's own strings and an
|
|
815
|
+
answer's, so no string this package retains is longer than 65536 code units whichever way it
|
|
816
|
+
arrived.
|
|
817
|
+
|
|
818
|
+
The whole-schema ceilings are what make the arithmetic safe. Whatever the per-item limits admit, one
|
|
819
|
+
audited schema retains at most 1048576 string code units and at most 16384 nodes — roughly two
|
|
820
|
+
megabytes of text — so the worst case is `TEXT_LIMIT` and `NODE_LIMIT`, never the product of the
|
|
821
|
+
others.
|
|
822
|
+
|
|
823
|
+
Regular-expression time, a `custom` validator's own work, and the structural read at the parse door
|
|
824
|
+
stay unbounded, each for its own reason. Regular-expression **time** is not bounded here, exactly as
|
|
825
|
+
the "Guards are total and parsers refuse" invariant under [Contract](#contract) states: a source
|
|
826
|
+
within `PATTERN_LIMIT` can still backtrack catastrophically, and evaluating an untrusted pattern
|
|
827
|
+
spends the caller's thread. And `custom` is in-process code the schema's own author wrote, so it is
|
|
828
|
+
trusted like any other function the host calls; it does not cross the wire, and nothing here limits
|
|
829
|
+
what it does.
|
|
830
|
+
|
|
831
|
+
The structural **read** at the parse door is unbounded for a different reason, and refusing an
|
|
832
|
+
over-budget schema is where it shows. The budgets bound what a schema may **retain**, and they bound
|
|
833
|
+
how far the audit **walks** to name a fault: the field pass stops at `FIELD_LIMIT` and the node pass
|
|
834
|
+
stops at `NODE_LIMIT`, so a fault beyond either ceiling goes unnamed while the breached ceiling
|
|
835
|
+
itself is reported. They do not bound the read that happens before any of that. `parseForm` copies
|
|
836
|
+
and guards every field that arrived before the audit sees one of them, so a payload four times over
|
|
837
|
+
`FIELD_LIMIT` is read four times over and then refused. Bound the size of a payload at the transport
|
|
838
|
+
that delivers it, which is the only layer holding the bytes.
|
|
839
|
+
|
|
840
|
+
```ts
|
|
841
|
+
import { auditSchema, matchesField, LIST_LIMIT, STRING_LIMIT } from '@orkestrel/form'
|
|
842
|
+
import type { CheckboxField } from '@orkestrel/form'
|
|
843
|
+
|
|
844
|
+
auditSchema({ fields: [{ control: 'text', name: 'n'.repeat(129) }] })
|
|
845
|
+
// ['Schema contains a name longer than 128']
|
|
846
|
+
auditSchema({ fields: [{ control: 'text', name: 'a', label: 'x'.repeat(STRING_LIMIT + 1) }] })
|
|
847
|
+
// ['Schema contains a string longer than 65536']
|
|
848
|
+
|
|
849
|
+
const topics: CheckboxField = {
|
|
850
|
+
control: 'checkbox',
|
|
851
|
+
name: 't',
|
|
852
|
+
choices: [{ value: 'a', label: 'A' }],
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
matchesField(
|
|
856
|
+
topics,
|
|
857
|
+
Array.from({ length: LIST_LIMIT + 1 }, () => 'a'),
|
|
858
|
+
) // false — refused by count
|
|
859
|
+
matchesField({ control: 'text', name: 'a' }, 'x'.repeat(STRING_LIMIT + 1)) // false — before any regex
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
### Auditing a schema
|
|
863
|
+
|
|
864
|
+
`auditSchema` is the semantic pass that structural validation cannot do: duplicate names, a missing
|
|
865
|
+
group, a default its own control cannot hold, a rule on a control that cannot measure it, a minimum
|
|
866
|
+
above its maximum, an uncompilable pattern, or a breach of any named budget. It also reports the
|
|
867
|
+
bounds no answer could satisfy: a required closed `select` with no enabled choice, a `checkbox` whose
|
|
868
|
+
positive `minimum` exceeds its enabled-choice count, and a negative `maximum` on a control that
|
|
869
|
+
measures a length or a count — `text`, `editor`, `password`, `checkbox`, or `file`. A required
|
|
870
|
+
`checkbox` alone remains satisfiable because `[]` is a present answer, `maximum: 0` on a `text` is
|
|
871
|
+
satisfiable by `''`, and a negative `maximum` on a `number` is an ordinary value bound.
|
|
872
|
+
|
|
873
|
+
**Every one of those faults holds for every field, disabled or not.** `auditSchema` takes the schema
|
|
874
|
+
and nothing else: no satisfiability arm reads `FieldBase.disabled`, and no runtime `disable` or
|
|
875
|
+
`enable` can change a diagnostic. That is why a declared-disabled field earns no exemption — it can
|
|
876
|
+
be put back into play at any moment, so a field that would be unanswerable the instant it is enabled
|
|
877
|
+
is a fault the audit names while a schema editor can still fix it.
|
|
878
|
+
|
|
879
|
+
**What a passing audit proves is that list and nothing wider.** Each check is a fixed question about
|
|
880
|
+
the schema's own declarations, so a passing schema is free of those faults under every runtime
|
|
881
|
+
disabled set — none of them depends on one. It is not a proof that some answer set exists. `custom`
|
|
882
|
+
is a function, the audit never calls it, and a validator that refuses every value passes the audit
|
|
883
|
+
and fails at evaluation.
|
|
884
|
+
|
|
885
|
+
The audit runs inside `createForm` and inside `parseForm`, so a consumer rarely calls it directly —
|
|
886
|
+
but it is exported, because a schema editor wants the diagnostics before it constructs anything.
|
|
887
|
+
|
|
888
|
+
**Its returned strings are human diagnostics, not a stable machine contract.** Read them, show them,
|
|
889
|
+
log them. Do not branch on their text or parse a field name out of them: the wording is free to
|
|
890
|
+
change with the diagnostics, and only the emptiness of the list is a promise. Where a machine
|
|
891
|
+
outcome is what you need, use the guards, or use `parseForm` and read `undefined`.
|
|
892
|
+
|
|
893
|
+
```ts
|
|
894
|
+
import { auditSchema } from '@orkestrel/form'
|
|
895
|
+
|
|
896
|
+
auditSchema({
|
|
897
|
+
fields: [
|
|
898
|
+
{ control: 'text', name: 'a' },
|
|
899
|
+
{ control: 'text', name: 'a' },
|
|
900
|
+
],
|
|
901
|
+
})
|
|
902
|
+
// ['Field "a" is declared more than once']
|
|
903
|
+
auditSchema({ fields: [{ control: 'number', name: 'n', rule: { minimum: '3' } }] })
|
|
904
|
+
// ['Field "n" has a string minimum on number']
|
|
905
|
+
auditSchema({
|
|
906
|
+
fields: [
|
|
907
|
+
{
|
|
908
|
+
control: 'select',
|
|
909
|
+
name: 'plan',
|
|
910
|
+
disabled: true,
|
|
911
|
+
choices: [{ value: 'legacy', label: 'Legacy', disabled: true }],
|
|
912
|
+
rule: { required: true },
|
|
913
|
+
},
|
|
914
|
+
],
|
|
915
|
+
})
|
|
916
|
+
// ['Field "plan" is required but offers no enabled choice'] — a declared-disabled field is no exemption
|
|
917
|
+
auditSchema({ fields: [{ control: 'text', name: 'code', rule: { maximum: -1 } }] })
|
|
918
|
+
// ['Field "code" has a negative maximum on text'] — no answer has a negative length
|
|
919
|
+
auditSchema({ fields: [{ control: 'text', name: 'email' }] }) // []
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
### The temporal patterns are lexical
|
|
923
|
+
|
|
924
|
+
`DATE_PATTERN`, `TIME_PATTERN`, and `DATETIME_PATTERN` check spelling, not calendars. They accept a
|
|
925
|
+
four-digit year, a month in 01–12, and a day in 01–31 — with no knowledge of month length and no
|
|
926
|
+
knowledge of leap years. `'2026-02-31'` is therefore a lexically valid `date` value, and this
|
|
927
|
+
package accepts it.
|
|
928
|
+
|
|
929
|
+
That is the boundary this package draws, and it draws it on purpose: a calendar is a host concern,
|
|
930
|
+
and the host that renders a date control already refuses an impossible day. Where a real calendar
|
|
931
|
+
date matters to your domain, add the check as a `custom` rule, which is exactly the seam it belongs
|
|
932
|
+
in.
|
|
933
|
+
|
|
934
|
+
```ts
|
|
935
|
+
import { matchesField } from '@orkestrel/form'
|
|
936
|
+
import type { DateField } from '@orkestrel/form'
|
|
937
|
+
|
|
938
|
+
const when: DateField = { control: 'date', name: 'when' }
|
|
939
|
+
|
|
940
|
+
matchesField(when, '2026-02-31') // true — lexically valid, no calendar is consulted
|
|
941
|
+
matchesField(when, '2026-13-01') // false — month 13 is not spelled correctly
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
## Lifecycle and state
|
|
945
|
+
|
|
946
|
+
A form opens `editing`, turns `settled` on its first valid submit, and turns `abandoned` when it is
|
|
947
|
+
destroyed before settling. Both end states are terminal, and every write to a form in either one is
|
|
948
|
+
refused with a `FormError`. Every getter keeps answering afterwards.
|
|
949
|
+
|
|
950
|
+
A destroy requested while a mutation batch is open records the request, refuses every subsequent
|
|
951
|
+
write from that instant, and defers teardown until the outermost batch closes. The batch's own
|
|
952
|
+
outcome wins. If it settles the form, the form ends `settled`, `answer` resolves, and no `abandon`
|
|
953
|
+
is emitted. Teardown never advances into the batch, and the batch is never aborted or rolled back.
|
|
954
|
+
The pending request is private, unnamed state, so `FormStatus` gains no fourth member.
|
|
955
|
+
|
|
956
|
+
**There is no `check()`.** `errors` is computed at construction and after every mutation whose
|
|
957
|
+
evaluation completes, and the `validate` event fires exactly when that list's content changes. If a
|
|
958
|
+
custom validator throws mid-mutation, the throw escapes after earlier state changes and leaves the
|
|
959
|
+
previous error list in place. The "Errors are current after completed evaluation" invariant under
|
|
960
|
+
[Contract](#contract) states the exact partial-state boundary.
|
|
961
|
+
|
|
962
|
+
**`valid` and `dirty` are derived on read.** `valid` is true when `errors` is empty. `dirty` is true
|
|
963
|
+
once the answers differ from `baseline`, the ones the form opened with. Neither is stored, so
|
|
964
|
+
neither can drift.
|
|
965
|
+
|
|
966
|
+
**`touched` is the fields somebody has visited.** It is what lets a renderer withhold an error until
|
|
967
|
+
the person has had their turn at the field. A failed submit marks every enabled field touched, so
|
|
968
|
+
the errors the person has not reached yet become showable at exactly the moment they matter.
|
|
969
|
+
|
|
970
|
+
```ts
|
|
971
|
+
import { createForm } from '@orkestrel/form'
|
|
972
|
+
|
|
973
|
+
const form = createForm({
|
|
974
|
+
fields: [
|
|
975
|
+
{ control: 'text', name: 'email', rule: { required: true, email: true } },
|
|
976
|
+
{ control: 'confirm', name: 'terms', rule: { required: true } },
|
|
977
|
+
],
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
form.errors.length // 2 — current from the moment the form opens
|
|
981
|
+
form.valid // false
|
|
982
|
+
form.dirty // false
|
|
983
|
+
form.status // 'editing'
|
|
984
|
+
|
|
985
|
+
form.field('email')?.control // 'text'
|
|
986
|
+
form.touch('email')
|
|
987
|
+
form.touched.has('email') // true
|
|
988
|
+
|
|
989
|
+
form.fill('email', 'ada@example.com')
|
|
990
|
+
form.dirty // true
|
|
991
|
+
form.errors.length // 1
|
|
992
|
+
|
|
993
|
+
form.submit().success // false — `terms` is still unanswered
|
|
994
|
+
Array.from(form.touched) // ['email', 'terms'] — a failed submit touches every enabled field
|
|
995
|
+
|
|
996
|
+
form.fill('terms', true)
|
|
997
|
+
form.submit() // { success: true, value: { email: 'ada@example.com', terms: true } }
|
|
998
|
+
form.status // 'settled'
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
### The visibility switches
|
|
1002
|
+
|
|
1003
|
+
They differ in what they remove, and the difference is load-bearing.
|
|
1004
|
+
|
|
1005
|
+
| Switch | Renderer obligation | `fill` | Validated | Submitted |
|
|
1006
|
+
| ---------- | ---------------------------- | ------- | --------- | --------- |
|
|
1007
|
+
| `hidden` | omit | accepts | yes | yes |
|
|
1008
|
+
| `locked` | render without person edits | accepts | yes | yes |
|
|
1009
|
+
| `disabled` | omit or render without edits | accepts | no | no |
|
|
1010
|
+
|
|
1011
|
+
`hidden` keeps a field out of the rendered form while it still travels. `locked` renders it
|
|
1012
|
+
unwritable. `disabled` takes the field out of the form entirely: it is neither evaluated nor
|
|
1013
|
+
submitted, and its value may still appear in `values` so a renderer can show it.
|
|
1014
|
+
`fill` refuses none of `hidden`, `locked`, and `disabled`; they constrain rendering, evaluation,
|
|
1015
|
+
and submission, not programmatic writes.
|
|
1016
|
+
|
|
1017
|
+
`FieldBase.disabled` is the field's **declared, opening** state. `FormInterface.disabled` is the
|
|
1018
|
+
**current fact**, and the next section is how it moves.
|
|
1019
|
+
|
|
1020
|
+
```ts
|
|
1021
|
+
import { createForm } from '@orkestrel/form'
|
|
1022
|
+
|
|
1023
|
+
const form = createForm({
|
|
1024
|
+
fields: [
|
|
1025
|
+
{ control: 'text', name: 'email', rule: { required: true } },
|
|
1026
|
+
{
|
|
1027
|
+
control: 'text',
|
|
1028
|
+
name: 'legacy',
|
|
1029
|
+
disabled: true,
|
|
1030
|
+
default: 'kept',
|
|
1031
|
+
rule: { required: true, email: true },
|
|
1032
|
+
},
|
|
1033
|
+
],
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
form.values // { legacy: 'kept' } — present for a renderer
|
|
1037
|
+
form.errors.length // 1 — only `email`; the disabled field is not evaluated
|
|
1038
|
+
|
|
1039
|
+
form.fill('email', 'ada@example.com')
|
|
1040
|
+
form.submit() // { success: true, value: { email: 'ada@example.com' } } — `legacy` is not submitted
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
### Taking a field out, and putting it back
|
|
1044
|
+
|
|
1045
|
+
`disable` and `enable` move a field between being in the form and being out of it, while the form is
|
|
1046
|
+
live. Each is one verb whose overloads take no argument for every field, one name for one field, or
|
|
1047
|
+
a list of names for those. There is no group overload, because a host expands a group in one line
|
|
1048
|
+
from the schema it already holds, and a group argument would be a second way to say the same thing.
|
|
1049
|
+
|
|
1050
|
+
```ts
|
|
1051
|
+
import { createForm } from '@orkestrel/form'
|
|
1052
|
+
|
|
1053
|
+
const form = createForm({
|
|
1054
|
+
groups: [{ name: 'billing', label: 'Billing' }],
|
|
1055
|
+
fields: [
|
|
1056
|
+
{ control: 'text', name: 'card', group: 'billing', rule: { required: true } },
|
|
1057
|
+
{ control: 'text', name: 'zip', group: 'billing', rule: { required: true } },
|
|
1058
|
+
{ control: 'text', name: 'email', rule: { required: true } },
|
|
1059
|
+
],
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
// A group, expanded by the host from the schema it already holds.
|
|
1063
|
+
const billing = form.schema.fields.filter((field) => field.group === 'billing')
|
|
1064
|
+
form.disable(billing.map((field) => field.name))
|
|
1065
|
+
|
|
1066
|
+
Array.from(form.disabled) // ['card', 'zip']
|
|
1067
|
+
form.errors.length // 1 — only `email` is still in the form
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
**The schema declares, the form decides.** `FieldBase.disabled` is what the schema said when the
|
|
1071
|
+
form opened. Each `disable` or `enable` records a runtime decision that sits over that declaration,
|
|
1072
|
+
and `form.disabled` is the declaration and the decision read together — the current fact, and the
|
|
1073
|
+
set every other part of the form reads: evaluation skips it, a submit leaves it out of the answers,
|
|
1074
|
+
and a failed submit does not touch it.
|
|
1075
|
+
|
|
1076
|
+
**A batch is all-or-nothing.** Every name in a list is checked against the schema before any field
|
|
1077
|
+
moves, so one unknown name throws `FormError` coded `FIELD` and the call changes nothing.
|
|
1078
|
+
|
|
1079
|
+
**Each announces only what moved.** `disable` and `enable` each fire once per field whose effective
|
|
1080
|
+
state actually changed, in the order the schema declares the fields. A call that moves nothing —
|
|
1081
|
+
disabling what is already out — returns before it writes anything: no event, no recompute, no
|
|
1082
|
+
overlay entry. `fill` is not the same. An unchanged answer suppresses the `fill` event, but the
|
|
1083
|
+
answer is still rewritten, the field's invalidation is still dropped, and the error list is still
|
|
1084
|
+
recomputed.
|
|
1085
|
+
|
|
1086
|
+
**An invalidation survives the trip.** A field's external failure is kept while it is out, withheld
|
|
1087
|
+
from `errors`, and reappears when it comes back. Disabling a field to skip its rules does not erase
|
|
1088
|
+
what a server told you about it.
|
|
1089
|
+
|
|
1090
|
+
**`clear` resets the overlay.** Clearing returns the form to how it opened, and the runtime decisions
|
|
1091
|
+
are part of that: `disabled` reads the schema's declarations again, beside the restored answers and
|
|
1092
|
+
the cleared `touched` set. **The `clear` event is the whole announcement of that reset**: the
|
|
1093
|
+
restored answers emit no `fill`, and the overlay reset emits no `disable` and no `enable`. A listener
|
|
1094
|
+
that maintains its own picture from events alone reads one `clear` as everything having gone back,
|
|
1095
|
+
rather than waiting for per-field news that never comes.
|
|
1096
|
+
|
|
1097
|
+
Both are writes, so a settled or abandoned form refuses them with `SETTLED` or `ABANDONED`. And
|
|
1098
|
+
because any declared-disabled field can be enabled at any moment, `auditSchema` holds every field to
|
|
1099
|
+
the same satisfiability standard whether it is declared disabled or not: it reads the schema alone,
|
|
1100
|
+
so no declaration and no runtime decision changes a diagnostic.
|
|
1101
|
+
|
|
1102
|
+
```ts
|
|
1103
|
+
import { createForm } from '@orkestrel/form'
|
|
1104
|
+
|
|
1105
|
+
const moved: string[] = []
|
|
1106
|
+
|
|
1107
|
+
const form = createForm(
|
|
1108
|
+
{
|
|
1109
|
+
fields: [
|
|
1110
|
+
{ control: 'text', name: 'email', rule: { required: true } },
|
|
1111
|
+
{ control: 'text', name: 'nickname', rule: { required: true } },
|
|
1112
|
+
{ control: 'text', name: 'legacy', disabled: true, default: 'kept' },
|
|
1113
|
+
],
|
|
1114
|
+
},
|
|
1115
|
+
{
|
|
1116
|
+
on: {
|
|
1117
|
+
fill: (name) => moved.push(`fill ${name}`),
|
|
1118
|
+
disable: (name) => moved.push(`disable ${name}`),
|
|
1119
|
+
enable: (name) => moved.push(`enable ${name}`),
|
|
1120
|
+
},
|
|
1121
|
+
},
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
Array.from(form.disabled) // ['legacy'] — the schema's declaration, before anything moves
|
|
1125
|
+
form.errors.length // 2 — `email` and `nickname`
|
|
1126
|
+
|
|
1127
|
+
form.disable('nickname')
|
|
1128
|
+
Array.from(form.disabled) // ['nickname', 'legacy'] — in schema order
|
|
1129
|
+
form.errors.length // 1 — a field that is out is not evaluated
|
|
1130
|
+
form.disable('nickname') // already out: nothing moves, nothing is announced
|
|
1131
|
+
|
|
1132
|
+
form.fill('email', 'ada@example.com')
|
|
1133
|
+
form.invalidate('email', 'That address is already registered')
|
|
1134
|
+
form.errors // [{ field: 'email', message: 'That address is already registered' }]
|
|
1135
|
+
|
|
1136
|
+
form.disable('email')
|
|
1137
|
+
form.errors // [] — the failure is held, not lost
|
|
1138
|
+
form.enable('email')
|
|
1139
|
+
form.errors // [{ field: 'email', message: 'That address is already registered' }]
|
|
1140
|
+
|
|
1141
|
+
try {
|
|
1142
|
+
form.disable(['email', 'nope'])
|
|
1143
|
+
} catch {
|
|
1144
|
+
// Coded FIELD. Every name is checked first, so `email` never left the form.
|
|
1145
|
+
}
|
|
1146
|
+
form.disabled.has('email') // false
|
|
1147
|
+
|
|
1148
|
+
form.clear()
|
|
1149
|
+
Array.from(form.disabled) // ['legacy'] — back to the declaration
|
|
1150
|
+
form.values // { legacy: 'kept' } — and the answers went back with it
|
|
1151
|
+
moved // ['disable nickname', 'fill email', 'disable email', 'enable email'] — `clear` added nothing
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
The same set travels to the pure helpers. `evaluateForm` takes `EvaluationOptions`, whose `disabled`
|
|
1155
|
+
**replaces** the schema's declarations rather than adding to them, because a live form always
|
|
1156
|
+
supplies its own current set, and the schema's declarations beside it would disagree.
|
|
1157
|
+
|
|
1158
|
+
```ts
|
|
1159
|
+
import { evaluateForm } from '@orkestrel/form'
|
|
1160
|
+
import type { FormSchema } from '@orkestrel/form'
|
|
1161
|
+
|
|
1162
|
+
const schema: FormSchema = {
|
|
1163
|
+
fields: [
|
|
1164
|
+
{ control: 'text', name: 'card', rule: { required: true } },
|
|
1165
|
+
{ control: 'text', name: 'email', rule: { required: true } },
|
|
1166
|
+
],
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
evaluateForm(schema, {}, { disabled: new Set(['card']) })
|
|
1170
|
+
// [{ field: 'email', message: 'This field is required', rule: 'required' }]
|
|
1171
|
+
evaluateForm(schema, {}, { messages: { required: 'Needed' }, disabled: new Set(['card']) })
|
|
1172
|
+
// [{ field: 'email', message: 'Needed', rule: 'required' }]
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
### Filling, clearing, and failing from outside
|
|
1176
|
+
|
|
1177
|
+
`fill` takes either one name and one value, or a whole record. Every answer is checked before any is
|
|
1178
|
+
written, so a refused write changes nothing. Passing `undefined` clears one field.
|
|
1179
|
+
|
|
1180
|
+
`invalidate` fails a field for a reason the rules cannot see — an address already registered, a
|
|
1181
|
+
coupon already spent. One field holds one external failure, a second call replaces the first, and
|
|
1182
|
+
the failure lasts until that field is filled again or the form is cleared.
|
|
1183
|
+
|
|
1184
|
+
`baseline` is those opening answers, held as a value: the schema's defaults overlaid with any seeded
|
|
1185
|
+
`values`, fixed when the form opens and never moved again. It is what `dirty` measures against and
|
|
1186
|
+
what `clear` returns to, so a host that wants to know _which_ answers moved — not merely that one
|
|
1187
|
+
did — reads `extractChanges(form.values, form.baseline)` and gets the names.
|
|
1188
|
+
|
|
1189
|
+
`clear` returns every answer to `baseline`. It also clears `touched`, every external failure, and
|
|
1190
|
+
every runtime `disable` or `enable`, so the form reads exactly as it opened.
|
|
1191
|
+
|
|
1192
|
+
```ts
|
|
1193
|
+
import { createForm, extractChanges } from '@orkestrel/form'
|
|
1194
|
+
|
|
1195
|
+
const form = createForm({
|
|
1196
|
+
fields: [
|
|
1197
|
+
{ control: 'text', name: 'email', rule: { required: true, email: true } },
|
|
1198
|
+
{
|
|
1199
|
+
control: 'select',
|
|
1200
|
+
name: 'plan',
|
|
1201
|
+
choices: [{ value: 'free', label: 'Free' }],
|
|
1202
|
+
default: 'free',
|
|
1203
|
+
},
|
|
1204
|
+
],
|
|
1205
|
+
})
|
|
1206
|
+
|
|
1207
|
+
form.baseline // { plan: 'free' } — the answers it opened with, fixed for its whole life
|
|
1208
|
+
|
|
1209
|
+
form.fill('email', 'ada@example.com')
|
|
1210
|
+
form.valid // true
|
|
1211
|
+
form.dirty // true
|
|
1212
|
+
Array.from(extractChanges(form.values, form.baseline)) // ['email'] — which answer moved, by name
|
|
1213
|
+
|
|
1214
|
+
form.invalidate('email', 'That address is already registered')
|
|
1215
|
+
form.errors // [{ field: 'email', message: 'That address is already registered' }]
|
|
1216
|
+
form.valid // false
|
|
1217
|
+
|
|
1218
|
+
form.fill('email', 'grace@example.com')
|
|
1219
|
+
form.errors // [] — refilling the field clears its external failure
|
|
1220
|
+
|
|
1221
|
+
form.clear()
|
|
1222
|
+
form.values // { plan: 'free' } — back to the answers the form opened with
|
|
1223
|
+
form.dirty // false
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
### Park-as-Promise: `answer`
|
|
1227
|
+
|
|
1228
|
+
`answer` is the form's whole point on a server. It resolves with the submitted values on the first
|
|
1229
|
+
valid submit, and rejects with a `FormError` coded `ABANDONED` when teardown abandons the form
|
|
1230
|
+
before it settles. One task can await it while an entirely different task fills and submits the
|
|
1231
|
+
form, which is what a parked question looks like when a promise is the only thing that has to cross
|
|
1232
|
+
between them.
|
|
1233
|
+
|
|
1234
|
+
Nothing has to await it. An unawaited form that is destroyed does not take the host down with it.
|
|
1235
|
+
|
|
1236
|
+
```ts
|
|
1237
|
+
import { createForm } from '@orkestrel/form'
|
|
1238
|
+
|
|
1239
|
+
const form = createForm({ fields: [{ control: 'text', name: 'name', rule: { required: true } }] })
|
|
1240
|
+
|
|
1241
|
+
// One task parks on the answer.
|
|
1242
|
+
const parked = form.answer
|
|
1243
|
+
|
|
1244
|
+
// Another task — a request handler, a socket message, a keyboard — supplies it.
|
|
1245
|
+
form.fill('name', 'Ada')
|
|
1246
|
+
form.submit()
|
|
1247
|
+
|
|
1248
|
+
await parked // { name: 'Ada' }
|
|
1249
|
+
```
|
|
1250
|
+
|
|
1251
|
+
### Abandoning a parked answer
|
|
1252
|
+
|
|
1253
|
+
Destroying a form before it settles rejects every parked `answer` with a `FormError` coded
|
|
1254
|
+
`ABANDONED`, which the parked task recovers through `isFormError`.
|
|
1255
|
+
|
|
1256
|
+
```ts
|
|
1257
|
+
import { createForm, isFormError } from '@orkestrel/form'
|
|
1258
|
+
|
|
1259
|
+
const abandoned = createForm({ fields: [{ control: 'text', name: 'name' }] })
|
|
1260
|
+
const pending = abandoned.answer
|
|
1261
|
+
|
|
1262
|
+
abandoned.destroy()
|
|
1263
|
+
abandoned.status // 'abandoned'
|
|
1264
|
+
|
|
1265
|
+
try {
|
|
1266
|
+
await pending
|
|
1267
|
+
} catch (error) {
|
|
1268
|
+
if (isFormError(error)) error.code // 'ABANDONED'
|
|
1269
|
+
}
|
|
1270
|
+
```
|
|
1271
|
+
|
|
1272
|
+
### Settle once
|
|
1273
|
+
|
|
1274
|
+
The first valid submit is the only one. It resolves `answer`, emits `submit`, sets `status` to
|
|
1275
|
+
`settled`, and every later write — `fill`, `touch`, `invalidate`, `disable`, `enable`, `submit`,
|
|
1276
|
+
`clear` — throws a `FormError` coded `SETTLED`. A failed submit settles nothing and leaves the form
|
|
1277
|
+
open.
|
|
1278
|
+
|
|
1279
|
+
`destroy` tears the form down. Destroying twice does nothing the second time. A form that already
|
|
1280
|
+
settled keeps its `settled` status and announces nothing. An editing form turns `abandoned`, rejects
|
|
1281
|
+
`answer`, and emits `abandon` unless the request was deferred behind a mutation batch that settles
|
|
1282
|
+
before teardown.
|
|
1283
|
+
|
|
1284
|
+
### The submit decision
|
|
1285
|
+
|
|
1286
|
+
A `validate` listener can write to the form while a submit is deciding. The rules that follow say
|
|
1287
|
+
what the submit does about it.
|
|
1288
|
+
|
|
1289
|
+
**A submit that changes the error list announces before it decides.** Its own evaluation moves that
|
|
1290
|
+
list only when a `custom` validator answers differently than it did at the last mutation — every
|
|
1291
|
+
other rule reads state that only a mutation changes, and every mutation already recomputed. When it
|
|
1292
|
+
does move, `validate` fires and the listeners run; if any of them wrote, the submit evaluates once
|
|
1293
|
+
more before deciding.
|
|
1294
|
+
That is one further evaluation, not a loop until nothing changes.
|
|
1295
|
+
|
|
1296
|
+
**One submit can therefore emit `validate` more than once.** A listener that fills, invalidates,
|
|
1297
|
+
disables, or enables announces its own change as it makes it, so a host counting emissions inside one
|
|
1298
|
+
submit can see more than one.
|
|
1299
|
+
|
|
1300
|
+
**A refusal is the list checked at the decision, not a view of the form.** An evaluation that already
|
|
1301
|
+
failed stays the answer even when a listener then repairs or disables the field that failed. So
|
|
1302
|
+
`submit()` can return `{ success: false, error: [...] }` while `form.errors` reads `[]` and `valid`
|
|
1303
|
+
reads `true` the line after. The returned result is what the submit decided; the form is what the
|
|
1304
|
+
form holds now.
|
|
1305
|
+
|
|
1306
|
+
**A settlement made during listener work wins.** A listener that repairs the form and calls `submit`
|
|
1307
|
+
itself settles it, and that settlement is what the outer call returns — one `submit` event, one
|
|
1308
|
+
resolved `answer`, and no evaluation after it.
|
|
1309
|
+
|
|
1310
|
+
### Retrying a submit
|
|
1311
|
+
|
|
1312
|
+
**`submit` is the commit, not the attempt.** It is the moment this document is finished with, which
|
|
1313
|
+
is why it settles the form and why nothing may be written afterwards. A network request that can be
|
|
1314
|
+
refused and tried again is a different act, it belongs to the host, and it happens **before**
|
|
1315
|
+
`submit` — never inside it.
|
|
1316
|
+
|
|
1317
|
+
That gives one sequence, and it is short:
|
|
1318
|
+
|
|
1319
|
+
1. **Ask the form the synchronous question.** A submit that fails settles nothing, marks every
|
|
1320
|
+
enabled field touched, and leaves `status` at `editing`. Calling it for exactly that is correct
|
|
1321
|
+
and repeatable — sync errors are the case a local failed submit is for. Read the errors off the
|
|
1322
|
+
returned result rather than off the form: with a `validate` listener that writes, the returned
|
|
1323
|
+
result and the form can already disagree, as the preceding section sets out.
|
|
1324
|
+
2. **Run the attempt against `values`.** The request is the host's: its own timeout, its own retry
|
|
1325
|
+
count, its own backoff. The form is not involved and knows nothing about it.
|
|
1326
|
+
3. **Report a refusal through `invalidate`, not through a submit.** The message lands as a
|
|
1327
|
+
`FieldError` on the field it belongs to, `valid` turns false, and the form is still open for the
|
|
1328
|
+
person to fix it and for the host to try again.
|
|
1329
|
+
4. **Call `submit` only once the attempt succeeded.** That commits: `answer` resolves, `submit`
|
|
1330
|
+
fires, and the form is terminal.
|
|
1331
|
+
|
|
1332
|
+
**`idle`, `submitting`, `succeeded`, and `failed` are the host's request states, not the form's.**
|
|
1333
|
+
`FormStatus` has a member for each of a document's fates — being answered, finished, abandoned —
|
|
1334
|
+
and a retried request has none of them. A host that needs those states holds them beside the form,
|
|
1335
|
+
in whatever it already uses for in-flight requests, so the form never gains a status meaning a
|
|
1336
|
+
request about it is in the air.
|
|
1337
|
+
|
|
1338
|
+
```ts
|
|
1339
|
+
import { createForm } from '@orkestrel/form'
|
|
1340
|
+
|
|
1341
|
+
const form = createForm({
|
|
1342
|
+
fields: [{ control: 'text', name: 'email', rule: { required: true, email: true } }],
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
// 1. The synchronous question. Nothing settles, and every enabled field is now touched.
|
|
1346
|
+
form.submit().success // false
|
|
1347
|
+
form.status // 'editing'
|
|
1348
|
+
Array.from(form.touched) // ['email']
|
|
1349
|
+
|
|
1350
|
+
form.fill('email', 'ada@example.com')
|
|
1351
|
+
|
|
1352
|
+
// 2. The host's own attempt, against the answers the form holds. A real one is a request; this
|
|
1353
|
+
// one decides locally so the example runs.
|
|
1354
|
+
const refused = form.values.email === 'ada@example.com'
|
|
1355
|
+
|
|
1356
|
+
// 3. A refusal comes back as an invalidation. The form stays open and retryable.
|
|
1357
|
+
if (refused) form.invalidate('email', 'That address is already registered')
|
|
1358
|
+
form.valid // false
|
|
1359
|
+
form.status // 'editing'
|
|
1360
|
+
|
|
1361
|
+
// 4. The attempt that succeeds is the one that commits.
|
|
1362
|
+
form.fill('email', 'grace@example.com')
|
|
1363
|
+
form.submit().success // true
|
|
1364
|
+
form.status // 'settled'
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
## Events
|
|
1368
|
+
|
|
1369
|
+
Each event carries what a listener needs to act without reading the form back.
|
|
1370
|
+
|
|
1371
|
+
| Event | Payload | Fires |
|
|
1372
|
+
| ---------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1373
|
+
| `fill` | the field's `name`, and its new value | Once per field whose answer actually moved, in the order written. The value is `undefined` when the answer was cleared. A `clear` is the exception; see its row. |
|
|
1374
|
+
| `validate` | every current `FieldError` | Whenever the error list's content changes — after a fill, an invalidate, a disable, an enable, a clear, or a submit. Empty when the change was to no errors at all. One submit can fire it more than once: the submit announces its own change, and a listener that writes announces that change too. |
|
|
1375
|
+
| `disable` | the field's `name` | Once per field taken out of the form, in schema order. A call that moves nothing announces nothing. A `clear` is the exception; see its row. |
|
|
1376
|
+
| `enable` | the field's `name` | Once per field put back into the form, in schema order. A call that moves nothing announces nothing. A `clear` is the exception; see its row. |
|
|
1377
|
+
| `submit` | the submitted `FormValues` | On the submit that settles the form, and only that one. |
|
|
1378
|
+
| `clear` | nothing | On a completed `clear`, before any `validate` it caused. `clear` is the whole announcement of the reset: restored answers emit no `fill`, and the overlay reset emits no `disable` or `enable`. A custom-validator throw during reevaluation resets state but emits no `clear` and leaves the previous errors. |
|
|
1379
|
+
| `abandon` | nothing | On the `destroy` that abandons an unsettled form. Never on a settled one. |
|
|
1380
|
+
|
|
1381
|
+
Wire listeners at construction through `FormOptions.on`, or afterwards through the `emitter`. Both
|
|
1382
|
+
reach the same typed emitter, and a listener that throws is isolated and reported to
|
|
1383
|
+
`FormOptions.error` rather than breaking its siblings or the form.
|
|
1384
|
+
|
|
1385
|
+
```ts
|
|
1386
|
+
import { createForm } from '@orkestrel/form'
|
|
1387
|
+
|
|
1388
|
+
const seen: string[] = []
|
|
1389
|
+
|
|
1390
|
+
const form = createForm(
|
|
1391
|
+
{ fields: [{ control: 'text', name: 'email', rule: { required: true } }] },
|
|
1392
|
+
{
|
|
1393
|
+
on: {
|
|
1394
|
+
fill: (name, value) => seen.push(`fill ${name} ${String(value)}`),
|
|
1395
|
+
validate: (errors) => seen.push(`validate ${errors.length}`),
|
|
1396
|
+
submit: () => seen.push('submit'),
|
|
1397
|
+
},
|
|
1398
|
+
error: (error) => console.error(error),
|
|
1399
|
+
},
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
form.emitter.on('abandon', () => seen.push('abandon'))
|
|
1403
|
+
|
|
1404
|
+
form.fill('email', 'ada@example.com')
|
|
1405
|
+
form.submit()
|
|
1406
|
+
|
|
1407
|
+
seen // ['fill email ada@example.com', 'validate 0', 'submit']
|
|
1408
|
+
```
|
|
1409
|
+
|
|
1410
|
+
## Wire safety
|
|
1411
|
+
|
|
1412
|
+
A schema is data, so it travels. `serializeForm` projects it into JSON — dropping every `custom`
|
|
1413
|
+
validator and every absent member — and `parseForm` reads unknown JSON back into an owned schema,
|
|
1414
|
+
refusing anything that is not structurally valid and semantically sound. The round trip is exact for
|
|
1415
|
+
everything that travels.
|
|
1416
|
+
|
|
1417
|
+
```ts
|
|
1418
|
+
import { parseForm, serializeForm } from '@orkestrel/form'
|
|
1419
|
+
import type { FormSchema } from '@orkestrel/form'
|
|
1420
|
+
|
|
1421
|
+
const schema: FormSchema = {
|
|
1422
|
+
name: 'signup',
|
|
1423
|
+
label: 'Sign up',
|
|
1424
|
+
groups: [{ name: 'account', label: 'Account' }],
|
|
1425
|
+
fields: [
|
|
1426
|
+
{ control: 'text', name: 'email', group: 'account', rule: { required: true, email: true } },
|
|
1427
|
+
{ control: 'checkbox', name: 'topics', choices: [{ value: 'a', label: 'A' }], default: ['a'] },
|
|
1428
|
+
],
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const wire = JSON.stringify(serializeForm(schema))
|
|
1432
|
+
const received = parseForm(JSON.parse(wire))
|
|
1433
|
+
|
|
1434
|
+
JSON.stringify(serializeForm(received ?? schema)) === wire // true
|
|
1435
|
+
parseForm({ fields: 'not a list' }) // undefined
|
|
1436
|
+
```
|
|
1437
|
+
|
|
1438
|
+
Answers travel too, and they arrive as strings far more often than not — a query string, a form
|
|
1439
|
+
post, a CSV cell. `parseValue` coerces a numeric string into a `number` for a `number` field, and
|
|
1440
|
+
`'true'` or `'false'` into a boolean for a `confirm` field, and nothing else. Every other value must
|
|
1441
|
+
already have its control's shape.
|
|
1442
|
+
|
|
1443
|
+
`parseValues` is strict in both directions: an unknown key refuses the whole record, and so does one
|
|
1444
|
+
value its field's control cannot hold. There is no partial result, because a half-accepted answer
|
|
1445
|
+
set is worse than a rejected one.
|
|
1446
|
+
|
|
1447
|
+
```ts
|
|
1448
|
+
import { parseValue, parseValues } from '@orkestrel/form'
|
|
1449
|
+
import type { ConfirmField, FormSchema, NumberField } from '@orkestrel/form'
|
|
1450
|
+
|
|
1451
|
+
const age: NumberField = { control: 'number', name: 'age' }
|
|
1452
|
+
const ok: ConfirmField = { control: 'confirm', name: 'ok' }
|
|
1453
|
+
const schema: FormSchema = { fields: [age, ok] }
|
|
1454
|
+
|
|
1455
|
+
parseValue(age, '42') // 42
|
|
1456
|
+
parseValue(age, 'abc') // undefined
|
|
1457
|
+
parseValue(ok, 'true') // true
|
|
1458
|
+
parseValue(ok, 'yes') // undefined
|
|
1459
|
+
|
|
1460
|
+
parseValues(schema, { age: '42', ok: 'true' }) // { age: 42, ok: true }
|
|
1461
|
+
parseValues(schema, { nope: '1' }) // undefined
|
|
1462
|
+
```
|
|
1463
|
+
|
|
1464
|
+
The guards are the same boundary read one field at a time, and every one of them is total.
|
|
1465
|
+
|
|
1466
|
+
```ts
|
|
1467
|
+
import {
|
|
1468
|
+
isFieldChoice,
|
|
1469
|
+
isFieldControl,
|
|
1470
|
+
isFieldError,
|
|
1471
|
+
isFieldRule,
|
|
1472
|
+
isFieldValue,
|
|
1473
|
+
isFormField,
|
|
1474
|
+
isFormGroup,
|
|
1475
|
+
isFormSchema,
|
|
1476
|
+
isFormStatus,
|
|
1477
|
+
isFormValues,
|
|
1478
|
+
} from '@orkestrel/form'
|
|
1479
|
+
|
|
1480
|
+
isFieldControl('datetime') // true
|
|
1481
|
+
isFieldControl('radio') // false — a radio group is a `select`
|
|
1482
|
+
isFormStatus('settled') // true
|
|
1483
|
+
isFieldValue(['a', 'b']) // true
|
|
1484
|
+
isFieldValue({}) // false
|
|
1485
|
+
isFieldChoice({ value: 'a', label: 'A' }) // true
|
|
1486
|
+
isFieldChoice({ value: 'a', label: 'A', colour: 'red' }) // false — an unknown member refuses it
|
|
1487
|
+
isFieldRule({ required: true, minimum: 8 }) // true
|
|
1488
|
+
isFormField({ control: 'text', name: 'email' }) // true
|
|
1489
|
+
isFormField({ control: 'text' }) // false
|
|
1490
|
+
isFormGroup({ name: 'account', label: 'Account' }) // true
|
|
1491
|
+
isFormSchema({ fields: [{ control: 'text', name: 'a' }] }) // true
|
|
1492
|
+
isFormValues({ a: 'b', c: 2 }) // true
|
|
1493
|
+
isFieldError({ field: 'a', message: 'b', rule: 'required' }) // true
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
### Owning what arrives
|
|
1497
|
+
|
|
1498
|
+
The cloners are how a value stops being the caller's. The form clones the schema at construction,
|
|
1499
|
+
before the structural guard and the audit run, so those checks run against the copy. A caller's
|
|
1500
|
+
object that answers each read differently cannot pass a check on one value and be stored as
|
|
1501
|
+
another. A later edit to the caller's object changes nothing inside the form. The form also clones
|
|
1502
|
+
each list value it stores and each it returns, so no caller ever holds a reference to internal
|
|
1503
|
+
state. The cloners are exported because a consumer building its own schema store needs the same
|
|
1504
|
+
guarantee.
|
|
1505
|
+
|
|
1506
|
+
```ts
|
|
1507
|
+
import { cloneChoices, cloneFormField, cloneFormSchema, cloneValue } from '@orkestrel/form'
|
|
1508
|
+
|
|
1509
|
+
const topics = ['releases']
|
|
1510
|
+
const owned = cloneValue(topics)
|
|
1511
|
+
owned === topics // false
|
|
1512
|
+
Object.isFrozen(owned) // true
|
|
1513
|
+
cloneValue('text') // 'text' — a scalar is already its own value
|
|
1514
|
+
|
|
1515
|
+
Object.isFrozen(cloneChoices([{ value: 'a', label: 'A' }])) // true
|
|
1516
|
+
Object.isFrozen(cloneFormField({ control: 'text', name: 'email' })) // true
|
|
1517
|
+
Object.isFrozen(cloneFormSchema({ fields: [{ control: 'text', name: 'email' }] })) // true
|
|
1518
|
+
```
|
|
1519
|
+
|
|
1520
|
+
### Deriving without a form
|
|
1521
|
+
|
|
1522
|
+
The evaluation and derivation helpers are pure and take a schema plus values, so a caller that has
|
|
1523
|
+
no form — a server checking a posted body, an editor previewing a schema — reaches the same answers
|
|
1524
|
+
the form would give.
|
|
1525
|
+
|
|
1526
|
+
```ts
|
|
1527
|
+
import {
|
|
1528
|
+
appliesRule,
|
|
1529
|
+
computeDefaults,
|
|
1530
|
+
evaluateForm,
|
|
1531
|
+
extractGroups,
|
|
1532
|
+
matchesValue,
|
|
1533
|
+
matchesValues,
|
|
1534
|
+
} from '@orkestrel/form'
|
|
1535
|
+
import type { FormSchema } from '@orkestrel/form'
|
|
1536
|
+
|
|
1537
|
+
const schema: FormSchema = {
|
|
1538
|
+
groups: [
|
|
1539
|
+
{ name: 'account', label: 'Account' },
|
|
1540
|
+
{ name: 'unused', label: 'Unused' },
|
|
1541
|
+
],
|
|
1542
|
+
fields: [
|
|
1543
|
+
{ control: 'text', name: 'email', group: 'account', default: 'ada@example.com' },
|
|
1544
|
+
{ control: 'confirm', name: 'terms', default: false },
|
|
1545
|
+
{ control: 'password', name: 'secret' },
|
|
1546
|
+
],
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
computeDefaults(schema) // { email: 'ada@example.com', terms: false } — `password` seeds nothing
|
|
1550
|
+
extractGroups(schema) // [{ name: 'account', label: 'Account' }] — `unused` is referenced by nobody
|
|
1551
|
+
evaluateForm(schema, {}) // [] — no field declares a rule
|
|
1552
|
+
appliesRule('number', 'step') // true
|
|
1553
|
+
matchesValue(['a'], ['a']) // true
|
|
1554
|
+
matchesValues({ topics: ['a'] }, { topics: ['a'] }) // true
|
|
1555
|
+
```
|
|
1556
|
+
|
|
1557
|
+
## Methods
|
|
1558
|
+
|
|
1559
|
+
The public methods of `FormInterface`, which the `Form` class implements exactly and adds nothing
|
|
1560
|
+
to. Its readonly data members stay in the preceding `## Surface` rows, in the `Shape` cell before
|
|
1561
|
+
`plus`, and are not repeated here.
|
|
1562
|
+
|
|
1563
|
+
A record of answers and a list of names are checked in full before anything moves, so a refused call
|
|
1564
|
+
changes nothing.
|
|
1565
|
+
|
|
1566
|
+
Every other row in the Surface tables is a data shape, a union, a constant, a function, or an error
|
|
1567
|
+
class, so none of them carries a method table. `FieldValidator` is a callable function type with one
|
|
1568
|
+
call signature and no named members.
|
|
1569
|
+
|
|
1570
|
+
#### `FormInterface`
|
|
1571
|
+
|
|
1572
|
+
| Method | Returns | Summary |
|
|
1573
|
+
| ------------ | -------------------------- | ------------------------------------------------------------------ |
|
|
1574
|
+
| `field` | `FormField` or `undefined` | Finds one field by name. |
|
|
1575
|
+
| `fill` | `void` | Answers one field, or several at once. |
|
|
1576
|
+
| `touch` | `void` | Records that somebody has visited a field. |
|
|
1577
|
+
| `invalidate` | `void` | Fails a field from outside, for what the rules cannot see. |
|
|
1578
|
+
| `disable` | `void` | Takes one field, several fields, or every field out of the form. |
|
|
1579
|
+
| `enable` | `void` | Puts one field, several fields, or every field back into the form. |
|
|
1580
|
+
| `submit` | `FormResult` | Checks every answer and settles the form when they all pass. |
|
|
1581
|
+
| `clear` | `void` | Returns every answer to the ones the form opened with. |
|
|
1582
|
+
| `destroy` | `void` | Tears the form down, abandoning it when it has not settled. |
|
|
1583
|
+
|
|
1584
|
+
## Errors
|
|
1585
|
+
|
|
1586
|
+
`FormError` carries a machine-readable `code` and an optional structured `context`. Narrow a caught
|
|
1587
|
+
value with `isFormError` and branch on `code`; never match on message text. A custom validator's own
|
|
1588
|
+
throw is the caller's exception and escapes unchanged.
|
|
1589
|
+
|
|
1590
|
+
| Code | Raised when |
|
|
1591
|
+
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1592
|
+
| `SCHEMA` | The schema is not a form schema, `auditSchema` found a domain fault, or `cloneFormField` cannot own a field's `meta`. The constructor raises each; `cloneFormField` and `serializeForm` each raise the metadata one on their own. |
|
|
1593
|
+
| `FIELD` | A name given to `fill`, `touch`, `invalidate`, `disable`, or `enable` is one the schema does not declare. |
|
|
1594
|
+
| `CONTROL` | A value written or seeded is one its field's control cannot hold. |
|
|
1595
|
+
| `SETTLED` | A write reached a form that has already settled. |
|
|
1596
|
+
| `ABANDONED` | A write reached a form that was destroyed before it settled, or `answer` rejected for that reason. |
|
|
1597
|
+
|
|
1598
|
+
```ts
|
|
1599
|
+
import { createForm, isFormError } from '@orkestrel/form'
|
|
1600
|
+
|
|
1601
|
+
try {
|
|
1602
|
+
createForm({ fields: [{ control: 'text', name: '' }] })
|
|
1603
|
+
} catch (error) {
|
|
1604
|
+
if (isFormError(error)) error.code // 'SCHEMA'
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
const form = createForm({ fields: [{ control: 'number', name: 'age' }] })
|
|
1608
|
+
|
|
1609
|
+
try {
|
|
1610
|
+
form.fill('nope', 1)
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
if (isFormError(error)) error.code // 'FIELD'
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
try {
|
|
1616
|
+
form.fill('age', 'twelve')
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
if (isFormError(error)) error.code // 'CONTROL'
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
form.values // {} — a refused write changed nothing
|
|
1622
|
+
```
|
|
1623
|
+
|
|
1624
|
+
`createForm` and `new Form(...)` are the same construction. Prefer the factory at a call site that
|
|
1625
|
+
only needs `FormInterface`; reach for the class where a class holds a form as its own field and
|
|
1626
|
+
wants the concrete type.
|
|
1627
|
+
|
|
1628
|
+
```ts
|
|
1629
|
+
import { Form } from '@orkestrel/form'
|
|
1630
|
+
|
|
1631
|
+
const form = new Form({ fields: [{ control: 'text', name: 'email', rule: { required: true } }] })
|
|
1632
|
+
form.fill('email', 'ada@example.com')
|
|
1633
|
+
form.submit().success // true
|
|
1634
|
+
```
|
|
1635
|
+
|
|
1636
|
+
## Contract
|
|
1637
|
+
|
|
1638
|
+
These invariants hold across [`src/core`](../src/core) and this guide.
|
|
1639
|
+
|
|
1640
|
+
1. **Documented surface equals exported surface.** Every row in the `## Surface` tables is a real
|
|
1641
|
+
barrel export of `src/core`, and every barrel export is a row — both directions, exhaustively.
|
|
1642
|
+
Nothing in this module is internal, so the parity suite's internal list is empty.
|
|
1643
|
+
2. **Documented methods equal interface methods.** The `## Methods` table for `FormInterface` lists
|
|
1644
|
+
exactly its call-signature members, and the `Form` class implements every one and adds no public
|
|
1645
|
+
behavior beyond them.
|
|
1646
|
+
3. **The schema is owned before it is read.** `Form` clones the schema at construction and freezes
|
|
1647
|
+
every nested group, field, rule, choice, and list. The clone is the only read of the caller's
|
|
1648
|
+
object: `isFormSchema` and `auditSchema` both run against the owned copy, and that same copy is
|
|
1649
|
+
what the form keeps. A later edit to the caller's object changes nothing inside the form, and no
|
|
1650
|
+
getter returns a live internal reference.
|
|
1651
|
+
4. **Errors are current after completed evaluation.** `errors` is recomputed at construction and
|
|
1652
|
+
after every mutation whose evaluation completes, and `validate` fires exactly when that list's
|
|
1653
|
+
content changes. A custom-validator throw escapes mid-evaluation. After a throwing `fill`, the
|
|
1654
|
+
form holds the new answers beside the pre-fill errors. A throwing `invalidate` records its
|
|
1655
|
+
failure but keeps that stale list. A throwing `clear` resets answers, touched fields, and
|
|
1656
|
+
invalidations but emits no `clear` and leaves the previous errors. There is no `check`. A failed
|
|
1657
|
+
`submit` returns the list it checked at the decision, which is a value rather than a view: after a
|
|
1658
|
+
`validate` listener wrote during that submit, `errors` can already differ from it.
|
|
1659
|
+
5. **`valid` and `dirty` are derived.** Both are computed on read, from `errors` and from the
|
|
1660
|
+
answers against `baseline`, so neither can drift from what the form holds. `baseline` itself is
|
|
1661
|
+
fixed when the form opens and never moves again.
|
|
1662
|
+
6. **A write is all-or-nothing.** `fill` checks every answer against its control before writing any,
|
|
1663
|
+
and `disable` and `enable` check every name against the schema before any field moves, so a
|
|
1664
|
+
`FIELD` or `CONTROL` failure leaves the form exactly as it was.
|
|
1665
|
+
7. **Settle once, terminally.** The first valid submit resolves `answer`, emits `submit`, and sets
|
|
1666
|
+
`status` to `settled`; every later write throws. A destroy not overtaken by an in-flight
|
|
1667
|
+
settlement sets `abandoned`, rejects `answer` with `ABANDONED`, and emits `abandon`. The exception
|
|
1668
|
+
is a destroy deferred behind a mutation batch that settles before teardown: the form ends
|
|
1669
|
+
`settled`, `answer` resolves, and no `abandon` is emitted. Neither end state is left, and every
|
|
1670
|
+
getter keeps answering in both. A settlement made by a nested `submit` inside a `validate`
|
|
1671
|
+
listener is that one settlement: the outer call returns it, `submit` still fires once, and nothing
|
|
1672
|
+
is evaluated after it.
|
|
1673
|
+
8. **`undefined` is the only absence.** A field is unanswered when `values` holds no key for it, and
|
|
1674
|
+
answered otherwise, so `''`, whitespace, `[]`, `false`, and `0` all satisfy `required`. This is
|
|
1675
|
+
not HTML's model, which fails `required` on the exact empty string. A binding that wants HTML's
|
|
1676
|
+
answer projects at the binding — `fill(name, matchesAnswer(raw) ? raw : undefined)` — and core
|
|
1677
|
+
evaluation never applies that projection itself.
|
|
1678
|
+
9. **A disabled field is out of the form, and the form decides which.** A field that is out is
|
|
1679
|
+
neither evaluated nor submitted, while `hidden` and `locked` are rendering facts only and are
|
|
1680
|
+
both still evaluated and still submitted. `FieldBase.disabled` is the declaration and
|
|
1681
|
+
`FormInterface.disabled` is the current fact: `disable` and `enable` move a field either way,
|
|
1682
|
+
each announcing once per field that actually moved in schema order, an invalidation is held
|
|
1683
|
+
while its field is out and restored when it returns, and `clear` resets the overlay to the
|
|
1684
|
+
declarations — announcing that reset with `clear` alone, never with a per-field `disable` or
|
|
1685
|
+
`enable`. `auditSchema` therefore holds every field to the same satisfiability standard,
|
|
1686
|
+
disabled or not: it reads the schema alone, so no declaration and no runtime set changes a
|
|
1687
|
+
diagnostic, and a passing schema carries none of the faults it enumerates whatever is disabled
|
|
1688
|
+
at runtime. It claims nothing beyond that list, and `custom` is outside it.
|
|
1689
|
+
10. **Guards are total and parsers refuse.** No `is*` throws for any input — hostile prototype,
|
|
1690
|
+
symbol key, cycle, or depth. No `parse*` throws; each returns `undefined` on refusal. A
|
|
1691
|
+
guard-valid value is never refused by its parser, and every parsed result satisfies its guard.
|
|
1692
|
+
A pattern within `PATTERN_LIMIT` can still backtrack catastrophically; this package applies no
|
|
1693
|
+
time bound, so evaluating an untrusted pattern spends the caller's thread.
|
|
1694
|
+
11. **Every retained size is budgeted.** `auditSchema` reports a breach of `FIELD_LIMIT`,
|
|
1695
|
+
`GROUP_LIMIT`, `CHOICE_LIMIT`, `NAME_LIMIT`, `STRING_LIMIT`, `TEXT_LIMIT`, `NODE_LIMIT`, or
|
|
1696
|
+
`PATTERN_LIMIT`, so `createForm` throws `SCHEMA` and `parseForm` refuses; `matchesField` refuses
|
|
1697
|
+
a value breaching `STRING_LIMIT` or `LIST_LIMIT` before any regular expression sees it, so
|
|
1698
|
+
`fill` and a seeded value throw `CONTROL` and the parsers return `undefined`. `TEXT_LIMIT` and
|
|
1699
|
+
`NODE_LIMIT` are whole-schema ceilings, `meta` included, so the per-item limits never multiply.
|
|
1700
|
+
They bound retention and the audit's own walk, never the structural read at the parse door,
|
|
1701
|
+
which is the transport's to bound. Regular-expression time and a `custom` validator's own work
|
|
1702
|
+
stay unbounded too.
|
|
1703
|
+
12. **Only data crosses the wire.** `serializeForm` drops every `custom` validator on the way out
|
|
1704
|
+
and `parseForm` drops every `custom` member on the way in, so no function crosses in either
|
|
1705
|
+
direction. Everything that does cross survives the round trip exactly, `meta` verbatim key for
|
|
1706
|
+
key: evaluation never reads it, and this package defines no key in it.
|
|
1707
|
+
13. **`auditSchema` returns diagnostics, not a contract.** The list's emptiness is the promise. The
|
|
1708
|
+
wording of its strings is not: never parse them.
|
|
1709
|
+
14. **The temporal patterns are lexical.** `date`, `time`, and `datetime` values are checked for
|
|
1710
|
+
spelling, never against a calendar, so `'2026-02-31'` is a valid `date` value here. Bounds also
|
|
1711
|
+
compare lexically, so operands and values must use the same precision; `'09:00'` sorts before
|
|
1712
|
+
`'09:00:00'`.
|
|
1713
|
+
|
|
1714
|
+
## Concept inventory
|
|
1715
|
+
|
|
1716
|
+
What this package deliberately does not do, and where the work goes instead. Each line is a
|
|
1717
|
+
boundary taken on evidence, not an omission — so a reader can tell a boundary from a gap, and the
|
|
1718
|
+
next change knows what it is reopening. `Layer` names who owns the concept, and a row reading
|
|
1719
|
+
**seam** is one this package answers today through a mechanism it already exposes.
|
|
1720
|
+
|
|
1721
|
+
| Concept | Layer | Why it sits there |
|
|
1722
|
+
| -------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1723
|
+
| Derived `hidden` | out | A `hidden` computed from siblings turns a declared fact into a derived one, which puts a recompute pass back in front of every read — the `check` this design removed. `hidden` stays what the schema said. |
|
|
1724
|
+
| Required when a sibling says so | seam | `custom` runs after the named rules and on an absent value, so a required-when rule is one validator reading `values`. The unanswered field carries both messages when both fail. |
|
|
1725
|
+
| Relevance, when a field stops applying | seam | A field that no longer applies goes out of the form with `disable` and comes back with `enable`: not evaluated, not submitted, and its invalidation held meanwhile. |
|
|
1726
|
+
| Repeating field arrays | out | A field group answered many times over. It changes `FormValues` from a flat record into a tree, and every rule, guard, parser, and error path with it. |
|
|
1727
|
+
| Wizards and multi-step | host | Pages, ordering, and progress are presentation. A wizard is several forms and a host that sequences them. |
|
|
1728
|
+
| Pagination | host | Sections of one long form are presentation for the same reason a wizard is, and `group` already carries the arrangement a host paginates on. |
|
|
1729
|
+
| `month` and `week` | out | More temporal controls with more lexical patterns and no new idea. They join when a real consumer asks for one. |
|
|
1730
|
+
| Temporal `step` | out | `step` is number-only. A temporal step means intervals over calendar arithmetic, which is the same calendar this package deliberately does not carry. |
|
|
1731
|
+
| Presentation hints | renderer | Switch, radio, and range are affordances for questions already modelled as `confirm`, `select`, and `number`. A hint here would be product policy; `meta` is the carrier when a host must ship one anyway. See [Rendering](#rendering) for the catalog category each control maps to. |
|
|
1732
|
+
| Input masks | renderer | A mask is how characters are typed and shown as they are typed. `pattern` states what the finished value must be, which is the part that has to travel. |
|
|
1733
|
+
| Accessibility IDs | renderer | `label` and `help` are the strings an accessible control needs. The `id`s tying them to inputs belong to the layer that owns the elements and their uniqueness. |
|
|
1734
|
+
| First-error focus | renderer | `errors` is ordered by schema then rule, so the first error is the first entry. Which element takes focus is a decision only a renderer can make. |
|
|
1735
|
+
| File bytes | host | A `file` value is names. Bytes are a transport concern with a host-specific representation, and putting them in the document would make the document unserializable. |
|
|
1736
|
+
| Async validation | host | Every rule here is synchronous, so `errors` stays current after every mutation. An async check is the host's attempt, reported through `invalidate` on refusal — `submit` is the commit, never the attempt. |
|
|
1737
|
+
| Validation timing and debounce | host | `errors` is current after every mutation, so when to _show_ it is policy over `touched` and the host's own timers rather than a mode inside the form. |
|
|
1738
|
+
| Warnings and first-error-only modes | host | Every failure is an error and `errors` carries all of them in order. A severity axis, or a switch that reports only the first, is display policy applied over that list. |
|
|
1739
|
+
| Server-error bags | host | A map of field names to server messages is a loop over `invalidate`, which already holds one external failure per field and drops it when the field is refilled. |
|
|
1740
|
+
| Form-level validators | seam | A rule about the form as a whole. `custom` already reads every answer, so the same check runs today attached to the field it would fail. |
|
|
1741
|
+
| Address lists | seam | `TextField` has no `multiple`: that word already means a list of file names on `FileField`, and one word cannot carry two value shapes. Several addresses is `custom` plus the exported `EMAIL_PATTERN`. |
|
|
1742
|
+
| Group-level disable | seam | `disable` takes no group argument. A host expands a group from the schema in one line, and a second way to name the same set is a second thing to keep consistent. |
|
|
1743
|
+
| Computed fields | host | A field deriving its value from siblings would be a second writer of `values` and could disagree with `fill`. The host computes and fills, so one writer stays one writer. |
|
|
1744
|
+
| Async and live choices | host | `choices` is data in the schema. A list fetched or filtered while somebody types is the host building a new schema, which `parseForm` audits for it. |
|
|
1745
|
+
| Drafts and autosave | host | `values` is readable at any moment and `parseValues` reads a stored record back, so persistence is a host loop over `values` and `parseValues` with the host's own storage and cadence. |
|
|
1746
|
+
| Undo and history | host | The form holds the answers and `baseline` holds the ones it opened with. A stack of everything in between is a host concern with a host's retention policy. |
|
|
1747
|
+
| Schema migration | host | Versioning a stored schema and moving old answers onto a new one is the host's data problem. `parseForm` and `parseValues` are the gates on each side of it. |
|
|
1748
|
+
| Trim and normalize | binding | The form stores what it is given. Trimming, case folding, and Unicode normalization belong to the binding, and `matchesAnswer` is where a value that is blank after trimming becomes absence. |
|
|
1749
|
+
| Localization | host | `FormOptions.messages` replaces a rule's copy, and `label` and `help` are the schema author's strings. Locale selection, plurals, and message catalogs are the host's. |
|
|
1750
|
+
| Value localization | binding | A `date` value is the control's own ISO string and a `number` is a number. Reading `31/12/2026` or `1.234,5` from a person is the binding's parse, and it fills the parsed value. |
|
|
1751
|
+
| Group and choice `meta` | out | `meta` is on `FieldBase` alone, because a field carrier is what the first consumer asked for. The exact guards refuse it on `FormGroup` and `FieldChoice` until one asks. |
|
|
1752
|
+
| Constraint-API binding | `src/browser` | Reflecting `required` and `minimum` onto real elements, and reading `validity` back, is binding work. It belongs where the elements are. |
|
|
1753
|
+
| `FormData` and duplicate names | `src/browser` | Several values under one name, the submitter button, and the `FormData` encoding are host wire formats. `FormValues` holds one answer per field name by design. |
|
|
1754
|
+
| Browser binding | `src/browser` | Binding a schema to real elements belongs in a future `src/browser`, taking a form and an element. It is not in this round because nothing renders here yet. |
|
|
1755
|
+
| Terminal adoption | terminal | A terminal driver parking a whole form rather than one prompt belongs in the terminal package, on the same `answer` promise this document already exposes. |
|
|
1756
|
+
|
|
1757
|
+
## Tests
|
|
1758
|
+
|
|
1759
|
+
- [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ barrel bijection, the
|
|
1760
|
+
`FormInterface` ↔ `Form` method bijection, and the equality gate: every `Summary` cell against its
|
|
1761
|
+
declaration's description paragraph, the titled `Open a form, answer it, and settle it` fence
|
|
1762
|
+
against the `@example` block of that title (pinned so the titled pair cannot be retired silently),
|
|
1763
|
+
and the README pitch against this guide's tagline. It also runs the preceding flagship fences
|
|
1764
|
+
against the real source, so a documented value that the code contradicts fails.
|
|
1765
|
+
- [`tests/src/core/Form.test.ts`](../tests/src/core/Form.test.ts) — construction, state, `baseline`,
|
|
1766
|
+
`fill`, `touch`, `invalidate`, `disable`, `enable`, `submit`, `clear`, `destroy`, and the rule
|
|
1767
|
+
paths through the entity.
|
|
1768
|
+
- [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — `matchesField`,
|
|
1769
|
+
`matchesAnswer`, `appliesRule`, `evaluateField`, `evaluateForm`, `computeDefaults`,
|
|
1770
|
+
`matchesValue`, `extractChanges`, `matchesValues`, `formatMessage`, `createFieldError`,
|
|
1771
|
+
`defineEntry`, `freezeEntry`, `serializeForm`, `extractGroups`, `auditSchema`, the budgets, and
|
|
1772
|
+
the control-by-rule matrix.
|
|
1773
|
+
- [`tests/src/core/validators.test.ts`](../tests/src/core/validators.test.ts) — every guard against
|
|
1774
|
+
valid, off-shape, and hostile input, plus guard/parser soundness in both directions.
|
|
1775
|
+
- [`tests/src/core/parsers.test.ts`](../tests/src/core/parsers.test.ts) — `parseValue`,
|
|
1776
|
+
`parseValues`, `parseForm`, the wire round trip, and answer parking.
|
|
1777
|
+
- [`tests/src/core/cloners.test.ts`](../tests/src/core/cloners.test.ts) — every clone is owned,
|
|
1778
|
+
frozen, and deep enough that no caller reference survives.
|
|
1779
|
+
- [`tests/src/core/constants.test.ts`](../tests/src/core/constants.test.ts) — the registries, the
|
|
1780
|
+
permitted-member table, the default messages, and each shipped pattern.
|
|
1781
|
+
- [`tests/src/core/errors.test.ts`](../tests/src/core/errors.test.ts) — `FormError`'s `code` and
|
|
1782
|
+
`context`, and `isFormError` narrowing.
|
|
1783
|
+
- [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) — `createForm` returns a
|
|
1784
|
+
working `FormInterface`.
|
|
1785
|
+
- [`tests/src/core/index.test.ts`](../tests/src/core/index.test.ts) — the barrel resolves every
|
|
1786
|
+
documented export.
|
|
1787
|
+
|
|
1788
|
+
## See also
|
|
1789
|
+
|
|
1790
|
+
- [`AGENTS.md`](../AGENTS.md) — the coding contract this package is written against.
|
|
1791
|
+
- [`README.md`](README.md) — the guides index.
|