@crombolo/render 0.0.0-stage → 0.9.1

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/src/forms.ts ADDED
@@ -0,0 +1,557 @@
1
+ /**
2
+ * Rendering a form definition to HTML.
3
+ *
4
+ * Built with `element()` rather than JSX syntax: `jsxImportSource` is `@crombolo/crombolo`,
5
+ * which depends on this package, so a `.tsx` file here would be a cycle.
6
+ *
7
+ * Progressive enhancement is the baseline. The form is a real
8
+ * `<form method="post">` that works with no JavaScript: conditional fields are
9
+ * all present in the markup, hidden when the current answers hide them, and
10
+ * carry their condition as data so a script can reveal them without a round
11
+ * trip. The server re-evaluates every condition anyway (`form-submissions.ts`),
12
+ * so what a script does to the DOM can only change what a visitor sees.
13
+ *
14
+ * Theming is composition, not directories. `components` overrides the renderer
15
+ * for a field type, and a site that wants different markup altogether writes it
16
+ * from the definition — which is three lines of TSX and needs nothing from here.
17
+ * `docs/18-forms.md` says why this diverges from Umbraco's theme folders.
18
+ */
19
+ import {
20
+ FORM_HONEYPOT_FIELD,
21
+ FORM_RENDERED_FIELD,
22
+ FORM_TOKEN_FIELD,
23
+ type FormFieldError,
24
+ type FormFieldType,
25
+ type FormSubmissionState,
26
+ formFieldType,
27
+ isShown,
28
+ type SchemaForm,
29
+ type SchemaFormField,
30
+ type SchemaFormGroup,
31
+ type SchemaFormPage,
32
+ type SubmittedValues,
33
+ } from '@crombolo/core'
34
+ import { RawHtml } from './html.ts'
35
+ import { type Child, element, Fragment } from './jsx-runtime.ts'
36
+
37
+ /** Where a submission is posted; the form's key is the only identifier it needs. */
38
+ export const FORM_SUBMIT_PATH = '/crombolo/forms'
39
+
40
+ /**
41
+ * How a rendered form gets its signed token.
42
+ *
43
+ * Process-wide rather than a prop or a context: the signing key is one
44
+ * immutable value per process, so there is no per-request state to interleave,
45
+ * and threading it through every view that draws a form would be noise. The
46
+ * server sets this once at boot; without it a form renders with no token, which
47
+ * is what a unit test sees and what the endpoint then refuses.
48
+ */
49
+ let signer: ((formKey: string, renderedAt: number) => string) | undefined
50
+
51
+ export function setFormTokenSigner(
52
+ sign: ((formKey: string, renderedAt: number) => string) | undefined,
53
+ ): void {
54
+ signer = sign
55
+ }
56
+
57
+ export const formAction = (form: SchemaForm): string =>
58
+ `${FORM_SUBMIT_PATH}/${encodeURIComponent(form.key ?? form.alias)}`
59
+
60
+ export interface FormFieldRenderProps {
61
+ field: SchemaFormField
62
+ /** What this field currently holds, after a failed submission or from a default. */
63
+ values: string[]
64
+ /** The message for this field, when the last submission failed on it. */
65
+ error: string | undefined
66
+ /** The input's `id`, already unique within the form. */
67
+ id: string
68
+ /** The name to post under, which is the field's alias. */
69
+ name: string
70
+ /**
71
+ * Whether the current answers show this field.
72
+ *
73
+ * A renderer must not mark a hidden field `required`: a browser with no
74
+ * JavaScript would then refuse to submit the form over a field nobody can
75
+ * see. `mandatory && shown` is the rule, and the server validates the real
76
+ * one anyway.
77
+ */
78
+ shown: boolean
79
+ }
80
+
81
+ export type FormFieldRenderer = (props: FormFieldRenderProps) => Child
82
+
83
+ export type FormFieldComponents = Partial<Record<FormFieldType, FormFieldRenderer>>
84
+
85
+ export interface FormProps {
86
+ /** The definition, as a `formPicker` property hands it over. */
87
+ form: SchemaForm | null | undefined
88
+ /** Defaults to this form's own endpoint. */
89
+ action?: string
90
+ /** The signed token the endpoint checks; omitted only in tests. */
91
+ token?: string
92
+ /** When the form was rendered, for the timing guard. Defaults to now. */
93
+ renderedAt?: number
94
+ /** Values to redisplay, after a submission the server refused. */
95
+ values?: SubmittedValues
96
+ errors?: readonly FormFieldError[]
97
+ components?: FormFieldComponents
98
+ /** Shown in place of the form, after a submission it accepted. */
99
+ message?: string
100
+ /**
101
+ * What just happened to a submission, as the page received it. Used only when
102
+ * its key matches this form, so two forms on one page do not show each
103
+ * other's errors. `values`, `errors` and `message` override it when given.
104
+ */
105
+ submission?: FormSubmissionState
106
+ className?: string
107
+ }
108
+
109
+ const attrs = (record: Record<string, unknown>): Record<string, unknown> => {
110
+ const out: Record<string, unknown> = {}
111
+ for (const [key, value] of Object.entries(record))
112
+ if (value !== undefined && value !== null && value !== false) out[key] = value
113
+ return out
114
+ }
115
+
116
+ const first = (values: readonly string[]): string => values[0] ?? ''
117
+
118
+ const checked = (values: readonly string[]): boolean =>
119
+ values.some((value) => value === 'true' || value === 'on' || value === '1')
120
+
121
+ /** The input types that are a plain `<input>` differing only by `type`. */
122
+ const INPUT_TYPES: Partial<Record<FormFieldType, string>> = {
123
+ shortAnswer: 'text',
124
+ email: 'email',
125
+ number: 'number',
126
+ date: 'date',
127
+ hidden: 'hidden',
128
+ }
129
+
130
+ function textInput({ field, values, id, name, error, shown }: FormFieldRenderProps): Child {
131
+ return element(
132
+ 'input',
133
+ attrs({
134
+ type: INPUT_TYPES[field.type] ?? 'text',
135
+ id,
136
+ name,
137
+ value: first(values),
138
+ required: (field.mandatory && shown) || undefined,
139
+ placeholder: field.placeholder,
140
+ maxlength: field.maxlength,
141
+ min: field.min,
142
+ max: field.max,
143
+ step: field.step,
144
+ autocomplete: field.autocomplete,
145
+ // The browser's own pattern check, with the server's as the authority.
146
+ pattern: field.pattern,
147
+ 'aria-invalid': error ? 'true' : undefined,
148
+ 'aria-describedby': describedBy(field, id, error),
149
+ }),
150
+ )
151
+ }
152
+
153
+ const describedBy = (
154
+ field: SchemaFormField,
155
+ id: string,
156
+ error: string | undefined,
157
+ ): string | undefined => {
158
+ const ids = [field.helpText ? `${id}-help` : '', error ? `${id}-error` : ''].filter(Boolean)
159
+ return ids.length > 0 ? ids.join(' ') : undefined
160
+ }
161
+
162
+ const DEFAULTS: Record<FormFieldType, FormFieldRenderer> = {
163
+ shortAnswer: textInput,
164
+ email: textInput,
165
+ number: textInput,
166
+ date: textInput,
167
+ hidden: textInput,
168
+
169
+ longAnswer: ({ field, values, id, name, error, shown }) =>
170
+ element(
171
+ 'textarea',
172
+ attrs({
173
+ id,
174
+ name,
175
+ rows: field.rows ?? 4,
176
+ required: (field.mandatory && shown) || undefined,
177
+ placeholder: field.placeholder,
178
+ maxlength: field.maxlength,
179
+ 'aria-invalid': error ? 'true' : undefined,
180
+ 'aria-describedby': describedBy(field, id, error),
181
+ children: first(values),
182
+ }),
183
+ ),
184
+
185
+ checkbox: ({ field, values, id, name, error, shown }) =>
186
+ element(
187
+ 'input',
188
+ attrs({
189
+ type: 'checkbox',
190
+ id,
191
+ name,
192
+ value: 'true',
193
+ checked: checked(values) || undefined,
194
+ required: (field.mandatory && shown) || undefined,
195
+ 'aria-invalid': error ? 'true' : undefined,
196
+ 'aria-describedby': describedBy(field, id, error),
197
+ }),
198
+ ),
199
+
200
+ // Consent reads as a checkbox whose label is the caption, because the wording
201
+ // of the consent *is* the question.
202
+ dataConsent: ({ field, values, id, name, error, shown }) =>
203
+ element(
204
+ 'input',
205
+ attrs({
206
+ type: 'checkbox',
207
+ id,
208
+ name,
209
+ value: 'true',
210
+ checked: checked(values) || undefined,
211
+ required: (field.mandatory && shown) || undefined,
212
+ 'aria-invalid': error ? 'true' : undefined,
213
+ 'aria-describedby': describedBy(field, id, error),
214
+ }),
215
+ ),
216
+
217
+ dropdown: ({ field, values, id, name, error, shown }) =>
218
+ element(
219
+ 'select',
220
+ attrs({
221
+ id,
222
+ name,
223
+ required: (field.mandatory && shown) || undefined,
224
+ 'aria-invalid': error ? 'true' : undefined,
225
+ 'aria-describedby': describedBy(field, id, error),
226
+ children: [
227
+ // An empty first option, so a mandatory dropdown starts unanswered
228
+ // rather than silently defaulting to whatever is first.
229
+ field.mandatory || first(values) === ''
230
+ ? element('option', { value: '', children: field.placeholder ?? '' })
231
+ : null,
232
+ ...field.values.map((option) =>
233
+ element(
234
+ 'option',
235
+ attrs({
236
+ value: option,
237
+ selected: option === first(values) || undefined,
238
+ children: option,
239
+ }),
240
+ ),
241
+ ),
242
+ ],
243
+ }),
244
+ ),
245
+
246
+ singleChoice: ({ field, values, id, name, shown }) =>
247
+ element('div', {
248
+ class: 'crombolo-form__choices',
249
+ role: 'radiogroup',
250
+ children: field.values.map((option, index) =>
251
+ element('label', {
252
+ class: 'crombolo-form__choice',
253
+ children: [
254
+ element(
255
+ 'input',
256
+ attrs({
257
+ type: 'radio',
258
+ id: `${id}-${index}`,
259
+ name,
260
+ value: option,
261
+ checked: option === first(values) || undefined,
262
+ required: (field.mandatory && shown) || undefined,
263
+ }),
264
+ ),
265
+ element('span', { children: option }),
266
+ ],
267
+ }),
268
+ ),
269
+ }),
270
+
271
+ multipleChoice: ({ field, values, id, name }) =>
272
+ element('div', {
273
+ class: 'crombolo-form__choices',
274
+ children: field.values.map((option, index) =>
275
+ element('label', {
276
+ class: 'crombolo-form__choice',
277
+ children: [
278
+ element(
279
+ 'input',
280
+ attrs({
281
+ type: 'checkbox',
282
+ id: `${id}-${index}`,
283
+ name,
284
+ value: option,
285
+ checked: values.includes(option) || undefined,
286
+ }),
287
+ ),
288
+ element('span', { children: option }),
289
+ ],
290
+ }),
291
+ ),
292
+ }),
293
+
294
+ fileUpload: ({ field, id, name, error, shown }) =>
295
+ element(
296
+ 'input',
297
+ attrs({
298
+ type: 'file',
299
+ id,
300
+ name,
301
+ multiple: field.multiple || undefined,
302
+ accept: field.accept.length > 0 ? field.accept.join(',') : undefined,
303
+ required: (field.mandatory && shown) || undefined,
304
+ 'aria-invalid': error ? 'true' : undefined,
305
+ 'aria-describedby': describedBy(field, id, error),
306
+ }),
307
+ ),
308
+
309
+ // Presentational: the content comes from the schema file, which a developer
310
+ // wrote and a deploy reviewed, so it is emitted as the markup it is.
311
+ titleAndDescription: ({ field }) =>
312
+ element(Fragment, {
313
+ children: [
314
+ element('h3', { class: 'crombolo-form__title', children: field.caption }),
315
+ field.content
316
+ ? element('div', {
317
+ class: 'crombolo-form__description',
318
+ setInnerHTML: { __html: field.content, dangerously: true },
319
+ })
320
+ : null,
321
+ ],
322
+ }),
323
+
324
+ richText: ({ field }) =>
325
+ element('div', {
326
+ class: 'crombolo-form__richtext',
327
+ setInnerHTML: { __html: field.content ?? '', dangerously: true },
328
+ }),
329
+ }
330
+
331
+ /** Which renderer draws a field: the site's override, else the built-in one. */
332
+ export function rendererFor(
333
+ type: FormFieldType,
334
+ components: FormFieldComponents | undefined,
335
+ ): FormFieldRenderer | undefined {
336
+ return components?.[type] ?? DEFAULTS[type]
337
+ }
338
+
339
+ function renderField(
340
+ field: SchemaFormField,
341
+ options: {
342
+ values: SubmittedValues
343
+ errors: readonly FormFieldError[]
344
+ components: FormFieldComponents | undefined
345
+ idPrefix: string
346
+ },
347
+ ): Child {
348
+ const info = formFieldType(field.type)
349
+ if (!info) return null
350
+ const renderer = rendererFor(field.type, options.components)
351
+ if (!renderer) return null
352
+
353
+ const id = `${options.idPrefix}-${field.alias}`
354
+ const error = options.errors.find((candidate) => candidate.field === field.alias)?.message
355
+ const values = options.values[field.alias] ?? (field.defaultValue ? [field.defaultValue] : [])
356
+ // A conditional field is always in the markup: a script needs something to
357
+ // reveal, and a visitor without one gets the server's answer on submit.
358
+ const shown = isShown(field.condition, options.values)
359
+ const control = renderer({ field, values, error, id, name: field.alias, shown })
360
+
361
+ if (field.type === 'hidden') return control
362
+
363
+ const classes = ['crombolo-form__field', `crombolo-form__field--${field.type}`]
364
+ if (field.cssClass) classes.push(field.cssClass)
365
+ if (error) classes.push('crombolo-form__field--invalid')
366
+
367
+ const labelled = info.presentational
368
+ ? [control]
369
+ : [
370
+ element('label', {
371
+ class: 'crombolo-form__label',
372
+ for: id,
373
+ children: [
374
+ field.caption,
375
+ field.mandatory
376
+ ? element('span', {
377
+ class: 'crombolo-form__required',
378
+ 'aria-hidden': 'true',
379
+ children: '*',
380
+ })
381
+ : null,
382
+ ],
383
+ }),
384
+ field.helpText
385
+ ? element('p', {
386
+ class: 'crombolo-form__help',
387
+ id: `${id}-help`,
388
+ children: field.helpText,
389
+ })
390
+ : null,
391
+ control,
392
+ error
393
+ ? element('p', {
394
+ class: 'crombolo-form__error',
395
+ id: `${id}-error`,
396
+ role: 'alert',
397
+ children: error,
398
+ })
399
+ : null,
400
+ ]
401
+
402
+ return element(
403
+ 'div',
404
+ attrs({
405
+ class: classes.join(' '),
406
+ hidden: shown ? undefined : true,
407
+ 'data-field': field.alias,
408
+ 'data-condition': field.condition ? JSON.stringify(field.condition) : undefined,
409
+ // So the script can put `required` back when it reveals the field; the
410
+ // markup could not carry it without breaking a no-JavaScript submit.
411
+ 'data-required': field.mandatory && !shown ? 'true' : undefined,
412
+ children: labelled,
413
+ }),
414
+ )
415
+ }
416
+
417
+ function renderGroup(group: SchemaFormGroup, options: Parameters<typeof renderField>[1]): Child {
418
+ const classes = ['crombolo-form__group']
419
+ if (group.columns > 1) classes.push(`crombolo-form__group--columns-${group.columns}`)
420
+ return element(
421
+ 'div',
422
+ attrs({
423
+ class: classes.join(' '),
424
+ hidden: isShown(group.condition, options.values) ? undefined : true,
425
+ 'data-condition': group.condition ? JSON.stringify(group.condition) : undefined,
426
+ style: group.columns > 1 ? `--crombolo-form-columns:${group.columns}` : undefined,
427
+ children: [
428
+ group.caption
429
+ ? element('legend', { class: 'crombolo-form__group-caption', children: group.caption })
430
+ : null,
431
+ ...group.fields.map((field) => renderField(field, options)),
432
+ ],
433
+ }),
434
+ )
435
+ }
436
+
437
+ function renderPage(
438
+ page: SchemaFormPage,
439
+ index: number,
440
+ options: Parameters<typeof renderField>[1],
441
+ ): Child {
442
+ return element(
443
+ 'fieldset',
444
+ attrs({
445
+ class: 'crombolo-form__page',
446
+ 'data-page': index,
447
+ hidden: isShown(page.condition, options.values) ? undefined : true,
448
+ 'data-condition': page.condition ? JSON.stringify(page.condition) : undefined,
449
+ children: [
450
+ page.caption
451
+ ? element('legend', { class: 'crombolo-form__page-caption', children: page.caption })
452
+ : null,
453
+ ...page.groups.map((group) => renderGroup(group, options)),
454
+ ],
455
+ }),
456
+ )
457
+ }
458
+
459
+ /**
460
+ * A form, as HTML.
461
+ *
462
+ * Every page is rendered into one form rather than one page at a time. A
463
+ * multi-page definition therefore submits in one go; stepping through pages with
464
+ * a round trip each is a refinement on top of this, not a different shape.
465
+ */
466
+ export function Form(props: FormProps): RawHtml {
467
+ const form = props.form
468
+ if (!form) return new RawHtml('')
469
+ const accepted =
470
+ props.message ??
471
+ (props.submission?.formKey === (form.key ?? form.alias) ? props.submission.message : undefined)
472
+ if (accepted !== undefined)
473
+ return element('div', {
474
+ class: 'crombolo-form crombolo-form--submitted',
475
+ role: 'status',
476
+ children: accepted,
477
+ })
478
+
479
+ // Only this form's own submission: a page with two forms must not show the
480
+ // other one's errors, and the key is what tells them apart.
481
+ const mine =
482
+ props.submission && props.submission.formKey === (form.key ?? form.alias)
483
+ ? props.submission
484
+ : undefined
485
+ const values = props.values ?? mine?.values ?? {}
486
+ const errors = props.errors ?? mine?.errors ?? []
487
+ const idPrefix = `form-${form.alias}`
488
+ const options = { values, errors, components: props.components, idPrefix }
489
+ const hasUpload = form.pages.some((page) =>
490
+ page.groups.some((group) => group.fields.some((field) => field.type === 'fileUpload')),
491
+ )
492
+
493
+ const classes = ['crombolo-form']
494
+ if (form.theme) classes.push(`crombolo-form--${form.theme}`)
495
+ if (props.className) classes.push(props.className)
496
+
497
+ const key = form.key ?? form.alias
498
+ const renderedAt = props.renderedAt ?? Date.now()
499
+ const token = props.token ?? signer?.(key, renderedAt)
500
+
501
+ return element(
502
+ 'form',
503
+ attrs({
504
+ method: 'post',
505
+ action: props.action ?? formAction(form),
506
+ // Only when there is a file to carry: urlencoded bodies are smaller and
507
+ // every other field posts fine without it.
508
+ enctype: hasUpload ? 'multipart/form-data' : undefined,
509
+ class: classes.join(' '),
510
+ 'data-form': form.alias,
511
+ novalidate: undefined,
512
+ children: [
513
+ errors.length > 0
514
+ ? element('div', {
515
+ class: 'crombolo-form__summary',
516
+ role: 'alert',
517
+ children: element('ul', {
518
+ children: errors.map((error) => element('li', { children: error.message })),
519
+ }),
520
+ })
521
+ : null,
522
+ ...form.pages.map((page, index) => renderPage(page, index, options)),
523
+ token ? element('input', { type: 'hidden', name: FORM_TOKEN_FIELD, value: token }) : null,
524
+ element('input', {
525
+ type: 'hidden',
526
+ name: FORM_RENDERED_FIELD,
527
+ value: String(renderedAt),
528
+ }),
529
+ // The honeypot: off-screen rather than `hidden`, because a bot that
530
+ // skips hidden inputs would skip this one too. `autocomplete=off` and a
531
+ // tab stop of -1 keep a real browser out of it.
532
+ form.honeypot
533
+ ? element('div', {
534
+ class: 'crombolo-form__honeypot',
535
+ 'aria-hidden': 'true',
536
+ style: 'position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden',
537
+ children: element('input', {
538
+ type: 'text',
539
+ name: FORM_HONEYPOT_FIELD,
540
+ value: '',
541
+ tabindex: '-1',
542
+ autocomplete: 'off',
543
+ }),
544
+ })
545
+ : null,
546
+ element('div', {
547
+ class: 'crombolo-form__actions',
548
+ children: element('button', {
549
+ type: 'submit',
550
+ class: 'crombolo-form__submit',
551
+ children: form.submitLabel ?? 'Submit',
552
+ }),
553
+ }),
554
+ ],
555
+ }),
556
+ )
557
+ }