@crombolo/render 0.0.0-stage → 0.10.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +36 -4
- package/src/context.ts +112 -0
- package/src/form-script.ts +112 -0
- package/src/forms.ts +557 -0
- package/src/html.ts +285 -0
- package/src/index.ts +16 -0
- package/src/jsx-attributes.ts +91 -0
- package/src/jsx-runtime.ts +334 -0
- package/src/loose/jsx-runtime.ts +21 -0
- package/src/media.ts +205 -0
- package/src/model.ts +303 -0
- package/src/published-cache.ts +865 -0
- package/src/redirects.ts +131 -0
- package/src/renderer.ts +374 -0
- package/src/seo.ts +209 -0
- package/src/sitemap.ts +99 -0
- package/src/snapshots.ts +398 -0
- package/src/url-segment.ts +34 -0
- package/src/values.ts +325 -0
- package/README.md +0 -3
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/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
|
+
}
|