@valancex/common 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/form.js ADDED
@@ -0,0 +1,388 @@
1
+ // Forms from the schema (direction Bet 11). One description of valid: the schema the submitted command takes. A form reads it once for what a screen needs (a field's name, kind, title, constraints), keeps
2
+ // what the person typed as text, and decides from the same schema what is wrong and when to say so. It is a model, not a primitive: the fields are drawn with the ordinary `textfield`, `checkbox` and
3
+ // `radiogroup`, and the state is the application's own (a field of its state), so the command that submits reads every draft.
4
+ //
5
+ // const Signup = Schema.Struct({ name: Schema.String.pipe(Schema.minLength(2)).annotations({ title: "Name", message: () => "Enter at least 2 characters" }), … });
6
+ // const signup = Form.make(Signup);
7
+ // defineApp({ state: { signup: signup.field() }, derived: (derive) => ({ form: signup.view(derive, (s) => s.signup) }), commands: (command) => signup.commands(command, { … }), … });
8
+ //
9
+ // What it keeps for each field: the text typed (`value`), whether the person has left the field (`touched`), and the message a server gave it (`remote`). `dirty` and the message are worked out. An error is
10
+ // shown for a field when it is wrong and the person has left the field once or tried to submit (stricter than Angular's dirty-or-touched and Phoenix's used-input, so the first keystroke is not an error); once
11
+ // shown it follows the typing. A failed submit touches every field. Tier 1: the defaults are the whole interface; the options (`initial`, `message`, `required`) are Tier 2.
12
+ import { Either, Option, ParseResult, Schema, SchemaAST as AST } from "effect";
13
+ import { FIELD, FORM } from "./index.js";
14
+ /** A mistake in the schema a form is made from, said at the declaration and naming the field. */
15
+ export class FormError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "FormError";
19
+ }
20
+ }
21
+ /** The schema of a form's state: a struct with a named entry for each field (the model has no type for a record with free keys), so it can be a field of the application's state and be read in a template. */
22
+ const stateSchemaOf = (fields) => Schema.Struct({
23
+ values: Schema.Struct(Object.fromEntries(fields.map((each) => [each.name, each.kind === "boolean" ? Schema.Boolean : Schema.String]))),
24
+ touched: Schema.Struct(Object.fromEntries(fields.map((each) => [each.name, Schema.optional(Schema.Literal(true))]))),
25
+ attempts: Schema.Number,
26
+ remote: Schema.Struct(Object.fromEntries(fields.map((each) => [each.name, Schema.optional(Schema.String)]))),
27
+ });
28
+ const FieldViewSchema = Schema.Struct({
29
+ name: Schema.String,
30
+ title: Schema.String,
31
+ kind: Schema.Literal("text", "password", "number", "boolean", "choice"),
32
+ input: Schema.String,
33
+ required: Schema.Boolean,
34
+ choices: Schema.Array(Schema.String),
35
+ value: Schema.String,
36
+ checked: Schema.Boolean,
37
+ invalid: Schema.Boolean,
38
+ error: Schema.String,
39
+ errorId: Schema.String,
40
+ touched: Schema.Boolean,
41
+ dirty: Schema.Boolean,
42
+ });
43
+ /** The type of `View`, for the derived value a template reads. */
44
+ export const ViewSchema = Schema.Struct({
45
+ fields: Schema.Array(FieldViewSchema),
46
+ valid: Schema.Boolean,
47
+ message: Schema.String,
48
+ first: Schema.String,
49
+ submitted: Schema.Boolean,
50
+ });
51
+ export const fieldErrors = (errors) => ({ _tag: "FieldErrors", errors });
52
+ // ---- describing a schema ----------------------------------------------------------------------------------------------------------------------------------------
53
+ const SCHEMA_ID = AST.SchemaIdAnnotationId;
54
+ /** The titles Effect gives a refinement or a keyword by itself: `minLength(2)`, `int`, `string`. An author's title is anything else. */
55
+ const generated = (title) => /^\w+\(.*\)$/.test(title) || ["string", "number", "boolean", "int", "nonEmpty", "trimmed", "lowercased", "uppercased", "finite", "nonNaN"].includes(title);
56
+ const titleOf = (node) => {
57
+ const given = node.annotations[AST.TitleAnnotationId];
58
+ return typeof given === "string" && !generated(given) ? given : undefined;
59
+ };
60
+ const unwrapOptional = (ast) => {
61
+ if (ast._tag !== "Union") {
62
+ return ast;
63
+ }
64
+ const rest = ast.types.filter((each) => each._tag !== "UndefinedKeyword");
65
+ return rest.length === 1 ? rest[0] : ast;
66
+ };
67
+ /** The encoded base of a type (what is typed) and its decoded base, the refinements met on the way down, and the titles. */
68
+ const baseOf = (given, side, refinements = []) => {
69
+ const ast = unwrapOptional(given);
70
+ if (ast._tag === "Refinement") {
71
+ refinements.push(ast);
72
+ return baseOf(ast.from, side, refinements);
73
+ }
74
+ if (ast._tag === "Transformation") {
75
+ return baseOf(side === "encoded" ? ast.from : ast.to, side, refinements);
76
+ }
77
+ if (ast._tag === "Suspend") {
78
+ return baseOf(ast.f(), side, refinements);
79
+ }
80
+ return { base: ast, refinements };
81
+ };
82
+ const literalsOf = (ast) => {
83
+ if (ast._tag === "Literal") {
84
+ return typeof ast.literal === "bigint" || ast.literal === null ? undefined : [ast.literal];
85
+ }
86
+ if (ast._tag === "Union") {
87
+ const each = ast.types.map(literalsOf);
88
+ return each.every((one) => one !== undefined) ? each.flat() : undefined;
89
+ }
90
+ return undefined;
91
+ };
92
+ const describeField = (path, ps) => {
93
+ const name = path.join(".");
94
+ const encoded = baseOf(ps.type, "encoded");
95
+ const decoded = baseOf(ps.type, "decoded");
96
+ const chain = [...encoded.refinements, ...decoded.refinements.filter((each) => !encoded.refinements.includes(each))];
97
+ const title = titleOf(ps) ?? titleOf(ps.type) ?? chain.map(titleOf).find((each) => each !== undefined);
98
+ if (title === undefined) {
99
+ throw new FormError(`Form: the field "${name}" has no title: give the schema \`.annotations({ title: "…" })\`, the label the person reads (and the name a screen reader says)`);
100
+ }
101
+ const optional = ps.isOptional || (ps.type._tag === "Union" && ps.type.types.some((each) => each._tag === "UndefinedKeyword"));
102
+ const constraints = Object.assign({}, ...chain.map((each) => each.annotations[AST.JSONSchemaAnnotationId] ?? {}));
103
+ const hasMessage = chain.some((each) => each.annotations[AST.MessageAnnotationId] !== undefined) || ps.type.annotations[AST.MessageAnnotationId] !== undefined;
104
+ const typed = encoded.base;
105
+ const literals = literalsOf(typed);
106
+ let kind;
107
+ let choices = [];
108
+ let redacted = false;
109
+ if (typed._tag === "StringKeyword" && decoded.base._tag === "Declaration") {
110
+ kind = "password";
111
+ redacted = true;
112
+ }
113
+ else if (typed._tag === "StringKeyword") {
114
+ kind = decoded.base._tag === "NumberKeyword" ? "number" : "text";
115
+ }
116
+ else if (typed._tag === "NumberKeyword") {
117
+ kind = "number";
118
+ }
119
+ else if (typed._tag === "BooleanKeyword" || (literals !== undefined && literals.length === 1 && typeof literals[0] === "boolean")) {
120
+ kind = "boolean";
121
+ }
122
+ else if (literals !== undefined && literals.every((each) => typeof each === "string")) {
123
+ kind = "choice";
124
+ choices = literals;
125
+ }
126
+ else if (typed._tag === "TypeLiteral" || typed._tag === "TupleType") {
127
+ throw new FormError(`Form: the field "${name}" is ${typed._tag === "TupleType" ? "a list" : "a record"} where a value is needed (lists of fields are not supported yet; flat and nested records are)`);
128
+ }
129
+ else {
130
+ throw new FormError(`Form: the field "${name}" has a type a form cannot draw (${typed._tag}): text, a number typed as text, a boolean, a closed set of strings, or a secret`);
131
+ }
132
+ return { name, path, title, kind, required: !optional && kind !== "boolean", choices, constraints, redacted, hasMessage };
133
+ };
134
+ /** The message the author wrote on a field's type (or on the field), by field name: what the field says whichever of its checks fails. */
135
+ const fieldMessagesOf = (ast, path = []) => {
136
+ const found = new Map();
137
+ const record = baseOf(ast, "decoded").base;
138
+ if (record._tag !== "TypeLiteral") {
139
+ return found;
140
+ }
141
+ for (const ps of record.propertySignatures) {
142
+ if (typeof ps.name !== "string") {
143
+ continue;
144
+ }
145
+ const here = [...path, ps.name];
146
+ const inner = baseOf(ps.type, "encoded").base;
147
+ if (inner._tag === "TypeLiteral") {
148
+ fieldMessagesOf(inner, here).forEach((value, key) => found.set(key, value));
149
+ continue;
150
+ }
151
+ const said = ps.annotations[AST.MessageAnnotationId] ?? unwrapOptional(ps.type).annotations[AST.MessageAnnotationId];
152
+ if (typeof said === "function") {
153
+ found.set(here.join("."), said);
154
+ }
155
+ }
156
+ return found;
157
+ };
158
+ const describe = (ast, path = []) => {
159
+ const { base } = baseOf(ast, "decoded");
160
+ const record = base._tag === "TypeLiteral" ? base : baseOf(ast, "encoded").base;
161
+ if (record._tag !== "TypeLiteral") {
162
+ throw new FormError("Form: a form is made from a struct: `Schema.Struct({ … })`");
163
+ }
164
+ return record.propertySignatures.flatMap((ps) => {
165
+ if (typeof ps.name !== "string") {
166
+ return [];
167
+ }
168
+ const here = [...path, ps.name];
169
+ const inner = baseOf(ps.type, "encoded").base;
170
+ return inner._tag === "TypeLiteral" ? describe(inner, here) : [describeField(here, ps)];
171
+ });
172
+ };
173
+ const leaves = (issue, path, spoken = false) => {
174
+ switch (issue._tag) {
175
+ case "Pointer":
176
+ return leaves(issue.issue, [...path, ...(Array.isArray(issue.path) ? issue.path : [issue.path]).map(String)], spoken);
177
+ case "Composite":
178
+ return (Array.isArray(issue.issues) ? issue.issues : [issue.issues]).flatMap((each) => leaves(each, path, spoken));
179
+ case "Refinement":
180
+ // A rule over a whole struct reports at the paths it names (a Pointer inside): those are the leaves, not the rule.
181
+ if (issue.kind === "From") {
182
+ return leaves(issue.issue, path, spoken);
183
+ }
184
+ return issue.issue._tag === "Pointer" || issue.issue._tag === "Composite" ? leaves(issue.issue, path, true) : [{ path, issue, spoken }];
185
+ case "Transformation":
186
+ return leaves(issue.issue, path, spoken);
187
+ default:
188
+ return [{ path, issue, spoken }];
189
+ }
190
+ };
191
+ const codeOf = (issue) => {
192
+ const ast = "ast" in issue ? issue.ast : undefined;
193
+ const id = ast?.annotations[SCHEMA_ID];
194
+ const params = ast?.annotations[AST.JSONSchemaAnnotationId] ?? {};
195
+ return { code: typeof id === "symbol" ? (id.description ?? "").replace(/^effect\/SchemaId\//, "") : issue._tag, params };
196
+ };
197
+ const summary = (descriptor) => {
198
+ const { minLength, maxLength, minimum, maximum } = descriptor.constraints;
199
+ if (descriptor.kind === "choice") {
200
+ return `Choose one of: ${descriptor.choices.join(", ")}`;
201
+ }
202
+ if (minimum !== undefined && maximum !== undefined) {
203
+ return `Enter a number from ${minimum} to ${maximum}`;
204
+ }
205
+ if (minLength !== undefined) {
206
+ return `Enter at least ${minLength} characters`;
207
+ }
208
+ if (maxLength !== undefined) {
209
+ return `Enter at most ${maxLength} characters`;
210
+ }
211
+ return descriptor.kind === "boolean" ? "This must be checked" : "Enter a valid value";
212
+ };
213
+ // ---- making a form ----------------------------------------------------------------------------------------------------------------------------------------------------
214
+ const hash = (text) => {
215
+ let value = 5381;
216
+ for (let at = 0; at < text.length; at += 1) {
217
+ value = ((value << 5) + value + text.charCodeAt(at)) >>> 0;
218
+ }
219
+ return value.toString(36);
220
+ };
221
+ const setIn = (target, path, value) => {
222
+ let at = target;
223
+ for (const step of path.slice(0, -1)) {
224
+ at = (at[step] ??= {});
225
+ }
226
+ at[path[path.length - 1]] = value;
227
+ };
228
+ export function make(schema, options = {}) {
229
+ const fields = describe(schema.ast);
230
+ const byName = new Map(fields.map((each) => [each.name, each]));
231
+ const fieldMessages = fieldMessagesOf(schema.ast);
232
+ const warnings = fields
233
+ .filter((each) => !each.hasMessage && Object.keys(each.constraints).length > 0)
234
+ .map((each) => `${each.name}: its constraints (${Object.keys(each.constraints).join(", ")}) have no message of their own: add \`message\` to the schema, or a \`message\` function to the form's options`);
235
+ const stateSchema = stateSchemaOf(fields);
236
+ const given = (options.initial ?? {});
237
+ const valueOf = (descriptor) => {
238
+ const at = descriptor.path.reduce((value, step) => (typeof value === "object" && value !== null ? value[step] : undefined), given);
239
+ return typeof at === "boolean" ? at : at === undefined ? (descriptor.kind === "boolean" ? false : "") : String(at);
240
+ };
241
+ const initial = { values: Object.fromEntries(fields.map((each) => [each.name, valueOf(each)])), touched: {}, attempts: 0, remote: {} };
242
+ const version = hash(fields.map((each) => `${each.name}:${each.kind}`).join("|"));
243
+ const known = (name) => {
244
+ const found = byName.get(name);
245
+ if (found === undefined) {
246
+ throw new FormError(`Form: there is no field "${name}" (the fields: ${fields.map((each) => each.name).join(", ")})`);
247
+ }
248
+ return found;
249
+ };
250
+ /** What the schema says is wrong with what is typed: a message for each field (and `""` for the form as a whole), the first the schema gives. */
251
+ const problems = (state) => {
252
+ const encoded = {};
253
+ for (const each of fields) {
254
+ const value = state.values[each.name];
255
+ if (typeof value === "string" && value === "" && !each.required) {
256
+ continue;
257
+ }
258
+ if (each.kind === "choice" && value === "") {
259
+ continue;
260
+ }
261
+ setIn(encoded, each.path, value);
262
+ }
263
+ const result = Schema.decodeUnknownEither(schema, { errors: "all", onExcessProperty: "ignore" })(encoded);
264
+ if (Either.isRight(result)) {
265
+ return { value: result.right, errors: new Map() };
266
+ }
267
+ const errors = new Map();
268
+ for (const { path, issue, spoken } of leaves(result.left.issue, [])) {
269
+ const name = path.join(".");
270
+ if (errors.has(name)) {
271
+ continue;
272
+ }
273
+ const descriptor = byName.get(name);
274
+ const { code, params } = codeOf(issue);
275
+ const own = ("ast" in issue && issue.ast.annotations[AST.MessageAnnotationId] !== undefined) ||
276
+ (spoken && "message" in issue && (typeof issue.message === "string" || (Option.isOption(issue.message) && Option.isSome(issue.message))));
277
+ const ofField = fieldMessages.get(name);
278
+ const said = own || ofField === undefined || issue._tag === "Missing" ? undefined : ofField(issue);
279
+ const fromField = typeof said === "string" ? said : typeof said === "object" && said !== null && "message" in said ? String(said.message) : undefined;
280
+ // The author's message on the check, else on the field (it speaks for every check of the field), else "Required", the application's words, the schema's own words.
281
+ const message = (own ? ParseResult.TreeFormatter.formatIssueSync(issue) : undefined) ??
282
+ fromField ??
283
+ (issue._tag === "Missing" ? "Required" : undefined) ??
284
+ options.message?.({ path, code, params, actual: descriptor?.redacted === true ? undefined : issue.actual }) ??
285
+ (descriptor === undefined ? ParseResult.TreeFormatter.formatIssueSync(issue) : summary(descriptor));
286
+ errors.set(name, message);
287
+ }
288
+ return { errors };
289
+ };
290
+ const touchedAll = (state) => ({ ...state, attempts: state.attempts + 1, touched: Object.fromEntries(fields.map((each) => [each.name, true])) });
291
+ const view = ((state, from) => {
292
+ if (typeof state === "function") {
293
+ return state(ViewSchema, (application) => view(from(application)));
294
+ }
295
+ const found = problems(state).errors;
296
+ const shown = (name) => state.remote[name] ?? (state.touched[name] === true || state.attempts > 0 ? (found.get(name) ?? "") : "");
297
+ const views = fields.map((each) => {
298
+ const value = state.values[each.name];
299
+ const error = shown(each.name);
300
+ return {
301
+ name: each.name,
302
+ title: each.title,
303
+ kind: each.kind,
304
+ input: each.kind === "password" ? "password" : "text",
305
+ required: each.required,
306
+ choices: each.choices,
307
+ value: typeof value === "string" ? value : "",
308
+ checked: value === true,
309
+ invalid: error !== "",
310
+ error,
311
+ errorId: `${each.name.replace(/\./g, "-")}-error`,
312
+ touched: state.touched[each.name] === true,
313
+ dirty: value !== initial.values[each.name],
314
+ };
315
+ });
316
+ const count = views.filter((each) => each.invalid).length;
317
+ return {
318
+ fields: views,
319
+ valid: found.size === 0 && Object.keys(state.remote).length === 0,
320
+ message: state.attempts > 0 && count > 0 ? `${count} ${count === 1 ? "field needs" : "fields need"} attention` : "",
321
+ first: views.find((each) => each.invalid)?.name ?? "",
322
+ submitted: state.attempts > 0,
323
+ };
324
+ });
325
+ const without = (record, name) => Object.fromEntries(Object.entries(record).filter(([key]) => key !== name));
326
+ const form = {
327
+ fields,
328
+ initial,
329
+ standard: Schema.standardSchemaV1(schema),
330
+ warnings,
331
+ field: () => ({ [FIELD]: true, [FORM]: true, schema: stateSchema, initial }),
332
+ view,
333
+ edit: (state, name, value) => {
334
+ known(name);
335
+ return { ...state, values: { ...state.values, [name]: value }, remote: without(state.remote, name) };
336
+ },
337
+ check: (state, name, value) => {
338
+ known(name);
339
+ return { ...state, values: { ...state.values, [name]: value }, touched: { ...state.touched, [name]: true }, remote: without(state.remote, name) };
340
+ },
341
+ blur: (state, name) => {
342
+ known(name);
343
+ return { ...state, touched: { ...state.touched, [name]: true } };
344
+ },
345
+ submit: (state) => {
346
+ const attempted = touchedAll(state);
347
+ const { value, errors } = problems(attempted);
348
+ return value !== undefined && Object.keys(state.remote).length === 0 && errors.size === 0 ? { ok: true, value, state: attempted } : { ok: false, state: attempted };
349
+ },
350
+ reject: (state, { errors }) => {
351
+ for (const name of Object.keys(errors)) {
352
+ known(name);
353
+ }
354
+ return {
355
+ ...state,
356
+ attempts: state.attempts + 1,
357
+ touched: { ...state.touched, ...Object.fromEntries(Object.keys(errors).map((name) => [name, true])) },
358
+ remote: { ...state.remote, ...errors },
359
+ };
360
+ },
361
+ draft: (state) => ({ version, values: Object.fromEntries(fields.filter((each) => !each.redacted).map((each) => [each.name, state.values[each.name]])) }),
362
+ restore: (draft) => draft.version !== version
363
+ ? initial
364
+ : { ...initial, values: { ...initial.values, ...Object.fromEntries(Object.entries(draft.values).filter(([name]) => byName.has(name) && !byName.get(name).redacted)) } },
365
+ commands: (command, given) => {
366
+ const field = given.field;
367
+ const how = {
368
+ get: given.get ?? ((state) => state[field]),
369
+ set: given.set ?? ((state, form) => ({ ...state, [field]: form })),
370
+ submitted: given.submitted,
371
+ };
372
+ if (given.field === undefined && (given.get === undefined || given.set === undefined)) {
373
+ throw new FormError('Form: say where the form is: `field: "signup"`, or `get` and `set`');
374
+ }
375
+ return {
376
+ edit: command(Schema.Struct({ name: Schema.String, value: Schema.String }), ({ name, value }, current) => how.set(current, form.edit(how.get(current), name, value))),
377
+ check: command(Schema.Struct({ name: Schema.String, value: Schema.Boolean }), ({ name, value }, current) => how.set(current, form.check(how.get(current), name, value))),
378
+ blur: command(Schema.Struct({ name: Schema.String }), ({ name }, current) => how.set(current, form.blur(how.get(current), name))),
379
+ submit: command((current) => {
380
+ const result = form.submit(how.get(current));
381
+ const next = how.set(current, result.state);
382
+ return (result.ok ? how.submitted(next, result.value) : next);
383
+ }),
384
+ };
385
+ },
386
+ };
387
+ return form;
388
+ }
package/dist/index.d.ts CHANGED
@@ -21,6 +21,8 @@ export type Appearance = ColorScheme | "system";
21
21
  /** Window size classes, the ones Material Design and iOS size classes agree on closely enough to design for. */
22
22
  export type Viewport = "compact" | "medium" | "expanded";
23
23
  export declare const FIELD: unique symbol;
24
+ /** The brand of a state field that holds a form (`Form.field()`): the application then takes the person to the first field in error after a failed submit, with nothing written. */
25
+ export declare const FORM: unique symbol;
24
26
  /** A state field: its type and its default, declared together. */
25
27
  export interface Field<A = unknown> {
26
28
  readonly [FIELD]: true;
@@ -68,27 +70,111 @@ export interface Component {
68
70
  readonly [COMPONENT]: true;
69
71
  readonly template: Template;
70
72
  /** Present when the component declares its props (an omitted side of a declared interface is none). Absent: the contract is inferred from the template and its uses. */
71
- readonly props?: Readonly<Record<string, Schema.Schema<any, any, never>>>;
73
+ readonly props?: Readonly<Record<string, PropDeclaration>>;
72
74
  readonly events?: Readonly<Record<string, Schema.Schema<any, any, never>>>;
75
+ /** The prop that is the component's accessible name, when its template passes the name of whoever uses it through to a control (see `Contract.name`). */
76
+ readonly name?: string;
77
+ /** The state and commands of one occurrence of the component, when it has any (see `unit`). */
78
+ readonly unit?: Valance.Unit;
73
79
  }
80
+ export declare const SNIPPET: unique symbol;
81
+ /** A prop that is a piece of template: what an occurrence writes between its tags (`children`), or as a `{#snippet}` of that name among them. */
82
+ export interface SnippetDeclaration {
83
+ readonly [SNIPPET]: true;
84
+ /** The types of the snippet's parameters, in order; `{@render name(a, b)}` passes exactly these. */
85
+ readonly params: ReadonlyArray<Schema.Schema<any, any, never>>;
86
+ /** Whether an occurrence may leave it out. Rendering it then needs `{#if has(name)}` around the render (`possibly-absent-snippet`). */
87
+ readonly isOptional: boolean;
88
+ /** The same snippet, which an occurrence may leave out: `snippet(Item).optional()`. */
89
+ optional(): SnippetDeclaration;
90
+ }
91
+ /**
92
+ * Declares a prop whose value is a snippet, with the types of its parameters: `children: snippet()` is the content written between the component's tags; `row: snippet(Item, Schema.Number)` is a
93
+ * `{#snippet row(item, index)}` an occurrence passes and the template renders with `{@render row(item, index)}`. The prop named `children` is reserved for the content between the tags and takes no
94
+ * parameters. A snippet is not data: it has no default, is not a field of a record or an event's payload, and is declared on a component that has a template. `snippet(...).optional()` is one an occurrence may leave out (an absent one draws nothing), and `children: snippet().optional()` is content an occurrence may leave out.
95
+ */
96
+ export declare const snippet: (...params: ReadonlyArray<Schema.Schema<any, any, never>>) => SnippetDeclaration;
97
+ /** Whether a declaration is a snippet's. */
98
+ export declare const isSnippet: (value: unknown) => value is SnippetDeclaration;
99
+ /** A prop is declared by its schema, by `field(schema, default)` when an occurrence that leaves it out should get a value, or by `snippet(…)` when it is a piece of template. */
100
+ export type PropDeclaration = Schema.Schema<any, any, never> | Field<any> | SnippetDeclaration;
101
+ /** Whether a prop is declared with a default. */
102
+ export declare const isFormField: (value: unknown) => boolean;
103
+ export declare const isField: (value: unknown) => value is Field;
74
104
  export interface Contract<P extends string, E extends string> {
75
105
  /**
76
- * The inputs the template reads, each with its type. Optional when the schema admits `undefined`. A prop has no default: the model has none. With neither `props` nor `events` the contract is
77
- * inferred; with either, the interface is exactly what is written (an omitted side is none).
106
+ * The inputs the template reads, each with its type. Optional when the schema admits `undefined`. `field(schema, default)` gives a prop a default: an occurrence of the component that leaves
107
+ * the prop out gets the default (for a component with a template; a primitive ignores it), and the prop may then be left out. A string-literal union (`Schema.Literal("a", "b")`) is a closed set:
108
+ * the model checks every literal written for it. With neither `props` nor `events` the contract is inferred; with either, the interface is exactly what is written (an omitted side is none).
78
109
  */
79
110
  readonly props?: {
80
- readonly [K in P]: Schema.Schema<any, any, never>;
111
+ readonly [K in P]: PropDeclaration;
81
112
  };
82
113
  /** The events the template raises, each with its payload's schema; `Schema.Void` is no payload. */
83
114
  readonly events?: {
84
115
  readonly [K in E]: Schema.Schema<any, any, never>;
85
116
  };
117
+ /**
118
+ * The prop that is the component's accessible name: a component whose template draws a control and takes its name from whoever uses it (a labelled checkbox of the design system) says which
119
+ * prop that is, and the toolchain holds both sides to it. The prop must be a string every use writes (the model reports a use that omits it, where it is written), and the template must read it;
120
+ * a control in the template that has no name of its own (no `label`, not inside a `<label>`) is then not reported by `unnamed-control`, because the name arrives from the use.
121
+ */
122
+ readonly name?: P;
123
+ /**
124
+ * State that belongs to one occurrence of the component, such as whether a disclosure is open or which tab is chosen: `unit({ state, commands })`. The template reads its fields and runs its commands
125
+ * by name, like any others; the application never sees them. A component with a unit states its interface (`props` and `events`, empty if it has none).
126
+ */
127
+ readonly unit?: Valance.Unit;
86
128
  }
87
129
  /**
88
130
  * Defines a component: its template and an optional contract. Register it once, in `defineApp`'s `components`; the templates that write its tag use it, and nothing about its interface is
89
131
  * written again there. Where the toolchain has generated the template's type, a contract that disagrees with the template's keys is a compile-time error.
90
132
  */
91
133
  export declare function component<P extends string = string, E extends string = string>(template: Template<P, E>, contract?: Contract<NoInfer<P>, NoInfer<E>>): Component;
134
+ /** What a unit's commands change: some of its fields. */
135
+ type Changes<S> = Partial<S>;
136
+ /** One command of a unit: a function of the state as the person saw it that returns the fields it changes, or the same with parameters and the events it announces. */
137
+ export type UnitCommandSpec<S, P extends Schema.Struct.Fields = Schema.Struct.Fields> = ((state: S) => Changes<S>) | {
138
+ /** The command's parameters, in order: `{ n: Schema.Number }`. Each is a parameter of the command in the model. */
139
+ readonly params?: P;
140
+ readonly run: (state: S, args: Schema.Struct.Type<P>) => Changes<S>;
141
+ /** Events of the component the command announces to its caller, each with the payload it carries, worked out from the state the command leaves: `{ change: (next) => next.at }`. */
142
+ readonly raises?: Readonly<Record<string, (next: S, args: Schema.Struct.Type<P>) => unknown>>;
143
+ };
144
+ /** A command of a unit as `unit` takes it: any parameters (they are typed where the command is written, with `unitCommand`). */
145
+ type UnitCommandInput<S> = ((state: S) => Changes<S>) | {
146
+ readonly params?: Schema.Struct.Fields;
147
+ readonly run: (state: S, args: any) => Changes<S>;
148
+ readonly raises?: Readonly<Record<string, (next: S, args: any) => unknown>>;
149
+ };
150
+ /** A command with parameters, typed: the parameters' types reach `run` and `raises`. */
151
+ export declare const unitCommand: <S, P extends Schema.Struct.Fields>(spec: {
152
+ readonly params: P;
153
+ readonly run: (state: S, args: Schema.Struct.Type<P>) => Changes<S>;
154
+ readonly raises?: Readonly<Record<string, (next: S, args: Schema.Struct.Type<P>) => unknown>>;
155
+ }) => UnitCommandSpec<S, P>;
156
+ /**
157
+ * Declares the state and commands of one occurrence of a component (Tier 2; a component with no unit needs none of it). The state is typed with defaults, as application state is; a command is a
158
+ * pure function of the state as the person saw it that returns the fields it changes:
159
+ *
160
+ * export default component(template, {
161
+ * props: { label: Schema.String },
162
+ * events: {},
163
+ * unit: unit({ state: { open: field(Schema.Boolean, false) }, commands: { toggle: (state) => ({ open: !state.open }) } }),
164
+ * });
165
+ *
166
+ * The template reads `open` and runs `toggle()`; the application holds neither. The state belongs to the occurrence: it follows its row when a keyed list is re-sorted, and starts again from the defaults
167
+ * when the occurrence leaves the page and returns. State that must outlive that (or that another part of the application reads) is application state, not a unit. A field named in `controllable` is
168
+ * owned by the caller when it writes a prop of the same name (the prop must be declared, optional and of the same type): the prop is then the value, the unit's change to it is not applied, and
169
+ * `raises` tells the caller what the person chose.
170
+ */
171
+ export declare const unit: <F extends Fields>(definition: {
172
+ readonly state: F;
173
+ readonly commands: {
174
+ readonly [name: string]: UnitCommandInput<StateOf<F>>;
175
+ };
176
+ readonly controllable?: ReadonlyArray<Extract<keyof F, string>>;
177
+ }) => Valance.Unit<StateOf<F>>;
92
178
  /**
93
179
  * What the document's title is made of. `page` is the page's own part (default: its title, from the Markdown front matter), `app` the application's name (default: the site's name), `status` what is
94
180
  * going on right now and is not part of any page (an unread count, "Saving…"): it is written first, in parentheses. The project's format in \`valance.json\` (`"title": "{page} · {site}"`) puts them
@@ -122,6 +208,12 @@ export interface AppDefinition<S extends StateInput = {}> {
122
208
  readonly components?: ReadonlyArray<Component>;
123
209
  /** Built-in components (`heading`, `runs`, …) you replace with your own template of the same name. */
124
210
  readonly override?: ReadonlyArray<Component>;
211
+ /**
212
+ * Which component a tag means when two share a name (Tier 2: nothing to write until there is a collision). A library's component has the qualified id `package/name` (`acme-ui/disclosure`); two
213
+ * libraries that both have a `disclosure`, or a library component named like a built-in, are a compile error that names both ids, and `resolve: { disclosure: "acme-ui/disclosure" }` says which one the
214
+ * tag is. The others are left out of the program, and a library template that writes the tag gets the one chosen. A built-in is replaced by the chosen library component, as `override` would.
215
+ */
216
+ readonly resolve?: Readonly<Record<string, string>>;
125
217
  /**
126
218
  * Application state beyond the page: `{ theme: field(Schema.Literal("light", "dark"), "light") }`. Its fields become names a layout can read. A hand-built `{ schema, initial }` is also accepted, for
127
219
  * hydration and shared schemas.
@@ -201,3 +293,4 @@ export declare const derivedOf: (author: {
201
293
  readonly derived?: AppDefinition<any>["derived"];
202
294
  } | undefined) => Readonly<Record<string, Derived<any>>>;
203
295
  export declare const defineApp: <S extends StateInput = {}>(definition: AppDefinition<S>) => DefinedApp;
296
+ export * as Form from "./form.js";
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
+ import { UNIT } from "@valancex/valance";
2
+ import { Schema } from "effect";
1
3
  // ---- state -------------------------------------------------------------------------------------------------------------------------------------------------
2
4
  export const FIELD = Symbol.for("valancex.common.field");
5
+ /** The brand of a state field that holds a form (`Form.field()`): the application then takes the person to the first field in error after a failed submit, with nothing written. */
6
+ export const FORM = Symbol.for("valancex.common.form");
3
7
  /**
4
8
  * Declares one field of the application's state with its type and its default in one place. The framework builds the state's schema and its initial value from the fields; the default is
5
9
  * checked against the type (by TypeScript, and again when the application is built).
@@ -8,6 +12,24 @@ export const DERIVED = Symbol.for("valancex.common.derived");
8
12
  const derive = (schema, from) => ({ [DERIVED]: true, schema: schema, from });
9
13
  export const field = (schema, initial) => ({ [FIELD]: true, schema: schema, initial });
10
14
  export const COMPONENT = Symbol.for("valancex.common.component");
15
+ export const SNIPPET = Symbol.for("valancex.common.snippet");
16
+ /**
17
+ * Declares a prop whose value is a snippet, with the types of its parameters: `children: snippet()` is the content written between the component's tags; `row: snippet(Item, Schema.Number)` is a
18
+ * `{#snippet row(item, index)}` an occurrence passes and the template renders with `{@render row(item, index)}`. The prop named `children` is reserved for the content between the tags and takes no
19
+ * parameters. A snippet is not data: it has no default, is not a field of a record or an event's payload, and is declared on a component that has a template. `snippet(...).optional()` is one an occurrence may leave out (an absent one draws nothing), and `children: snippet().optional()` is content an occurrence may leave out.
20
+ */
21
+ export const snippet = (...params) => declare(params, false);
22
+ const declare = (params, isOptional) => ({
23
+ [SNIPPET]: true,
24
+ params,
25
+ isOptional,
26
+ optional: () => declare(params, true),
27
+ });
28
+ /** Whether a declaration is a snippet's. */
29
+ export const isSnippet = (value) => typeof value === "object" && value !== null && SNIPPET in value;
30
+ /** Whether a prop is declared with a default. */
31
+ export const isFormField = (value) => typeof value === "object" && value !== null && FORM in value;
32
+ export const isField = (value) => typeof value === "object" && value !== null && FIELD in value;
11
33
  /**
12
34
  * Defines a component: its template and an optional contract. Register it once, in `defineApp`'s `components`; the templates that write its tag use it, and nothing about its interface is
13
35
  * written again there. Where the toolchain has generated the template's type, a contract that disagrees with the template's keys is a compile-time error.
@@ -18,11 +40,53 @@ export function component(template, contract = {}) {
18
40
  template,
19
41
  ...(contract.props === undefined ? {} : { props: contract.props }),
20
42
  ...(contract.events === undefined ? {} : { events: contract.events }),
43
+ ...(contract.name === undefined ? {} : { name: contract.name }),
44
+ ...(contract.unit === undefined ? {} : { unit: contract.unit }),
21
45
  };
22
46
  }
47
+ /** A command with parameters, typed: the parameters' types reach `run` and `raises`. */
48
+ export const unitCommand = (spec) => spec;
49
+ /**
50
+ * Declares the state and commands of one occurrence of a component (Tier 2; a component with no unit needs none of it). The state is typed with defaults, as application state is; a command is a
51
+ * pure function of the state as the person saw it that returns the fields it changes:
52
+ *
53
+ * export default component(template, {
54
+ * props: { label: Schema.String },
55
+ * events: {},
56
+ * unit: unit({ state: { open: field(Schema.Boolean, false) }, commands: { toggle: (state) => ({ open: !state.open }) } }),
57
+ * });
58
+ *
59
+ * The template reads `open` and runs `toggle()`; the application holds neither. The state belongs to the occurrence: it follows its row when a keyed list is re-sorted, and starts again from the defaults
60
+ * when the occurrence leaves the page and returns. State that must outlive that (or that another part of the application reads) is application state, not a unit. A field named in `controllable` is
61
+ * owned by the caller when it writes a prop of the same name (the prop must be declared, optional and of the same type): the prop is then the value, the unit's change to it is not applied, and
62
+ * `raises` tells the caller what the person chose.
63
+ */
64
+ export const unit = (definition) => {
65
+ const entries = Object.entries(definition.state);
66
+ const fields = Object.fromEntries(entries.map(([name, each]) => [name, { schema: each.schema, initial: each.initial }]));
67
+ const schema = Schema.Struct(Object.fromEntries(entries.map(([name, each]) => [name, each.schema])));
68
+ return {
69
+ [UNIT]: true,
70
+ schema: schema,
71
+ fields,
72
+ controllable: definition.controllable ?? [],
73
+ commands: Object.fromEntries(Object.entries(definition.commands).map(([name, command]) => [
74
+ name,
75
+ typeof command === "function"
76
+ ? { run: command }
77
+ : {
78
+ ...(command.params === undefined ? {} : { input: Schema.Struct(command.params) }),
79
+ run: command.run,
80
+ ...(command.raises === undefined ? {} : { raises: command.raises }),
81
+ },
82
+ ])),
83
+ };
84
+ };
23
85
  /** The brand that says a value came from `defineApp`. */
24
86
  export const APP = Symbol.for("valancex.common");
25
87
  /** Declares the application's component tree, state and commands. It starts nothing; the framework runs it. */
26
88
  /** What `derived` makes, for the toolchain: the function is called with the one `derive`. */
27
89
  export const derivedOf = (author) => author?.derived?.(derive) ?? {};
28
90
  export const defineApp = (definition) => ({ ...definition, [APP]: true });
91
+ // ---- forms ---------------------------------------------------------------------------------------------------------------------------------------------------
92
+ export * as Form from "./form.js";
@@ -11,3 +11,4 @@ export * from "./store.js";
11
11
  export * from "./open.js";
12
12
  export * from "./navigation.js";
13
13
  export * from "./platform.js";
14
+ export { earlyHead, earlyShell, earlyShellLimit } from "./early.js";
package/dist/internal.js CHANGED
@@ -13,3 +13,4 @@ export * from "./store.js";
13
13
  export * from "./open.js";
14
14
  export * from "./navigation.js";
15
15
  export * from "./platform.js";
16
+ export { earlyHead, earlyShell, earlyShellLimit } from "./early.js";